diff --git a/README.md b/README.md index 345c7a8..0bad531 100644 --- a/README.md +++ b/README.md @@ -230,13 +230,15 @@ val q = query[Course](_.studentNames.length == 20) ```scala import oolong.dsl.* -case class Course(studentNames: List[String], tutor: String) +case class Student(name: String, age: Int) -val q = query[Course](_.studentNames.exists(_ == 20)) // $elemMatch ommited when querying single field -// q is {"studentNames": 20} +case class Course(students: List[Student], tutor: String) -val q = query[Course](course => course.studentNames.exists(_ > 20) && course.tutor == "Pavlov") -// q is {"studentNames": {"$elemMatch": {"studentNames": {"$gt": 20}, "tutor": "Pavlov"}}} +val q = query[Course](_.students.exists(_.age == 20)) // $elemMatch ommited when querying single field +// q is {"students.age": 20} + +val q = query[Course](course => course.students.exists(st => st.age > 20 && st.name == "Pavel")) +// q is {"students": {"$elemMatch": {"age": {"$gt": 20}, "name": "Pavel"}}} ``` diff --git a/oolong-core/src/main/scala/oolong/AstParser.scala b/oolong-core/src/main/scala/oolong/AstParser.scala index 1d22428..de80d0d 100644 --- a/oolong-core/src/main/scala/oolong/AstParser.scala +++ b/oolong-core/src/main/scala/oolong/AstParser.scala @@ -159,6 +159,9 @@ private[oolong] class DefaultAstParser(using quotes: Quotes) extends AstParser { case '{ ($x: Option[_]).isDefined } => QExpr.Exists(parse(x), QExpr.Constant(true)) + case '{ ($x: Option[_]).nonEmpty } => + QExpr.Exists(parse(x), QExpr.Constant(true)) + case PropSelector(name, path) if name == paramName => QExpr.Prop(path.mkString(".")) diff --git a/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoSpec.scala b/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoSpec.scala index ff0903c..caddf4d 100644 --- a/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoSpec.scala +++ b/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoSpec.scala @@ -28,14 +28,15 @@ class OolongMongoSpec extends AsyncFlatSpec with ForAllTestContainer with Before container.start() val client = MongoClient(container.replicaSetUrl) - val collection = client.getDatabase("test").getCollection[BsonDocument]("testColection") + val collection = client.getDatabase("test").getCollection[BsonDocument]("testCollection") override def beforeAll(): Unit = { val documents = List( - TestClass("0", 0, InnerClass("sdf"), Nil), - TestClass("1", 1, InnerClass("qwe"), Nil), - TestClass("2", 2, InnerClass("asd"), Nil), - TestClass("3", 12, InnerClass("sdf"), Nil) + TestClass("0", 0, InnerClass("sdf", 1), List(1, 2), None, List(InnerClass("abc", 1))), + TestClass("1", 1, InnerClass("qwe", 2), Nil, Some(2L), List(InnerClass("cde", 10))), + TestClass("2", 2, InnerClass("asd", 3), Nil, None, List.empty), + TestClass("3", 12, InnerClass("sdf", 4), List(10, 25), None, List.empty), + TestClass("12345", 12, InnerClass("sdf", 5), Nil, None, List.empty), ) implicit val ec = ExecutionContext.global @@ -55,14 +56,14 @@ class OolongMongoSpec extends AsyncFlatSpec with ForAllTestContainer with Before case Failure(exception) => Future.failed(exception) case Success(value) => Future.successful(value) } - } yield assert(v == TestClass("1", 1, InnerClass("qwe"), Nil)) + } yield assert(v == TestClass("1", 1, InnerClass("qwe", 2), Nil, Some(2L), List(InnerClass("cde", 10)))) } it should "find documents in a collection with query with runtime constant" in { val q = query[TestClass](_.field2 <= lift(Random.between(13, 100))) for { res <- collection.find(q).toFuture() - } yield assert(res.size == 4) + } yield assert(res.size == 5) } it should "find both documents with OR operator" in { @@ -93,7 +94,7 @@ class OolongMongoSpec extends AsyncFlatSpec with ForAllTestContainer with Before for { res <- collection.find(q).toFuture() - } yield assert(res.size == 4) + } yield assert(res.size == 5) } it should "compile queries with `unchecked`" in { @@ -129,15 +130,23 @@ class OolongMongoSpec extends AsyncFlatSpec with ForAllTestContainer with Before } it should "compile queries with `.contains` #3" in { - val q = query[TestClass](x => lift(Set(InnerClass("qwe"), InnerClass("asd"))).contains(x.field3)) + val q = query[TestClass](x => lift(Set(InnerClass("qwe", 2), InnerClass("asd", 3))).contains(x.field3)) for { res <- collection.find(q).toFuture() } yield assert(res.size == 2) } + it should "compile queries with `.contains` #4" in { + val q = query[TestClass](x => !List(1, 2, 3).contains(x.field2)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 3) + } + it should "compile queries with nested objects" in { - val q = query[TestClass](_.field3 == lift(InnerClass("qwe"))) + val q = query[TestClass](_.field3 == lift(InnerClass("qwe", 2))) for { res <- collection.find(q).toFuture() @@ -149,7 +158,7 @@ class OolongMongoSpec extends AsyncFlatSpec with ForAllTestContainer with Before for { res <- collection.find(q).toFuture() - } yield assert(res.size == 4) + } yield assert(res.size == 5) } it should "compile queries with `.isInstance` #2" in { @@ -157,32 +166,80 @@ class OolongMongoSpec extends AsyncFlatSpec with ForAllTestContainer with Before for { res <- collection.find(q).toFuture() - } yield assert(res.size == 4) + } yield assert(res.size == 5) } - it should "compile queries with `.mod` #1" in { + it should "compile queries with `%` #1" in { val q = query[TestClass](_.field2 % 4 == 0) for { res <- collection.find(q).toFuture() - } yield assert(res.size == 2) + } yield assert(res.size == 3) } - it should "compile queries with `.mod` #2" in { + it should "compile queries with `%` #2" in { val q = query[TestClass](_.field2 % 4.99 == 0) + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 3) + } + + it should "compile queries with $type" in { + val q = query[TestClass](_.field2.isInstanceOf[MongoType.INT32]) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 5) + } + + it should "compile queries with $exists" in { + val q = query[TestClass](_.field5.isDefined) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with $regex" in { + val q = query[TestClass](_.field1.matches("\\d{2,5}")) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with $size" in { + val q = query[TestClass](_.field4.size == 2) + for { res <- collection.find(q).toFuture() } yield assert(res.size == 2) } - // TODO: test updates -// it should "compile updates" in { -// val upd = compileUpdate { -// update[CompanySuccess] -// .set(_.from, 2) -// .set(_.field4, liftU(Field("qweasd"))) -// } -// } + it should "compile queries with $elemMatch" in { + val q = query[TestClass](tc => tc.field6.exists(s => s.innerField == "cde")) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with $elemMatch #2" in { + val q = query[TestClass](tc => tc.field6.exists(s => s.innerField != "cde" && s.otherField < 10)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with $all" in { + inline def variants = List(1, 2) + val q = query[TestClass](ins => variants.forall(ins.field4.contains)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } } diff --git a/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoUpdateSpec.scala b/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoUpdateSpec.scala new file mode 100644 index 0000000..5eb3189 --- /dev/null +++ b/oolong-mongo-it/src/test/scala/oolong/mongo/OolongMongoUpdateSpec.scala @@ -0,0 +1,132 @@ +package oolong.mongo + +import scala.concurrent.Await +import scala.concurrent.ExecutionContext + +import com.dimafeng.testcontainers.ForAllTestContainer +import com.dimafeng.testcontainers.MongoDBContainer +import concurrent.duration.DurationInt +import oolong.dsl.* +import org.mongodb.scala.MongoClient +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.model.UpdateOptions +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AsyncFlatSpec + +class OolongMongoUpdateSpec extends AsyncFlatSpec with ForAllTestContainer with BeforeAndAfterAll { + + override val container: MongoDBContainer = MongoDBContainer() + container.start() + + val client = MongoClient(container.replicaSetUrl) + val collection = client.getDatabase("test").getCollection[BsonDocument]("testCollection") + + override def beforeAll(): Unit = { + val documents = List( + TestClass("0", 0, InnerClass("sdf", 2.0), List(1, 2), None, List.empty), + TestClass("1", 1, InnerClass("qwe", 3), Nil, Some(2L), List.empty), + TestClass("2", 2, InnerClass("asd", 0), Nil, None, List.empty), + TestClass("3", 12, InnerClass("sdf", 1), Nil, None, List.empty), + TestClass("12345", 12, InnerClass("sdf", 11), Nil, None, List.empty), + ) + + implicit val ec = ExecutionContext.global + + val f = for { + _ <- client.getDatabase("test").createCollection("testCollection").head() + _ <- collection.insertMany(documents.map(_.bson.asDocument())).head() + } yield () + + Await.result(f, 30.seconds) + } + + it should "update with $inc" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "0"), + update[TestClass](_.inc(_.field2, 1)) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + + it should "update with $min" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "3"), + update[TestClass](_.min(_.field2, 1)) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + + it should "update with $max" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "0"), + update[TestClass](_.max(_.field2, 10)) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + + it should "update with $mul" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "0"), + update[TestClass](_.mul(_.field2, 10)) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + + it should "update with $rename" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "12345"), + update[TestClass](_.rename(_.field2, "NewField2")) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + + it should "update with $set" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "0"), + update[TestClass](_.set(_.field5.!!, 2L)) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + + it should "not update existing document with $senOnInsert" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "0"), + update[TestClass](_.setOnInsert(_.field2, 2)), + (new UpdateOptions).upsert(true) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 0) + } + + it should "update with $unset" in { + for { + res <- collection + .updateOne( + query[TestClass](_.field1 == "1"), + update[TestClass](_.unset(_.field5)) + ) + .head() + } yield assert(res.wasAcknowledged() && res.getMatchedCount == 1 && res.getModifiedCount == 1) + } + +} diff --git a/oolong-mongo/src/test/scala/oolong/mongo/TestClass.scala b/oolong-mongo/src/test/scala/oolong/mongo/TestClass.scala index 01eaf47..06ee939 100644 --- a/oolong-mongo/src/test/scala/oolong/mongo/TestClass.scala +++ b/oolong-mongo/src/test/scala/oolong/mongo/TestClass.scala @@ -8,10 +8,13 @@ case class TestClass( field2: Int, field3: InnerClass, field4: List[Int], + field5: Option[Long], + field6: List[InnerClass] ) derives BsonEncoder, BsonDecoder case class InnerClass( - innerField: String + innerField: String, + otherField: Double ) derives BsonEncoder, BsonDecoder