All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.twitter.storehaus.mysql.MySQLStore.scala Maven / Gradle / Ivy

There is a newer version: 0.15.0-RC1
Show newest version
/*
 * Copyright 2013 Twitter Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may obtain
 * a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.twitter.storehaus.mysql

import com.twitter.finagle.exp.mysql.{ Client, Result, Parameter }
import com.twitter.storehaus.FutureOps
import com.twitter.storehaus.Store
import com.twitter.util.{ Await, Future, Time }

/**
  * @author Ruban Monu
  */

/** Factory for [[com.twitter.storehaus.mysql.MySqlStore]] instances. */
object MySqlStore {

  def apply(client: Client, table: String, kCol: String, vCol: String) =
    new MySqlStore(client, table, kCol, vCol)
}

/**
  * Simple storehaus wrapper over finagle-mysql.
  *
  * Assumes the underlying table's key and value columns are both strings.
  * Supported MySQL column types are: BLOB, TEXT, VARCHAR.
  *
  * The finagle-mysql client is required to set the user, database and create
  * the underlying table schema prior to this class being used.
  *
  * Storehaus-mysql also works with pre-populated MySQL tables, based on the assumption that the key column picked is unique.
  * Any table columns other than the picked key and value columns are ignored during reads and writes.
  *
  * Example usage:
  * {{{
  * import com.twitter.finagle.exp.mysql.Client
  * import com.twitter.storehaus.mysql.MySqlStore
  *
  * val client = Client("localhost:3306", "storehaususer", "test1234", "storehaus_test")
  * val schema = """CREATE TABLE `storehaus-mysql-test` (
  *       `key` varchar(40) DEFAULT NULL,
  *       `value` varchar(100) DEFAULT NULL
  *     ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"""
  * // or, use an existing pre-populated table.
  * client.query(schema).get
  * val store = MySqlStore(client, "storehaus-mysql-test", "key", "value")
  * }}}
  */
class MySqlStore(protected [mysql] val client: Client, table: String, kCol: String, vCol: String)
    extends Store[MySqlValue, MySqlValue] {

  protected val SELECT_SQL = "SELECT " + g(vCol) + " FROM " + g(table) + " WHERE " + g(kCol) + "=?"
  protected val MULTI_SELECT_SQL_PREFIX = "SELECT " + g(kCol) + ", " + g(vCol) + " FROM " + g(table) + " WHERE " + g(kCol) + " IN "

  protected val INSERT_SQL = "INSERT INTO " + g(table) + "(" + g(kCol) + "," + g(vCol) + ")" + " VALUES (?,?)"
  protected val MULTI_INSERT_SQL_PREFIX = "INSERT INTO " + g(table) + "(" + g(kCol) + "," + g(vCol) + ") VALUES "

  protected val UPDATE_SQL = "UPDATE " + g(table) + " SET " + g(vCol) + "=? WHERE " + g(kCol) + "=?"

  // update multiple rows together. e.g.
  // UDPATE table SET value = CASE key
  //   WHEN "key1" THEN "value1"
  //   WHEN "key2" THEN "value2"
  // END
  // WHERE key IN ("key1", "key2")
  protected val MULTI_UPDATE_SQL_PREFIX = "UPDATE " + g(table) + " SET " + g(vCol) + " = CASE " + g(kCol) + " "
  protected val MULTI_UPDATE_SQL_INFIX = " END WHERE " + g(kCol) + " IN "

  protected val DELETE_SQL = "DELETE FROM " + g(table) + " WHERE " + g(kCol) + "=?"
  protected val MULTI_DELETE_SQL_PREFIX = "DELETE FROM " + g(table) + " WHERE " + g(kCol) + " IN "

  protected val START_TXN_SQL = "START TRANSACTION"
  protected val COMMIT_TXN_SQL = "COMMIT"
  protected val ROLLBACK_TXN_SQL = "ROLLBACK"

  // prepared statements to be reused across gets and puts
  // TODO: should this be non-blocking? this is part of object construction, so maybe not?
  protected val selectStmt = client.prepare(SELECT_SQL)
  protected val insertStmt = client.prepare(INSERT_SQL)
  protected val updateStmt = client.prepare(UPDATE_SQL)
  protected val deleteStmt = client.prepare(DELETE_SQL)

  protected [mysql] def startTransaction : Future[Unit] = client.query(START_TXN_SQL).unit
  protected [mysql] def commitTransaction : Future[Unit] = client.query(COMMIT_TXN_SQL).unit
  protected [mysql] def rollbackTransaction : Future[Unit] = client.query(ROLLBACK_TXN_SQL).unit

  protected [mysql] def executeMultiInsert[K1 <: MySqlValue](kvs: Map[K1, MySqlValue]) = {
    val insertSql = MULTI_INSERT_SQL_PREFIX + Stream.continually("(?, ?)").take(kvs.size).mkString(",")
    val insertParams = kvs.map { kv =>
      List(MySqlStringInjection(kv._1).getBytes, MySqlStringInjection(kv._2).getBytes)
    }.toSeq.flatten.map(Parameter.wrap[Array[Byte]])
    client.prepare(insertSql)(insertParams:_*)
  }

  protected [mysql] def executeMultiUpdate[K1 <: MySqlValue](kvs: Map[K1, MySqlValue]) = {
    val updateSql = MULTI_UPDATE_SQL_PREFIX + Stream.continually("WHEN ? THEN ?").take(kvs.size).mkString(" ") +
      MULTI_UPDATE_SQL_INFIX + Stream.continually("?").take(kvs.size).mkString("(", ",", ")")

    val updateParams = kvs.map { kv =>
      (MySqlStringInjection(kv._1).getBytes, MySqlStringInjection(kv._2).getBytes)
    }
    // params for "WHEN ? THEN ?"
      val updateCaseParams: Seq[Parameter] =
        updateParams.map { kv => List(kv._1, kv._2) }.toSeq.flatten.map(Parameter.wrap[Array[Byte]])
    // params for "IN (?, ?, ?)"
    val updateInParams = updateParams.map { kv => kv._1 }.toSeq.map(Parameter.wrap[Array[Byte]])
    client.prepare(updateSql)((updateCaseParams ++ updateInParams):_*)
  }

  override def get(k: MySqlValue): Future[Option[MySqlValue]] = {
    // finagle-mysql select() method lets you pass in a mapping function
    // to convert resultset into desired output format
    // we assume here the mysql client already has the dbname/schema selected
    val mysqlResult =
      selectStmt.select(MySqlStringInjection(k).getBytes) { row =>
        row(vCol).map { MySqlValue(_)}
      }

    mysqlResult.map { result => result.lift(0).flatten.headOption }
  }

  override def multiGet[K1 <: MySqlValue](ks: Set[K1]): Map[K1, Future[Option[MySqlValue]]] = {
    if (ks.isEmpty) return Map()
    // build preparedstatement based on keyset size
    val placeholders = Stream.continually("?").take(ks.size).mkString("(", ",", ")")
    val selectSql = MULTI_SELECT_SQL_PREFIX + placeholders

    val params = ks.map(key => MySqlStringInjection(key).getBytes).toSeq.map(Parameter.wrap[Array[Byte]])
    val mysqlResult =
      client.prepare(selectSql).select(params:_*) { row =>
        (row(kCol).map(MySqlValue(_)), row(vCol).map(MySqlValue(_)))
      }
    FutureOps.liftValues(
      ks,
      mysqlResult.map { rows =>
        rows.toMap.filterKeys { _ != None }.map { case (optK, optV) => (optK.get, optV) }
      },
      { (k: K1) => Future.None }
    )
  }

  protected def set(k: MySqlValue, v: MySqlValue) = doSet(k, v)

  override def put(kv: (MySqlValue, Option[MySqlValue])): Future[Unit] = {
    kv match {
      case (key, Some(value)) => doSet(key, value).unit
      case (key, None) => doDelete(key).unit
    }
  }

  override def multiPut[K1 <: MySqlValue](kvs: Map[K1, Option[MySqlValue]]): Map[K1, Future[Unit]] = {
    // batched version of put. the batch is split into insert, update, and delete statements.
    // reduce your batch size if you are hitting mysql packet limit:
    // http://dev.mysql.com/doc/refman/5.1/en/packet-too-large.html
    val putResult = startTransaction.flatMap { t =>
      FutureOps.mapCollect(multiGet(kvs.keySet)).flatMap { result =>
        val existingKeys = result.filter { !_._2.isEmpty }.keySet
        val newKeys = result.filter { _._2.isEmpty }.keySet

        // handle inserts for new keys
        val insertF = newKeys.isEmpty match {
          case true => Future.Unit
          case false =>
            // do not include None values in insert query
            val insertKvs = newKeys.map { k => k -> kvs.getOrElse(k, None) }.filter { ! _._2.isEmpty }
              .toMap.mapValues { v => v.get }
            insertKvs.isEmpty match {
              case true => Future.Unit
              case false => executeMultiInsert(insertKvs)
            }
        }

        // handle update and/or delete for existing keys
        val existingKvs = existingKeys.map { k => k -> kvs.getOrElse(k, None) }

        // do not include None values in update query
        val updateKvs = existingKvs.filter { ! _._2.isEmpty }
          .toMap.mapValues { v => v.get }
        lazy val updateF = updateKvs.isEmpty match {
          case true => Future.Unit
          case false => executeMultiUpdate(updateKvs)
        }

        // deletes
        val deleteKeys = existingKvs.filter { _._2.isEmpty }.map { _._1 }
        lazy val deleteF = deleteKeys.isEmpty match {
          case true => Future.Unit
          case false =>
            val deleteSql = MULTI_DELETE_SQL_PREFIX + Stream.continually("?").take(deleteKeys.size).mkString("(", ",", ")")
            val deleteParams = deleteKeys.map { k => MySqlStringInjection(k).getBytes }.toSeq.map(Parameter.wrap[Array[Byte]])
            client.prepare(deleteSql)(deleteParams:_*)
        }

        // sequence the three queries. the inner futures are lazy
        insertF.flatMap { f =>
          updateF.flatMap { f =>
            deleteF.flatMap { f => commitTransaction }
              .handle { case e: Exception => rollbackTransaction.flatMap { throw e } }
          }
        }
      }
    }
    kvs.mapValues { v => putResult.unit }
  }

  override def close(t: Time) = {
    // close prepared statements before closing the connection
    client.close(t)
  }

  protected def doSet(k: MySqlValue, v: MySqlValue): Future[Result] = {
    // mysql's insert-or-update syntax works only when a primary key is defined:
    // http://dev.mysql.com/doc/refman/5.1/en/insert-on-duplicate.html
    // since we are not guaranteed that, we first check if key exists
    // and insert or update accordingly
    get(k).flatMap { optionV =>
      optionV match {
        case Some(value) =>
          updateStmt(MySqlStringInjection(v).getBytes, MySqlStringInjection(k).getBytes)
        case None =>
          insertStmt(MySqlStringInjection(k).getBytes, MySqlStringInjection(v).getBytes)
      }
    }
  }

  protected def doDelete(k: MySqlValue): Future[Result] = {
    deleteStmt(MySqlStringInjection(k).getBytes)
  }

  // enclose table or column names in backticks, in case they happen to be sql keywords
  protected def g(s: String)  = "`" + s + "`"
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy