diff --git a/.scalafmt.conf b/.scalafmt.conf index 3498245..c3542ef 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.7.14" +version = "2.7.5" maxColumn = 120 align = most continuationIndent.defnSite = 2 diff --git a/README.md b/README.md index 4509504..33078f8 100644 --- a/README.md +++ b/README.md @@ -90,30 +90,47 @@ import scala.jdk.DurationConverters._ import com.datastax.oss.driver.api.core.ConsistencyLevel import com.ringcentral.cassandra4io.CassandraSession import com.ringcentral.cassandra4io.cql._ - -case class Model(id: Int, data: String) - + +case class Model(pk: Long, ck: String, data: String, metaData: String) +case class Key(pk: Long, ck: String) +case class Data(data: String, metaData: String) + trait Dao[F[_]] { def put(value: Model): F[Unit] - def get(id: Int): F[Option[Model]] + def update(key: Key, data: Data): F[Unit] + def get(key: Key): F[Option[Model]] } - + object Dao { - - private val tableName = "table" - private val insertQuery = cqlt"insert into ${Const(tableName)} (id, data) values (${Put[Int]}, ${Put[String]})" - .config(_.setTimeout(1.second.toJava)) - private val selectQuery = cqlt"select id, data from ${Const(tableName)} where id = ${Put[Int]}".as[Model] - def apply[F[_]: Async](session: CassandraSession[F]) = for { + private val tableName = "table" + private val insertQuery = + cqlt"insert into ${Const(tableName)} (pk, ck, data, meta_data) values (${Put[Long]}, ${Put[String]}, ${Put[String]}, ${Put[String]})" + .config(_.setTimeout(1.second.toJava)) + private val insertQueryAlternative = + cqlt"insert into ${Const(tableName)} (${Columns[Model]}) values (${Values[Model]})" + private val updateQuery = cqlt"update ${Const(tableName)} set ${Assignment[Data]} where ${EqualsTo[Key]}" + private val selectQuery = cqlt"select ${Columns[Model]} from ${Const(tableName)} where ${EqualsTo[Key]}".as[Model] + + def apply[F[_] : Async](session: CassandraSession[F]) = for { insert <- insertQuery.prepare(session) - select <- selectQuery.prepare(session) + update <- updateQuery.prepare(session) + insertAlternative <- insertQueryAlternative.prepare(session) + select <- selectQuery.prepare(session) } yield new Dao[F] { - override def put(value: Model) = insert(value.id, value.data).execute.void - override def get(id: Int) = select(id).config(_.setExecutionProfileName("default")).select.head.compile.last - } -} + override def put(value: Model) = insert( + value.pk, + value.ck, + value.data, + value.metaData + ).execute.void // insertAlternative(value).execute.void + override def update(key: Key, data: Data): F[Unit] = updateQuery(data, key).execute.void + override def get(key: Key) = select(key).config(_.setExecutionProfileName("default")).select.head.compile.last + } +} ``` +As you can see `${Columns[Model]}` expands to `pk, ck, data, meta_data`, `${Values[Model]}` to `?, ?, ?, ?`, `${Assignment[Data]}` to `data = ?, meta_data = ?` and `${EqualsTo[Key]}` expands to `pk = ? and ck = ?`. +Latter three types adjust query type as well for being able to bind corresponding values ### Handling optional fields (`null`) diff --git a/src/it/resources/migration/1__test_tables.cql b/src/it/resources/migration/1__test_tables.cql index 26c5751..1c14b28 100644 --- a/src/it/resources/migration/1__test_tables.cql +++ b/src/it/resources/migration/1__test_tables.cql @@ -74,3 +74,12 @@ CREATE TABLE heavily_nested_prim_table( data example_nested_primitive_type, PRIMARY KEY (id) ); + +create table test_data_interpolated( + key bigint, + projection_key text, + projection_data text, + offset bigint, + timestamp bigint, + PRIMARY KEY (key, projection_key) +); diff --git a/src/it/scala/com/ringcentral/cassandra4io/cql/CqlSuite.scala b/src/it/scala/com/ringcentral/cassandra4io/cql/CqlSuite.scala index a12a19e..f2fc107 100644 --- a/src/it/scala/com/ringcentral/cassandra4io/cql/CqlSuite.scala +++ b/src/it/scala/com/ringcentral/cassandra4io/cql/CqlSuite.scala @@ -194,6 +194,55 @@ trait CqlSuite { } yield expect(results == Seq(Data(1, "one"), Data(2, "two"), Data(3, "three"))) } + test( + "interpolated inserts and selects should work with derived KeyEquals, Columns and Values" + ) { session => + case class Table(key: Long, projectionKey: String, projectionData: String, offset: Long, timestamp: Long) + case class Key(key: Long, projectionKey: String) + + val insert = cqlt"INSERT INTO ${Const("test_data_interpolated")}(${Columns[Table]}) VALUES (${Values[Table]})" + val select = + cqlt"SELECT ${Columns[Table]} FROM ${Const("test_data_interpolated")} WHERE ${EqualsTo[Key]}" + .as[Table] + + val data1 = Table(1, "projection-1", "data-1", 1, 1732547921580L) + val data2 = Table(1, "projection-2", "data-1", 2, 1732547921586L) + val key = Key(1, "projection-1") + + for { + preparedInsert <- insert.prepare(session) + preparedSelect <- select.prepare(session) + _ <- preparedInsert(data1).execute + _ <- preparedInsert(data2).execute + result <- preparedSelect(key).select.compile.toList + } yield expect(result == List(data1)) + } + + test( + "interpolated updates and selects should work with derived KeyEquals and Assignment" + ) { session => + case class Data(projectionData: String, offset: Long, timestamp: Long) + case class Key(key: Long, projectionKey: String) + + val update = cqlt"UPDATE ${Const("test_data_interpolated")} SET ${Assignment[Data]} WHERE ${EqualsTo[Key]}" + val select = + cqlt"SELECT ${Columns[Data]} FROM ${Const("test_data_interpolated")} WHERE ${EqualsTo[Key]}" + .as[Data] + + val data1 = Data("data-1", 1, 1732547921580L) + val data2 = Data("data-1", 2, 1732547921586L) + val key1 = Key(2, "projection-1") + val key2 = Key(2, "projection-2") + + for { + preparedUpdate <- update.prepare(session) + preparedSelect <- select.prepare(session) + _ <- preparedUpdate(data1, key1).execute + _ <- preparedUpdate(data2, key2).execute + result <- preparedSelect(key1).select.compile.toList + } yield expect(result == List(data1)) + } + test( "interpolated inserts and selects should produce UDTs and return data case classes when nested case classes are used" ) { session => diff --git a/src/main/scala/com/ringcentral/cassandra4io/cql/package.scala b/src/main/scala/com/ringcentral/cassandra4io/cql/package.scala index 4b5f72e..a49ddea 100644 --- a/src/main/scala/com/ringcentral/cassandra4io/cql/package.scala +++ b/src/main/scala/com/ringcentral/cassandra4io/cql/package.scala @@ -9,6 +9,7 @@ import com.datastax.oss.driver.api.core.cql._ import com.datastax.oss.driver.api.core.data.UdtValue import fs2.Stream import shapeless._ +import shapeless.labelled.FieldType import shapeless.ops.hlist.Prepend import java.nio.ByteBuffer @@ -125,9 +126,17 @@ package object cql { QueryTemplate[V, Row]( ctx.parts .foldLeft[(HList, StringBuilder)]((params, new StringBuilder())) { - case ((Const(const) :: tail, builder), part) => (tail, builder.appendAll(part).appendAll(const)) - case ((_ :: tail, builder), part) => (tail, builder.appendAll(part).appendAll("?")) - case ((HNil, builder), part) => (HNil, builder.appendAll(part)) + case ((Const(const) :: tail, builder), part) => (tail, builder.appendAll(part).appendAll(const)) + case (((restriction: EqualsTo[_]) :: tail, builder), part) => + (tail, builder.appendAll(part).appendAll(restriction.keys.map(key => s"${key} = ?").mkString(" AND "))) + case (((assignment: Assignment[_]) :: tail, builder), part) => + (tail, builder.appendAll(part).appendAll(assignment.keys.map(key => s"${key} = ?").mkString(", "))) + case (((columns: Columns[_]) :: tail, builder), part) => + (tail, builder.appendAll(part).appendAll(columns.keys.mkString(", "))) + case (((values: Values[_]) :: tail, builder), part) => + (tail, builder.appendAll(part).appendAll(List.fill(values.size)("?").mkString(", "))) + case ((_ :: tail, builder), part) => (tail, builder.appendAll(part).appendAll("?")) + case ((HNil, builder), part) => (HNil, builder.appendAll(part)) } ._2 .toString(), @@ -166,6 +175,25 @@ package object cql { override type Repr = RT override def binder: Binder[RT] = f.binder } + + implicit def hConsBindableColumnsBuilder[T, PT <: HList, RT <: HList](implicit + f: BindableBuilder.Aux[PT, RT] + ): BindableBuilder.Aux[Columns[T] :: PT, RT] = + new BindableBuilder[Columns[T] :: PT] { + override type Repr = RT + override def binder: Binder[RT] = f.binder + } + + implicit def hConsBindableValuesBuilder[V[_] <: Values[_], T: ColumnsValues, PT <: HList, RT <: HList](implicit + f: BindableBuilder.Aux[PT, RT] + ): BindableBuilder.Aux[V[T] :: PT, T :: RT] = new BindableBuilder[V[T] :: PT] { + override type Repr = T :: RT + override def binder: Binder[T :: RT] = { + implicit val hBinder: Binder[T] = Values[T].binder + implicit val tBinder: Binder[RT] = f.binder + Binder[T :: RT] + } + } } } @@ -261,6 +289,93 @@ package object cql { } case class Const(fragment: String) + trait Columns[T] { + def keys: List[String] + } + object Columns { + def apply[T: ColumnsValues]: Columns[T] = new Columns[T] { + override def keys: List[String] = ColumnsValues[T].keys + } + } + trait Values[T] { + def size: Int + def binder: Binder[T] + } + object Values { + def apply[T: ColumnsValues]: Values[T] = new Values[T] { + override def size: Int = ColumnsValues[T].size + override def binder: Binder[T] = ColumnsValues[T].binder + } + } + trait EqualsTo[T] extends Columns[T] with Values[T] + object EqualsTo { + def apply[T: ColumnsValues]: EqualsTo[T] = new EqualsTo[T] { + override def keys: List[String] = ColumnsValues[T].keys + override def size: Int = ColumnsValues[T].size + override def binder: Binder[T] = ColumnsValues[T].binder + } + } + + trait Assignment[T] extends Columns[T] with Values[T] + object Assignment { + def apply[T: ColumnsValues]: Assignment[T] = new Assignment[T] { + override def keys: List[String] = ColumnsValues[T].keys + override def size: Int = ColumnsValues[T].size + override def binder: Binder[T] = ColumnsValues[T].binder + } + } + + private trait ColumnsValues[T] extends Columns[T] with Values[T] + private object ColumnsValues { + def apply[T](implicit ev: ColumnsValues[T]): ColumnsValues[T] = ev + + implicit val hNilColumnsValues: ColumnsValues[HNil] = new ColumnsValues[HNil] { + override def keys: List[String] = List.empty + override def size: Int = 0 + override def binder: Binder[HNil] = Binder.hNilBinder + } + + private def camel2snake(text: String) = + text.tail.foldLeft(text.headOption.fold("")(_.toLower.toString)) { + case (acc, c) if c.isUpper => acc + "_" + c.toLower + case (acc, c) => acc + c + } + + implicit def hListColumnsValues[K, V, T <: HList](implicit + witness: Witness.Aux[K], + tColumnsValues: ColumnsValues[T], + vBinder: Binder[V] + ): ColumnsValues[FieldType[K, V] :: T] = + new ColumnsValues[FieldType[K, V] :: T] { + override def keys: List[String] = { + val key = witness.value match { + case Symbol(key) => camel2snake(key) + case _ => witness.value.toString + } + key :: tColumnsValues.keys + } + override def size: Int = tColumnsValues.size + 1 + override def binder: Binder[FieldType[K, V] :: T] = { + implicit val hBinder: Binder[FieldType[K, V]] = new Binder[FieldType[K, V]] { + override def bind(statement: BoundStatement, index: Int, value: FieldType[K, V]): (BoundStatement, Int) = + vBinder.bind(statement, index, value) + } + implicit val tBinder: Binder[T] = tColumnsValues.binder + Binder[FieldType[K, V] :: T] + } + } + implicit def genColumnValues[T, TRepr](implicit + gen: LabelledGeneric.Aux[T, TRepr], + columnsValues: ColumnsValues[TRepr] + ): ColumnsValues[T] = new ColumnsValues[T] { + override def keys: List[String] = columnsValues.keys + override def size: Int = columnsValues.size + override def binder: Binder[T] = new Binder[T] { + override def bind(statement: BoundStatement, index: Int, value: T): (BoundStatement, Int) = + columnsValues.binder.bind(statement, index, gen.to(value)) + } + } + } object Binder extends BinderLowerPriority with BinderLowestPriority {