Oolong - compile-time query generation for document stores.
This library is insipred by Quill. Everything is implemented with Scala 3 macros. Scala 2 is not supported. At the moment MongoDB is the only supported document store.
If you want to contribute please see our guide for contributors.
All query generation is happening at compile-time. This means:
- Zero runtime overhead. You can enjoy the abstraction without worrying about performance.
- Debugging is straightforward because generated queries are displayed as compilation messages.
Write your queries as plain Scala lambdas and oolong will translate them into the target representation for your document store:
importorg.mongodb.scala.bson.BsonDocumentimportoolong.dsl.*importoolong.mongo.*caseclassPerson(name: String, address: Address)
caseclassAddress(city: String)
valq:BsonDocument= query[Person](p => p.name =="Joe"&& p.address.city =="Amsterdam")
// The generated query will be displayed during compilation:// {"$and": [{"name": {"$eq": "Joe"}}, {"address.city": {"$eq": "Amsterdam"}}]}// ... Then you run the query by passing the generated BSON to mongo-scala-driverUpdates are also supported:
valq:BsonDocument= update[Person](_
.set(_.name, "Alice")
.inc(_.age, 5)
)
// q is {// $set: { "name": "Alice" },// $inc: { "age": 5 }// }I Comparison query operators
- $eq
importoolong.dsl.*importoolong.mongo.*caseclassPerson(name: String, age: Int, email: Option[String])
valq= query[Person](_.name =="John")
// q is {"name": "John"}In oolong $eq query is transformed into its implicit form: { field: <value> }, except when a field is queried more than once.
- $gt
valq= query[Person](_.age >18)
// q is {"age": {"$gt": 18}}- $gte
valq= query[Person](_.age >=18)
// q is {"age": {"$gte": 18}}- $in
valq= query[Person](p =>List(18, 19, 20).contains(p.age))
// q is {"age": {"$in": [18, 19, 20]}}- $lt
valq= query[Person](_.age <18)
// q is {"age": {"$lt": 18}}- $lte
valq= query[Person](_.age <=18)
// q is {"age": {"$lte": 18}}- $ne
valq= query[Person](_.name !="John")
// q is {"name" : {"$ne": "John"}}- $nin
valq= query[Person](p =>!List(18, 19, 20).contains(p.age))
// q is {"age": {"$nin": [18, 19, 20]}}- $type
valq= query[Person](_.age.isInstance[MongoType.INT32])
// q is {"age": { "$type": 16 }}- $mod
valq= query[Person](_.age %4.5==2)
// q is {"age": {"$mod": [4.5, 2]}}Also $mod is supported if % is defined in extension:
traitNewType[T](usingev: Numeric[T]):opaquetypeType=TgivenNumeric[Type] = ev
extension (nt: Type) defvalue:T= nt
objectNumberextendsNewType[Int]:extension (self: Number) def%(a: Int):Int= self.value % a
typeNumber=Number.TypecaseclassHuman(age: Number)
valq= query[Human](_.age %2==2)
// q is {"age": {"$mod": [2, 2]}}II Logical query operators
- $and
valq= query[Person](p => p.name =="John"&& p.age >=18)
// q is {"name" : "John", "age": {"$gte": 18}}If we query different fields the query is simplified as above.
//However, should we query the same field twice, we would observe the form with $andvalq= query[Person](p => p.age !=33&& p.age >=18)
// q is {"$and": [{"age": {"$ne": 33}}, {"age": {"$gte": 18}]}- $or
valq= query[Person](p => p.age !=33|| p.age >=18)
// q is {"or": [{"age": {"$ne": 33}}, {"age": {"$gte": 18}]}- $not
valq= query[Person](p =>!(p.age <18))
// q is { "age": { "$not": { "$lt": 18 } } }III Element Query Operators
- $exists
valq= query[Person](_.email.isDefined)
// q is { "email": { "$exists": true } }valq1= query[Person](_.email.nonEmpty)
// q1 is { "email": { "$exists": true } }valq2= query[Person](_.email.isEmpty)
// q2 is { "email": { "$exists": false } }IV Evaluation Query Operators
- $regex
There are 4 ways to make a $regex query, that are supported in oolong, which are:
importjava.util.regex.Patternvalq= query[Person](_.email.!!.matches("(?ix)^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"))
//q is {"email": {"$regex": "^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$", "$options": "ix"} valq1= query[Person](p =>Pattern.compile("(?ix)^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$").matcher(p.email.!!).matches())
//q1 is {"email": {"$regex": "^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$", "$options": "ix"}valq2= query[Person](p =>Pattern.compile("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", Pattern.CASE_INSENSITIVE|Pattern.COMMENTS).matcher(p.email.!!).matches())
//q2 is {"email": {"$regex": "^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$", "$options": "ix"}valq3= query[Person](p =>Pattern.matches("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", p.email.!!))
//q3 is {"email": {"$regex": "^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"}V Array Query Operators
- $size
importoolong.dsl.*caseclassCourse(studentNames: List[String])
valq= query[Course](_.studentNames.size ==20)
valq= query[Course](_.studentNames.length ==20)
// q is {"studentNames": {"$size": 20}}- $elemMatch
importoolong.dsl.*caseclassStudent(name: String, age: Int)
caseclassCourse(students: List[Student], tutor: String)
valq= query[Course](_.students.exists(_.age ==20)) // $elemMatch ommited when querying single field// q is {"students.age": 20}valq= query[Course](course => course.students.exists(st => st.age >20&& st.name =="Pavel"))
// q is {"students": {"$elemMatch": {"age": {"$gt": 20}, "name": "Pavel"}}}- $all
caseclassLotteryTicket(numbers: List[Int])
inlinedefwinningNumbers=List(4, 8, 15, 16, 23, 42)
valq= query[LotteryTicket](lt => winningNumbers.forall(lt.numbers.contains))
// q is { "numbers": { "$all": [4, 8, 15, 16, 23, 42] } }$all with $elemMatch
caseclassLotteryTicket(numbers: List[Int], series: Long)
caseclassLotteryTickets(tickets: Vector[LotteryTicket])
valq= query[LotteryTickets](lts =>
lts.tickets.exists(_.numbers.size ==20) && lts.tickets.exists(ticket =>
ticket.numbers.size ==10&& ticket.series ==99L
)
)
// q is { "tickets": { "$all": [{ "$elemMatch": { "numbers": { "$size": 20 } } }, { "$elemMatch": { "numbers": { "$size": 10 }, "series": 99 } }] } }I Field Update Operators
- $inc
importoolong.dsl.*importoolong.mongo.*caseclassObservation(count: Int, result: Long, name: String, threshold: Option[Int])
valq= update[Observation](_.inc(_.count, 1))
// q is {"$set": {"count": 1}}- $min
valq= update[Observation](_.min(_.result, 1))
// q is {"$min": {"result": 1}}- $max
valq= update[Observation](_.max(_.result, 10))
// q is {"$min": {"result": 1}}- $mul
valq= update[Observation](_.mul(_.result, 2))
// q is {"$mul": {"result": 2}}- $rename
valq= update[Observation](_.rename(_.name, "tag"))
// q is {"$rename": {"name": "tag"}}- $set
valq= update[Observation](_.set(_.count, 0))
// q is {"$set": {"count": 0}}- $set
valq= update[Observation](_.set(_.count, 0))
// q is {"$set": {"count": 0}}- $set
valq= update[Observation](_.setOnInsert(_.threshold, 100))
// q is {"$setOnInsert": {"threshold": 100}}- $unset
$unset can be used only to set None on Option fields
valq= update[Observation](_.unset(_.threshold))
// q is {"$unset": {"threshold": ""}}II Array update operators
- $addToSet
caseclassStudent(id: Int, courses: List[Int])
valq= update[Student](_.addToSet(_.courses, 55))
// q is {"$addToSet": {"courses": 55}}In order to append multiple values to array addToSetAll should be used:
valq= update[Student](_.addToSetAll(_.courses, List(42, 44, 53)))
// q is {"$addToSet": {"courses": {$each: [42, 44, 53] }}}- $pop
caseclassStudent(id: Int, courses: List[Int])
valq= update[Student](_.popHead(_.courses)) // removes the first element// q is {"$pop": {"courses": -1}}valq1= update[Student](_.popLast(_.courses)) // removes the last element// q1 is {"$pop": {"courses": 1}}- $pull
caseclassStudent(id: Int, courses: List[Int])
valq= update[Student](_.pull(_.courses, _ >=42)) // q is {"$pull": {"courses": {"$gte": 42}}}- $pullAll
caseclassStudent(id: Int, courses: List[Int])
valq= update[Student](_.pullAll(_.courses, List(5, 10, 42)))
// q is {"$pullAll": {"courses": [5, 10, 42]}}caseclassPassport(number: String, issueDate: LocalDate)
caseclassBirthInfo(country: String, date: LocalDate)
caseclassStudent(name: String, lastName: String, passport: Passport, birthInfo: BirthInfo)
caseclassStudentDTO(name: String, lastName: String)
caseclassPassportDTO(number: String, issueDate: LocalDate)
caseclassBirthDateDTO(country: String, date: LocalDate)
valproj= projection[Student, StudentDTO]
// proj is {"name": 1, "birthInfo.date": 1, "passport": 1, "lastName": 1}In order to rename fields in codecs and queries for type T the instance of QueryMeta[T] should be provided in the scope:
importorg.mongodb.scala.BsonDocumentimportoolong.bson.BsonDecoderimportoolong.bson.BsonEncoderimportoolong.bson.givenimportoolong.bson.meta.*importoolong.bson.meta.QueryMetaimportoolong.dsl.*importoolong.mongo.*caseclassPerson(name: String, address: Option[Address]) derivesBsonEncoder, BsonDecoderobjectPerson:inlinegivenQueryMeta[Person] = queryMeta(_.name ->"lastName")
caseclassAddress(city: String) derivesBsonEncoder, BsonDecodervalperson=Person("Adams", Some(Address("New York")))
valbson:BsonDocument= person.bson.asDocument()
valjson= bson.toJson
// json is {"lastName": "Adams", "address": {"city": "New York"}}//also having QueryMeta[Person] affects filter and update queries:valq0:BsonDocument= query[Person](_.name =="Johnson")
// The generated query will be:// {"lastName": "Johnson"}valq1:BsonDocument= update[Person](_
.set(_.name, "Brook")
)
// q1 is {// $set: { "lastName": "Brook" },// }All QueryMeta instances should be inline given instances to be used in macro.
If they are not given their presence will not have any effect on codecs and queries.
And if they are not inline the error will be thrown during compilation:
Please, add `inline` to given QueryMeta[T]
In addition to manual creation of QueryMeta instances, there are several existing instances of QueryMeta: QueryMeta.snakeCase QueryMeta.camelCase QueryMeta.upperCamelCase
Also they can be combined with manual fields renaming:
importoolong.bson.BsonDecoderimportoolong.bson.BsonEncoderimportoolong.bson.givenimportoolong.bson.meta.*importoolong.bson.meta.QueryMetacaseclassStudent(firstName: String, lastName: String, previousUniversity: String) derivesBsonEncoder, BsonDecoderobjectStudent:inlinegivenQueryMeta[Student] =QueryMeta.snakeCase.withRenaming(_.firstName ->"name")
vals=Student("Alexander", "Bloom", "MSU")
valbson= s.bson
// bson printed form is: {"name": "Alexander", "last_name": "Bloom", "previous_university": "MSU"}If fields of a class T are not renamed, you don't need to provide any instance, even if some other class U has a field of type T.
Macro automatically searches for instances of QueryMeta for all fields, types of which are case classes, and if not found, assumes that fields are not renamed, and then continues doing it recursively
When we need to unwrap an A from Option[A], we don't use map / flatMap / etc.
We use !! to reduce verbosity:
caseclassPerson(name: String, address: Option[Address])
caseclassAddress(city: String)
valq= query[Person](_.address.!!.city =="Amsterdam")Similar to Quill, Oolong provides a quoted DSL, which means that the code you write inside query(...) and update blocks never gets to execute.
Since we don't have to worry about runtime exceptions, we can tell the compiler to relax and give us the type that we want.
If you need to use a feature that's not supported by oolong, you can write the target subquery manually and combine it with the high level query DSL:
valq= query[Person](_.name =="Joe"&& unchecked(
BsonDocument(Seq(
("address.city", BsonDocument(Seq(
("$eq", BsonString("Amsterdam"))
)))
))
))It's possible to reuse a query by defining an 'inline def':
inlinedefcityFilter(doc: Person) = doc.address.!!.city =="Amsterdam"valq= query[Person](p => p.name =="Joe"&& cityFilter(p))- elasticsearch support
- aggregation pipelines for Mongo