diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..133e2a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +This template isn't a strict requirement to open issues, but please try to provide as much information as possible. + +**Version**: (e.g. `0.4.1-SNAPSHOT`) +**Module**: (e.g. `oolong-core`) +**Database**: (e.g. `MongoDB`) + +### Expected behavior + +### Actual behavior + +### Steps to reproduce the behavior + +### Workaround + +@oolong/maintainers diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..83e68e4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +Fixes #issue_number + +### Problem + +Explain here the context, and why you're making that change. +What is the problem you're trying to solve? + +### Solution + +Describe the modifications you've done. + +### Notes + +Additional notes. + +### Checklist + +- [ ] Unit test all changes +- [ ] Update `README.md` if applicable +- [ ] Add `[WIP]` to the pull request title if it's work in progress +- [ ] [Squash commits](https://ariejan.net/2011/07/05/git-squash-your-latests-commits-into-one) that aren't meaningful changes + +@oolong/maintainers diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a2c2a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.bsp +.idea +.idea_modules +.ensime +.ensime_cache/ +*.sublime-project +*.sublime-workspace +target/ +.DS_Store +**/main/resources/local.conf +keys/private +.metals +scalafix-commons.conf +.vscode +.bloop +**/project/metals.sbt +.sbt-cache \ No newline at end of file diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..168b2bd --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,36 @@ +rules = [ + LeakingImplicitClassVal, + NoValInForComprehension, + #ProcedureSyntax, не поддерживается для Scala 3 + DisableSyntax +] + +RemoveUnused.imports = true +RemoveUnused.privates = false +RemoveUnused.locals = false +RemoveUnused.patternvars = false +RemoveUnused.params = false + +DisableSyntax.regex = [ + { + id = "mapUnit" + pattern = "\\.map\\(_\\s*=>\\s*\\(\\)\\)" + message = "Use .void" + }, { + id = mouseAny + pattern = "import mouse\\.any\\._" + message = "Use scala.util.chaining" + }, { + id = utilsResourceManagement + pattern = "import ru\\.tinkoff\\.tcb\\.utils\\.rm\\._" + message = "Use scala.util.Using" + },{ + id = mapAs + pattern = "\\.map\\(_\\s*=>\\s*[\\w\\d\\.\"\\(\\)]+\\)" + message = "Use .as" + }, { + id = catsImplicits + pattern = "import cats\\.implicits" + message = "Use granular imports" + } +] \ No newline at end of file diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..c85733f --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,58 @@ +align = most +align.openParenCallSite = false +align.openParenDefnSite = false +align.tokens = [ + { code = "extends", owner = "Defn.(Class|Trait|Object)" } + { code = "//", owner = ".*" } + { code = "{", owner = "Template" } + { code = "}", owner = "Template" } + { code = "%", owner = "Term.ApplyInfix" } + { code = "=>", owner = "Case" } + { code = "%%",owner = "Term.ApplyInfix" } + { code = "%%%",owner = "Term.ApplyInfix" } + { code = "<-", owner = "Enumerator.Generator" } + { code = "->", owner = "Term.ApplyInfix" } + { code = "=", owner = "(Enumerator.Val|Defn.(Va(l|r)|Def|Type))" } +] +continuationIndent.defnSite = 4 +docstrings.style = Asterisk +encoding = UTF-8 +importSelectors = singleLine +maxColumn = 120 +newlines.beforeTypeBounds = unfold +newlines.avoidForSimpleOverflow = [tooLong, punct, slc] +optIn.configStyleArguments = true +project.git = true +rewrite.rules = [ + PreferCurlyFors + Imports, // Может делать ошибки если есть относительные пути. Конфликтует с OrganizeImports из scalafix, но работает значительно быстрее и подхватывается Idea. + RedundantBraces + RedundantParens + SortModifiers +] +rewrite.imports.expand = true +rewrite.imports.sort = ascii +rewrite.imports.groups = [ + ["javax?..*", "scala..*"] + [".*", "ru\\.tinkoff.tschema\\..*", "ru\\.tinkoff.tofu\\..*"] + ["ru\\.tinkoff\\..*"] +] +rewrite.sortModifiers.order = [ + implicit + final + sealed + abstract + override + private + protected + lazy + open + transparent + inline + infix + opaque +] +runner.dialect = "scala3" +style = IntelliJ +trailingCommas = preserve +version=3.4.0 diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..3840f91 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @edubrovski @danslapman @desavitsky @Assassin4791 @InversionSpaces diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ff3a2dd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +## Pull Request Process + +1. Make a fork of this repo. +2. Make changes. +3. Create a pull request from your fork to this repo. + +## Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c95a2e6 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Oolong + +Oolong - compile-time query generation for document stores. + +This library is insipred by [Quill](https://github.com/zio/zio-protoquill). +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](CONTRIBUTING.md). + +## Overview + +All query generation is happening at compile-time. This means: +1. Zero runtime overhead. You can enjoy the abstraction without worrying about performance. +2. 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: + +```scala +import org.mongodb.scala.bson.BsonDocument + +import ru.tinkoff.oolong.dsl.* +import ru.tinkoff.oolong.mongo.* + +case class Person(name: String, address: Address) + +case class Address(city: String) + +val q: 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-driver +``` + +Updates are also supported: +```scala +val q: BsonDocument = compileUpdate { + update[Person] + .set(_.name, "Alice") + .set(_.address.city, "Berlin") + } +// q is {"$set": {"name": "Alice", "address.city": "Berlin"}} +``` + +## DSL of oolong + +### Working with Option[_] + +When we need to unwrap an `A` from `Option[A]`, we don't use `map` / `flatMap` / etc. +We use `!!` to reduce verbosity: +```scala +case class Person(name: String, address: Option[Address]) + +case class Address(city: String) + +val q = query[Person](_.address.!!.city == "Amsterdam") +``` + +Similar to Quill, Oolong provides a quoted DSL, which means that the code you write inside `query(...)` and `compileUpdate` 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. + +### Raw subquries + +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: +```scala +val q = query[Person](_.name == "Joe" && unchecked( + BsonDocument(Seq( + ("address.city", BsonDocument(Seq( + ("$eq", BsonString("Amsterdam")) + ))) + )) +)) +``` + +### Composing queries + +At the moment query composition is only supported via `unchecked`: +```scala +val cityFilter: BsonDocument = query[Person](_.address.!!.city == "Amsterdam") + +val q = query[Person](_.name == "Joe" && unchecked(cityFilter)) +``` + +## Coming soon + +- better query composition +- elasticsearch support +- field renaming +- aggregation pipelines for Mongo + diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..6507916 --- /dev/null +++ b/build.sbt @@ -0,0 +1,59 @@ +//To use Scalafix on Scala 3 projects, you must unset `scalafixBinaryScalaVersion` +//ThisBuild / scalafixScalaBinaryVersion := CrossVersion.binaryScalaVersion(scalaVersion.value) + +val `oolong-bson` = (project in file("oolong-bson")) + .settings(Settings.common) + .settings( + libraryDependencies ++= Seq( + ("org.mongodb.scala" %% "mongo-scala-driver" % "4.2.0").cross(CrossVersion.for3Use2_13), + "com.softwaremill.magnolia1_3" %% "magnolia" % "1.1.3", + "org.scalatest" %% "scalatest" % "3.2.11" % Test, + "org.scalatestplus" %% "scalacheck-1-15" % "3.2.11.0" % Test, + "org.scalacheck" %% "scalacheck" % "1.15.3" % Test + ), + Test / fork := true, + Test / scalacOptions += "-Yretain-trees" + ) + +val `oolong-core` = (project in file("oolong-core")) + .settings(Settings.common) + .dependsOn(`oolong-bson`) + .settings( + libraryDependencies ++= Seq( + "com.lihaoyi" %% "pprint" % "0.7.3" % Compile, + "org.scalatest" %% "scalatest" % "3.2.11" % Test, + ), + Test / fork := true + ) + +val `oolong-mongo` = (project in file("oolong-mongo")) + .settings(Settings.common) + .dependsOn(`oolong-core`, `oolong-bson`) + .settings( + libraryDependencies ++= Seq( + "com.dimafeng" %% "testcontainers-scala-scalatest" % "0.40.5" % Test, + "com.dimafeng" %% "testcontainers-scala-mongodb" % "0.40.5" % Test, + "org.scalatest" %% "scalatest" % "3.2.11" % Test, + "org.slf4j" % "slf4j-api" % "1.7.36" % Test, + "org.slf4j" % "slf4j-simple" % "1.7.36" % Test, + ), + Test / fork := true + ) + +val root = (project in file(".")) + .settings(Settings.common) + .aggregate(`oolong-bson`, `oolong-core`, `oolong-mongo`) + .settings( + pullRemoteCache := {}, + pushRemoteCache := {} + ) + .settings( + addCommandAlias( + "fixCheck", + "scalafixAll --check; scalafmtCheck" + ), + addCommandAlias( + "lintAll", + "scalafixAll; scalafmtAll" + ) + ) diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonDecoder.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonDecoder.scala new file mode 100644 index 0000000..6856af4 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonDecoder.scala @@ -0,0 +1,108 @@ +package ru.tinkoff.oolong.bson + +import scala.util.* + +import magnolia1.* +import org.bson.BsonInvalidOperationException +import org.bson.BsonNull +import org.mongodb.scala.bson.* + +import ru.tinkoff.oolong.bson.annotation.* + +/* + * A type class that provides a way to produce a value of type `T` from a BsonValue + */ +trait BsonDecoder[T]: + /* + * Decode given BsonValue + */ + def fromBson(value: BsonValue): Try[T] + + /* + * Create a BsonDecoder that post-processes value with a given function + */ + def afterRead[U](f: T => U): BsonDecoder[U] = + (value: BsonValue) => fromBson(value).map(f) + + /* + * Create a BsonDecoder that post-processes value with a given function + */ + def afterReadTry[U](f: T => Try[U]): BsonDecoder[U] = + (value: BsonValue) => fromBson(value).flatMap(f) + +object BsonDecoder extends Derivation[BsonDecoder] { + + import scala.compiletime.* + import scala.deriving.Mirror + import scala.quoted.* + + def apply[T](using bd: BsonDecoder[T]): BsonDecoder[T] = bd + + /* + * Create a BsonDecoder from a given function + */ + def ofDocument[T](f: BsonDocument => Try[T]): BsonDecoder[T] = + (value: BsonValue) => Try(value.asDocument()).flatMap(f) + + /* + * Create a BsonDecoder from a given function + */ + def ofArray[T](f: BsonArray => Try[T]): BsonDecoder[T] = + (value: BsonValue) => Try(value.asArray()).flatMap(f) + + /* + * Create a BsonDecoder from a given partial function + */ + def partial[T](pf: PartialFunction[BsonValue, T]): BsonDecoder[T] = + (value: BsonValue) => + Try( + pf.applyOrElse[BsonValue, T]( + value, + bv => throw new BsonInvalidOperationException(s"Can't decode $bv") + ) + ) + + override def join[T](caseClass: CaseClass[BsonDecoder, T]): BsonDecoder[T] = + BsonDecoder.ofDocument { doc => + caseClass.constructMonadic { f => + val fieldName = + if (f.annotations.isEmpty) f.label + else f.annotations.collectFirst { case BsonKey(value) => value }.getOrElse(f.label) + + f.typeclass.fromBson(doc.getFieldOpt(fieldName).getOrElse(BsonNull())) match { + case Failure(_) if f.default.isDefined => Success(f.default.get) + case Failure(exc) => + Failure(DeserializationError(s"Unable to decode field ${f.label}", exc)) + case otherwise => otherwise + } + } + } + + override def split[T](sealedTrait: SealedTrait[BsonDecoder, T]): BsonDecoder[T] = + BsonDecoder.ofDocument { doc => + val (discriminatorField, renameFun) = + if (sealedTrait.annotations.isEmpty) BsonDiscriminator.ClassNameField -> identity[String] _ + else + sealedTrait.annotations + .collectFirst { case BsonDiscriminator(d, rename) => d -> rename } + .getOrElse(BsonDiscriminator.ClassNameField -> identity[String] _) + + for { + discriminator <- doc + .getFieldOpt(discriminatorField) + .toRight( + DeserializationError( + s"No discriminator field ($discriminatorField) found while decoding ${sealedTrait.typeInfo.short}" + ) + ) + .toTry + typeName <- BsonDecoder[String].fromBson(discriminator) + decoder <- sealedTrait.subtypes + .find(st => renameFun(st.typeInfo.short) == typeName) + .toRight(DeserializationError(s"No case $typeName in ${sealedTrait.typeInfo.short}")) + .toTry + instance = decoder.typeclass + result <- instance.fromBson(doc) + } yield result + } +} diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonEncoder.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonEncoder.scala new file mode 100644 index 0000000..07d5966 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonEncoder.scala @@ -0,0 +1,72 @@ +package ru.tinkoff.oolong.bson + +import magnolia1.* +import org.bson.BsonNull +import org.mongodb.scala.bson.* + +import ru.tinkoff.oolong.bson.annotation.* + +/** + * A type class that provides a conversion from a value of type `T` to a BsonValue + */ +trait BsonEncoder[T]: + /* + * Serialize given value to Bson + */ + extension (value: T) def bson: BsonValue + + /* + * Create a BsonEncoder that pre-processes value with a given function + */ + def beforeWrite[U](f: U => T): BsonEncoder[U] = + (u: U) => f(u).bson + +object BsonEncoder extends Derivation[BsonEncoder] { + + import scala.compiletime.* + import scala.deriving.Mirror + import scala.quoted.* + + def apply[T](using be: BsonEncoder[T]): BsonEncoder[T] = be + + /* + * Create BsonEncoder that always returns given BsonValue + */ + def constant[T](bv: BsonValue): BsonEncoder[T] = + (_: T) => bv + + override def join[T](caseClass: CaseClass[BsonEncoder, T]): BsonEncoder[T] = + (value: T) => + BsonDocument( + caseClass.params + .map { p => + val fieldName = + if (p.annotations.isEmpty) p.label + else p.annotations.collectFirst { case BsonKey(value) => value }.getOrElse(p.label) + + given tEnc: Typeclass[p.PType] = p.typeclass + + fieldName -> p.deref(value).bson + } + .filterNot(_._2.isNull) + ) + + override def split[T](sealedTrait: SealedTrait[BsonEncoder, T]): BsonEncoder[T] = (value: T) => { + val (discriminatorField, renameFun) = + if (sealedTrait.annotations.isEmpty) BsonDiscriminator.ClassNameField -> identity[String] _ + else + sealedTrait.annotations + .collectFirst { case BsonDiscriminator(d, rename) => d -> rename } + .getOrElse(BsonDiscriminator.ClassNameField -> identity[String] _) + + sealedTrait.choose(value) { st => + implicit val tEnc = st.typeclass + + st.cast(value).bson match { + case BDocument(fields) => + BsonDocument(fields + (discriminatorField -> BsonString(renameFun(st.typeInfo.short)))) + case other => other + } + } + } +} diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonKeyDecoder.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonKeyDecoder.scala new file mode 100644 index 0000000..4f3fab4 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonKeyDecoder.scala @@ -0,0 +1,12 @@ +package ru.tinkoff.oolong.bson + +import scala.util.Try + +/* + * A type class providing a way to use a value of type `T` as Map key during reading bson + */ +trait BsonKeyDecoder[T]: + def decode(value: String): Try[T] + +object BsonKeyDecoder: + def apply[T](using bkd: BsonKeyDecoder[T]) = bkd diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonKeyEncoder.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonKeyEncoder.scala new file mode 100644 index 0000000..1fe4a30 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/BsonKeyEncoder.scala @@ -0,0 +1,10 @@ +package ru.tinkoff.oolong.bson + +/* + * A type class providing a way to use a value of type `T` as Map key during writing bson + */ +trait BsonKeyEncoder[T]: + def encode(t: T): String + +object BsonKeyEncoder: + def apply[T](using bke: BsonKeyEncoder[T]) = bke diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/DeserializationError.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/DeserializationError.scala new file mode 100644 index 0000000..6d66a10 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/DeserializationError.scala @@ -0,0 +1,8 @@ +package ru.tinkoff.oolong.bson + +case class DeserializationError(message: String, cause: Throwable) extends RuntimeException(message, cause): + def this(message: String) = this(message, null) + +object DeserializationError: + def apply(message: String): DeserializationError = + new DeserializationError(message) diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/annotation/BsonDiscriminator.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/annotation/BsonDiscriminator.scala new file mode 100644 index 0000000..2b468b3 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/annotation/BsonDiscriminator.scala @@ -0,0 +1,9 @@ +package ru.tinkoff.oolong.bson.annotation + +import scala.annotation.StaticAnnotation + +final case class BsonDiscriminator(name: String, renameValues: String => String = identity[String]) + extends StaticAnnotation + +object BsonDiscriminator: + val ClassNameField = "className" diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/annotation/BsonKey.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/annotation/BsonKey.scala new file mode 100644 index 0000000..63248e5 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/annotation/BsonKey.scala @@ -0,0 +1,5 @@ +package ru.tinkoff.oolong.bson.annotation + +import scala.annotation.StaticAnnotation + +final case class BsonKey(value: String) extends StaticAnnotation diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/bson.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/bson.scala new file mode 100644 index 0000000..a170e25 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/bson.scala @@ -0,0 +1,123 @@ +package ru.tinkoff.oolong.bson + +import java.time.Instant +import scala.jdk.CollectionConverters.* +import scala.util.Try +import scala.util.chaining.* + +import org.mongodb.scala.bson.* + +private type PartialEndo[A] = PartialFunction[A, A] +private type PartialEndo2[A, B] = PartialFunction[(A, B), (A, B)] + +object BUndef: + def unapply(bu: BsonUndefined): true = true + +object BNull: + def unapply(bn: BsonNull): true = true + +object BBoolean: + def unapply(bb: BsonBoolean): Some[Boolean] = Some(bb.getValue) + +object BInt: + def unapply(bi: BsonInt32): Some[Int] = Some(bi.getValue) + +object BLong: + def unapply(bl: BsonInt64): Some[Long] = Some(bl.getValue) + +object BDouble: + def unapply(bd: BsonDouble): Some[Double] = Some(bd.getValue) + +object BDecimal: + def unapply(bd: BsonDecimal128): Some[BigDecimal] = Some(bd.getValue.bigDecimalValue()) + +object BString: + def unapply(bs: BsonString): Some[String] = Some(bs.getValue) + +object BDateTime: + def unapply(bdt: BsonDateTime): Option[Instant] = + Try(Instant.ofEpochMilli(bdt.asDateTime().getValue)).toOption + +object BElement: + def unapply(be: BsonElement): Some[(String, BsonValue)] = Some((be.key, be.value)) + +object BArray: + def unapply(ba: BsonArray): Some[Vector[BsonValue]] = Some(ba.asScala.toVector) + +object BDocument: + def unapply(bd: BsonDocument): Some[Map[String, BsonValue]] = Some(bd.asScala.toMap) + +object BObjectId: + def unapply(boi: BsonObjectId): Some[ObjectId] = Some(boi.getValue) + +object BSymbol: + def unapply(bs: BsonSymbol): Some[String] = Some(bs.getSymbol) + +object BJavaScript: + def unapply(bjs: BsonJavaScript): Some[String] = Some(bjs.getCode) + +object BScopedJavaScript: + def unapply(bjs: BsonJavaScriptWithScope): Some[(String, BsonDocument)] = + Some(bjs.getCode -> bjs.getScope) + +object BRegex: + def unapply(brx: BsonRegularExpression): Some[(String, String)] = + Some(brx.getPattern -> brx.getOptions) + +extension (doc: BsonDocument.type) + @inline def apply(element: BsonElement): BsonDocument = + BsonDocument(element.key -> element.value) + +extension (bv: BsonValue) + /** + * Merges two bson values + * + * bson1 :+ bson2 + * + * In a case of key collision bson1 values takes priority + */ + @inline def :+(other: BsonValue): BsonValue = merge(other, bv) + + /** + * Merges two bson values + * + * bson1 :+ bson2 + * + * In a case of key collision bson2 values takes priority + */ + @inline def +:(other: BsonValue): BsonValue = merge(other, bv) + +extension (ba: BsonArray) + def modify(f: PartialEndo[BsonValue]): BsonArray = BsonArray.fromIterable( + ba.asScala.map(f.applyOrElse(_, identity[BsonValue])) + ) + + def modifyAt(idx: Int, f: PartialEndo[BsonValue]): BsonArray = BsonArray.fromIterable( + ba.asScala.patch(idx, Seq(f.applyOrElse(ba.get(idx), identity[BsonValue])), 1) + ) + +extension (bd: BsonDocument) + @inline def getFieldOpt(name: String): Option[BsonValue] = + if (bd.containsKey(name)) Try(bd.get(name)).toOption else None + + def modify(f: PartialEndo2[String, BsonValue]): BsonDocument = + BsonDocument( + bd.asScala.map(f.applyOrElse(_, identity[(String, BsonValue)])) + ) + + def modifyValues(f: PartialEndo[BsonValue]): BsonDocument = + BsonDocument( + bd.asScala.view.mapValues(f.applyOrElse(_, identity[BsonValue])) + ) + + /** + * Add or update + */ + def +!(el: (String, BsonValue)): BsonDocument = + if (bd.containsKey(el._1)) + bd.clone().tap { doc => + val existing = doc.get(el._1) + doc.put(el._1, merge(existing, el._2)) + } + else + bd.clone().append(el._1, el._2) diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/bsonmerge.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/bsonmerge.scala new file mode 100644 index 0000000..f056a18 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/bsonmerge.scala @@ -0,0 +1,36 @@ +package ru.tinkoff.oolong.bson + +import scala.jdk.CollectionConverters.* +import scala.util.chaining.* + +import org.mongodb.scala.bson.* + +protected def merge( + base: BsonValue, + patch: BsonValue, + arraySubvalues: Boolean = false +): BsonValue = + (base, patch) match { + case (ld: BsonDocument, rd: BsonDocument) => + ld.clone().tap { left => + rd.forEach { (key, value) => + if (left.containsKey(key)) + left.put(key, merge(value, left.get(key))) + else + left.put(key, value) + } + } + case (baseArr: BsonArray, patchArr: BsonArray) => + val mrgPair = (l: BsonValue, r: BsonValue) => merge(l, r, arraySubvalues = true) + + if (baseArr.size >= patchArr.size) + BsonArray.fromIterable((baseArr.asScala zip patchArr.asScala).map(mrgPair.tupled)) + else + BsonArray.fromIterable( + baseArr.asScala + .zipAll(patchArr.asScala, BsonNull(), patchArr.asScala.last) + .map(mrgPair.tupled) + ) + case (p, BNull()) if arraySubvalues => p + case (_, p) => p + } diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/defaultdec.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/defaultdec.scala new file mode 100644 index 0000000..e288fa8 --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/defaultdec.scala @@ -0,0 +1,142 @@ +package ru.tinkoff.oolong.bson + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.Year +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.UUID +import scala.collection.immutable +import scala.collection.immutable.Map +import scala.collection.mutable +import scala.collection.mutable.Builder +import scala.concurrent.duration.Duration +import scala.concurrent.duration.FiniteDuration +import scala.jdk.CollectionConverters.* +import scala.util.Success +import scala.util.Try +import scala.util.matching.Regex + +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonNull +import org.mongodb.scala.bson.BsonObjectId +import org.mongodb.scala.bson.BsonValue + +given BsonDecoder[BsonValue] with + def fromBson(value: BsonValue): Try[BsonValue] = Success(value) + +given BsonDecoder[BsonObjectId] with + def fromBson(value: BsonValue): Try[BsonObjectId] = Try(value.asObjectId) + +given BsonDecoder[Boolean] with + def fromBson(value: BsonValue): Try[Boolean] = Try(value.asBoolean.getValue) + +given BsonDecoder[Int] with + def fromBson(value: BsonValue): Try[Int] = Try(value.asInt32.getValue) + +given BsonDecoder[Long] with + def fromBson(value: BsonValue): Try[Long] = Try(value.asInt64.getValue) + +given BsonDecoder[Double] with + def fromBson(value: BsonValue): Try[Double] = Try(value.asDouble.getValue) + +given BsonDecoder[BigDecimal] with + def fromBson(value: BsonValue): Try[BigDecimal] = Try(value.asDecimal128.getValue.bigDecimalValue) + +given BsonDecoder[String] with + def fromBson(value: BsonValue): Try[String] = Try(value.asString.getValue) + +given BsonDecoder[Instant] with + def fromBson(value: BsonValue): Try[Instant] = Try(Instant.ofEpochMilli(value.asDateTime.getValue)) + +given BsonDecoder[ZonedDateTime] = BsonDecoder[Instant].afterRead(_.atZone(ZoneOffset.UTC)) + +given BsonDecoder[LocalDate] = BsonDecoder[ZonedDateTime].afterRead(_.toLocalDate()) + +given BsonDecoder[LocalDateTime] = BsonDecoder[ZonedDateTime].afterRead(_.toLocalDateTime()) + +given BsonDecoder[Year] with + def fromBson(value: BsonValue): Try[Year] = Try(Year.of(value.asInt32.getValue)) + +given BsonDecoder[UUID] with + def fromBson(value: BsonValue): Try[UUID] = Try(UUID.fromString(value.asString.getValue)) + +given [T](using BsonDecoder[T]): BsonDecoder[Option[T]] with + def fromBson(value: BsonValue): Try[Option[T]] = value match { + case BNull() => Success(None) + case bv => BsonDecoder[T].fromBson(bv).map(Some(_)) + } + +protected def buildBsonDecoder[C[_], T: BsonDecoder](builder: => mutable.Builder[T, C[T]]): BsonDecoder[C[T]] = + BsonDecoder.ofArray( + _.asScala + .foldLeft[Try[mutable.Builder[T, C[T]]]](Success(builder))((seq, bv) => + seq.flatMap(sqb => BsonDecoder[T].fromBson(bv).map(sqb += _)) + ) + .map(_.result()) + ) + +given [T](using BsonDecoder[T]): BsonDecoder[Seq[T]] = buildBsonDecoder(Seq.newBuilder[T]) + +given [T](using BsonDecoder[T]): BsonDecoder[List[T]] = buildBsonDecoder(List.newBuilder[T]) + +given [T](using BsonDecoder[T]): BsonDecoder[Vector[T]] = buildBsonDecoder(Vector.newBuilder[T]) + +given [T](using BsonDecoder[T]): BsonDecoder[Set[T]] = buildBsonDecoder(Set.newBuilder[T]) + +given [T](using BsonDecoder[T]): BsonDecoder[Map[String, T]] = + BsonDecoder.ofDocument { doc => + Try { + val builder = immutable.Map.newBuilder[String, T] + + for ((k, v) <- doc.asScala) + builder += k -> BsonDecoder[T].fromBson(v).get + + builder.result() + } + } + +given [K, V](using BsonKeyDecoder[K], BsonDecoder[V]): BsonDecoder[Map[K, V]] = + BsonDecoder.ofDocument { doc => + Try { + val builder = immutable.Map.newBuilder[K, V] + + for ((key, value) <- doc.asScala) { + val k = BsonKeyDecoder[K].decode(key).get + val v = BsonDecoder[V].fromBson(value).get + builder += k -> v + } + + builder.result() + } + } + +given [L, R](using BsonDecoder[L], BsonDecoder[R]): BsonDecoder[L Either R] with + def fromBson(value: BsonValue): Try[L Either R] = + BsonDecoder[L].fromBson(value).map(Left(_)).orElse(BsonDecoder[R].fromBson(value).map(Right(_))) + +given [A, B](using BsonDecoder[A], BsonDecoder[B]): BsonDecoder[(A, B)] = + BsonDecoder.ofArray { arr => + for { + a <- Try(arr.get(0)).flatMap(BsonDecoder[A].fromBson) + b <- Try(arr.get(1)).flatMap(BsonDecoder[B].fromBson) + } yield (a, b) + } + +given BsonDecoder[FiniteDuration] with + def fromBson(value: BsonValue): Try[FiniteDuration] = + Try(value.asString().getValue).flatMap(str => + Try { + val d = Duration(str) + FiniteDuration(d.length, d.unit) + } + ) + +given BsonDecoder[Array[Byte]] with + def fromBson(value: BsonValue): Try[Array[Byte]] = + Try(value.asBinary().getData) + +given BsonDecoder[Regex] with + def fromBson(value: BsonValue): Try[Regex] = + Try(value.asRegularExpression()).map(bre => new Regex(bre.getPattern)) diff --git a/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/defaultenc.scala b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/defaultenc.scala new file mode 100644 index 0000000..1e8f9fa --- /dev/null +++ b/oolong-bson/src/main/scala/ru/tinkoff/oolong/bson/defaultenc.scala @@ -0,0 +1,92 @@ +package ru.tinkoff.oolong.bson + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.Year +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.UUID +import scala.concurrent.duration.FiniteDuration +import scala.util.matching.Regex + +import org.mongodb.scala.bson.* + +given BsonEncoder[BsonValue] with + extension (value: BsonValue) def bson: BsonValue = value + +given BsonEncoder[BsonObjectId] with + extension (value: BsonObjectId) def bson: BsonValue = value + +given BsonEncoder[Boolean] with + extension (value: Boolean) def bson: BsonValue = BsonBoolean(value) + +given BsonEncoder[Int] with + extension (value: Int) def bson: BsonValue = BsonInt32(value) + +given BsonEncoder[Long] with + extension (value: Long) def bson: BsonValue = BsonInt64(value) + +given BsonEncoder[Double] with + extension (value: Double) def bson: BsonValue = BsonDouble(value) + +given BsonEncoder[BigDecimal] with + extension (value: BigDecimal) def bson: BsonValue = BsonDecimal128(value) + +given BsonEncoder[String] with + extension (value: String) def bson: BsonValue = BsonString(value) + +given BsonEncoder[Instant] with + extension (value: Instant) def bson: BsonValue = BsonDateTime(value.toEpochMilli) + +given BsonEncoder[ZonedDateTime] = BsonEncoder[Instant].beforeWrite(_.toInstant) + +given BsonEncoder[LocalDate] = BsonEncoder[ZonedDateTime].beforeWrite(_.atStartOfDay(ZoneOffset.UTC)) + +given BsonEncoder[LocalDateTime] = BsonEncoder[ZonedDateTime].beforeWrite(_.atZone(ZoneOffset.UTC)) + +given BsonEncoder[Year] with + extension (value: Year) def bson: BsonValue = BsonInt32(value.getValue) + +given BsonEncoder[UUID] with + extension (value: UUID) def bson: BsonValue = BsonString(value.toString) + +given [T](using BsonEncoder[T]): BsonEncoder[Option[T]] with + extension (value: Option[T]) def bson: BsonValue = value.fold[BsonValue](BsonNull())(_.bson) + +given [T](using BsonEncoder[T]): BsonEncoder[Seq[T]] with + extension (value: Seq[T]) def bson: BsonValue = BsonArray.fromIterable(value.map(_.bson)) + +given [T](using BsonEncoder[T]): BsonEncoder[List[T]] with + extension (value: List[T]) def bson: BsonValue = BsonArray.fromIterable(value.map(_.bson)) + +given [T](using BsonEncoder[T]): BsonEncoder[Vector[T]] with + extension (value: Vector[T]) def bson: BsonValue = BsonArray.fromIterable(value.map(_.bson)) + +given [T](using BsonEncoder[T]): BsonEncoder[Set[T]] with + extension (value: Set[T]) def bson: BsonValue = BsonArray.fromIterable(value.map(_.bson)) + +given StrMapEncoder[T](using BsonEncoder[T]): BsonEncoder[Map[String, T]] with + extension (value: Map[String, T]) def bson: BsonValue = BsonDocument(value.view.mapValues(_.bson)) + +given [K, V](using BsonKeyEncoder[K], BsonEncoder[V]): BsonEncoder[Map[K, V]] with + extension (value: Map[K, V]) + def bson: BsonValue = + BsonDocument(value.map { case (key, value) => + BsonKeyEncoder[K].encode(key) -> value.bson + }) + +given [L, R](using BsonEncoder[L], BsonEncoder[R]): BsonEncoder[L Either R] with + extension (value: L Either R) def bson: BsonValue = value.map(_.bson).left.map(_.bson).merge + +given [A, B](using BsonEncoder[A], BsonEncoder[B]): BsonEncoder[(A, B)] with + extension (value: (A, B)) def bson: BsonValue = BsonArray(value._1.bson, value._2.bson) + +given BsonEncoder[FiniteDuration] with + extension (value: FiniteDuration) def bson: BsonValue = BsonString(value.toString()) + +given BsonEncoder[Array[Byte]] with + extension (value: Array[Byte]) def bson: BsonValue = BsonBinary(value) + +given BsonEncoder[Regex] with + extension (value: Regex) def bson: BsonValue = BsonRegularExpression(value) diff --git a/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/RoundRobinSpec.scala b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/RoundRobinSpec.scala new file mode 100644 index 0000000..e2e935c --- /dev/null +++ b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/RoundRobinSpec.scala @@ -0,0 +1,25 @@ +package ru.tinkoff.oolong.bson + +import scala.util.matching.Regex + +import org.scalactic.Equality +import org.scalatest.TryValues +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class RoundRobinSpec extends AnyFunSuite with Matchers with TryValues { + implicit private val regexEquality: Equality[Regex] = + (a: Regex, b: Any) => + b match { + case rb: Regex => a.regex == rb.regex + case _ => false + } + + test("Regex serialization") { + val group = "<(?[a-zA-Z0-9]+)>".r + + val sut = BsonDecoder[Regex].fromBson(group.bson) + + sut.success.value shouldEqual group + } +} diff --git a/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/BsonDecoderSpec.scala b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/BsonDecoderSpec.scala new file mode 100644 index 0000000..3f691db --- /dev/null +++ b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/BsonDecoderSpec.scala @@ -0,0 +1,114 @@ +package ru.tinkoff.oolong.bson.derivation + +import java.time.Instant +import java.time.Year + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonDocument +import org.scalatest.TryValues +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import ru.tinkoff.oolong.bson.* +import ru.tinkoff.oolong.bson.given + +class BsonDecoderSpec extends AnyFunSuite with Matchers with TryValues { + test("decode XXXCaseClass") { + val doc = BsonDocument( + "a" -> 1, + "b" -> 2, + "c" -> 3, + "d" -> 4, + "e" -> 5, + "f" -> 6, + "g" -> 7, + "h" -> 8, + "i" -> 9, + "j" -> 10, + "k" -> 11, + "l" -> 12, + "m" -> 13, + "n" -> 14, + "o" -> 15, + "p" -> 16, + "q" -> 17, + "r" -> 18, + "s" -> 19, + "t" -> 20, + "u" -> 21, + "v" -> 22, + "w" -> 23, + "x" -> 24, + "y" -> 25, + "z" -> 26 + ) + + val result = BsonDecoder[XXXCaseClass].fromBson(doc).success.value + + result shouldBe XXXCaseClass(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26) + } + + test("complex entity test") { + val testDoc = BsonDocument( + "_id" -> 42, + "name" -> "Peka", + "meta" -> BsonDocument( + "time" -> Instant.ofEpochSecond(1504787696).bson, + "seq" -> 228L, + "flag" -> false + ), + "linkId" -> 721, + "checks" -> BsonArray( + BsonDocument( + "year" -> Year.of(2018).bson, + "comment" -> "valid" + ) + ) + ) + + val entity = BsonDecoder[TestEntity].fromBson(testDoc).success.value + + entity shouldEqual TestEntity( + 42, + "Peka", + TestMeta(Instant.ofEpochSecond(1504787696), 228, flag = false), + None, + Some(721), + Seq(TestCheck(Year.of(2018), "valid")) + ) + } + + test("complex entity with defaults test") { + val testDoc = BsonDocument( + "_id" -> 42, + "meta" -> BsonDocument( + "time" -> Instant.ofEpochSecond(1504787696).bson, + "seq" -> 228L, + "flag" -> false + ), + "linkId" -> 721 + ) + + val entity = BsonDecoder[TestEntityWithDefaults].fromBson(testDoc).success.value + + entity shouldEqual TestEntityWithDefaults( + 42, + "test", + TestMeta(Instant.ofEpochSecond(1504787696), 228, flag = false), + None, + Some(721), + Seq() + ) + } + + test("container test") { + val testDoc = BsonDocument( + "value" -> 42 + ) + + val entity = BsonDecoder[TestContainer[Int]].fromBson(testDoc).success.value + + entity shouldEqual TestContainer(Some(42)) + } +} diff --git a/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/BsonEncoderSpec.scala b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/BsonEncoderSpec.scala new file mode 100644 index 0000000..89dc10e --- /dev/null +++ b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/BsonEncoderSpec.scala @@ -0,0 +1,104 @@ +package ru.tinkoff.oolong.bson.derivation + +import java.time.Instant +import java.time.Year +import scala.annotation.nowarn +import scala.jdk.CollectionConverters.* + +import org.mongodb.scala.bson.* +import org.scalactic.Equality +import org.scalactic.Prettifier +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import ru.tinkoff.oolong.bson.* +import ru.tinkoff.oolong.bson.given + +class BsonEncoderSpec extends AnyFunSuite with Matchers { + implicit private val bdocEq: Equality[BsonDocument] = (a: BsonDocument, b: Any) => + b match { + // noinspection SameElementsToEquals + case BDocument(d2) => + a.asScala.toVector sameElements d2.toVector + case _ => false + } + + implicit private val bdocPretty: Prettifier = + Prettifier { case doc: BsonDocument => doc.toJson } + + test("encode XXXCaseClass") { + val instance = + XXXCaseClass(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26) + + val result = instance.bson + + result shouldEqual BsonDocument( + "a" -> 1, + "b" -> 2, + "c" -> 3, + "d" -> 4, + "e" -> 5, + "f" -> 6, + "g" -> 7, + "h" -> 8, + "i" -> 9, + "j" -> 10, + "k" -> 11, + "l" -> 12, + "m" -> 13, + "n" -> 14, + "o" -> 15, + "p" -> 16, + "q" -> 17, + "r" -> 18, + "s" -> 19, + "t" -> 20, + "u" -> 21, + "v" -> 22, + "w" -> 23, + "x" -> 24, + "y" -> 25, + "z" -> 26 + ) + } + + test("complex entity test") { + val testData = TestEntity( + 42, + "Peka", + TestMeta(Instant.ofEpochSecond(1504787696), 228, flag = false), + None, + Some(721), + Seq(TestCheck(Year.of(2018), "valid")) + ) + + val doc = testData.bson + + doc shouldEqual BsonDocument( + "_id" -> 42, + "name" -> "Peka", + "meta" -> BsonDocument( + "time" -> Instant.ofEpochSecond(1504787696).bson, + "seq" -> 228L, + "flag" -> false + ), + "linkId" -> 721, + "checks" -> BsonArray( + BsonDocument( + "year" -> Year.of(2018).bson, + "comment" -> "valid" + ) + ) + ) + } + + test("container test") { + val testData = TestContainer(Some(42)) + + val doc = testData.bson + + doc shouldEqual BsonDocument( + "value" -> 42 + ) + } +} diff --git a/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/TestDomain.scala b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/TestDomain.scala new file mode 100644 index 0000000..2134adb --- /dev/null +++ b/oolong-bson/src/test/scala/ru/tinkoff/oolong/bson/derivation/TestDomain.scala @@ -0,0 +1,65 @@ +package ru.tinkoff.oolong.bson.derivation + +import java.time.Instant +import java.time.Year + +import ru.tinkoff.oolong.bson.BsonDecoder +import ru.tinkoff.oolong.bson.BsonEncoder +import ru.tinkoff.oolong.bson.annotation.BsonKey +import ru.tinkoff.oolong.bson.given + +case class TestMeta(time: Instant, seq: Long, flag: Boolean) derives BsonEncoder, BsonDecoder + +case class TestCheck(year: Year, comment: String) derives BsonEncoder, BsonDecoder + +case class TestEntity( + @BsonKey("_id") id: Int, + name: String, + meta: TestMeta, + comment: Option[String], + linkId: Option[Int], + checks: Seq[TestCheck] +) derives BsonEncoder, + BsonDecoder + +case class TestContainer[T](value: Option[T]) derives BsonEncoder, BsonDecoder + +case class TestEntityWithDefaults( + @BsonKey("_id") id: Int, + name: String = "test", + meta: TestMeta, + comment: Option[String], + linkId: Option[Int], + checks: Seq[TestCheck] = Seq() +) derives BsonEncoder, + BsonDecoder + +case class XXXCaseClass( + a: Int, + b: Int, + c: Int, + d: Int, + e: Int, + f: Int, + g: Int, + h: Int, + i: Int, + j: Int, + k: Int, + l: Int, + m: Int, + n: Int, + o: Int, + p: Int, + q: Int, + r: Int, + s: Int, + t: Int, + u: Int, + v: Int, + w: Int, + x: Int, + y: Int, + z: Int +) derives BsonEncoder, + BsonDecoder diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/AstParser.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/AstParser.scala new file mode 100644 index 0000000..e16e413 --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/AstParser.scala @@ -0,0 +1,312 @@ +package ru.tinkoff.oolong + +import scala.annotation.tailrec +import scala.language.postfixOps +import scala.quoted.* + +import ru.tinkoff.oolong.AstParser +import ru.tinkoff.oolong.UExpr.FieldUpdateExpr +import ru.tinkoff.oolong.Utils.* +import ru.tinkoff.oolong.dsl.* + +private[oolong] trait AstParser { + def parseQExpr[Doc: Type](input: Expr[Doc => Boolean])(using quotes: Quotes): QExpr + + def parseUExpr(input: Expr[UpdateDslNode[_]])(using quotes: Quotes): UExpr +} + +private[oolong] object DefaultAstParser extends AstParser { + + trait MakeConst[T, Ast] { + def apply(t: T): Ast + } + + def extractConstant[T](valueOpt: Option[T])(using quotes: Quotes): T = { + import quotes.reflect.* + + valueOpt.getOrElse(report.errorAndAbort("Use `lift` for runtime values")) + } + + def getConstant[T: Type, Ast](expr: Expr[T])(using quotes: Quotes, makeConst: MakeConst[T, Ast]): Ast = { + import quotes.reflect.* + + expr match { + case '{ ${ t }: Long } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Int } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Short } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Byte } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Double } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Float } => makeConst(extractConstant(t.value)) + case '{ ${ t }: String } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Char } => makeConst(extractConstant(t.value)) + case '{ ${ t }: Boolean } => makeConst(extractConstant(t.value)) + case _ => + report.errorAndAbort("Unsupported constant type, consider using `lift`") + } + } + + override def parseQExpr[Doc: Type](input: Expr[Doc => Boolean])(using quotes: Quotes): QExpr = { + import quotes.reflect.* + + given makeConst[T]: MakeConst[T, QExpr] = (t: T) => QExpr.Constant(t) + + def showTerm(term: Term) = term.show(using Printer.TreeStructure) + + def unwrapLambda(input: Term): (String, Term) = input match { + case Inlined(_, _, expansion) => + unwrapLambda(expansion) + case AnonfunBlock(paramName, body) => + (paramName, body) + case _ => + report.errorAndAbort(s"Expected a lambda, got ${showTerm(input)}") + } + + def doParse(lambdaParamName: String, lambdaBody: Expr[_]): QExpr = { + + def parseIterable[T: Type](expr: Expr[Seq[T] | Set[T]])(using q: Quotes): List[QExpr] | QExpr = { + import q.reflect.* + expr match { + case AsIterable(elems) => + elems.map { + case '{ $t: Boolean } => makeConst(extractConstant[Boolean](t.value)) + case '{ $t: Long } => makeConst(extractConstant[Long](t.value)) + case '{ $t: Int } => makeConst(extractConstant[Int](t.value)) + case '{ $t: Short } => makeConst(extractConstant[Short](t.value)) + case '{ $t: Byte } => makeConst(extractConstant[Byte](t.value)) + case '{ $t: Double } => makeConst(extractConstant[Double](t.value)) + case '{ $t: Float } => makeConst(extractConstant[Float](t.value)) + case '{ $t: String } => makeConst(extractConstant[String](t.value)) + case '{ $t: Char } => makeConst(extractConstant[Char](t.value)) + case '{ lift($x: t) } => QExpr.ScalaCode(x) + case x => QExpr.ScalaCode(x) // are we sure we need this this case? + }.toList + case '{ type t; lift($x: Seq[`t`] | Set[`t`]) } => QExpr.ScalaCodeIterable(x) + case _ => + report.errorAndAbort("Unexpected expr while parsing AST: " + expr.asTerm.show(using Printer.TreeStructure)) + } + } + + def parse(input: Expr[_]): QExpr = input match { + case '{ ($x: Boolean) || ($y: Boolean) } => + QExpr.Or(List(parse(x), parse(y))) + + case '{ ($x: Boolean) && ($y: Boolean) } => + QExpr.And(List(parse(x), parse(y))) + + case '{ ($x: Seq[_]).size == ($y: Int) } => + QExpr.Size(parse(x), parse(y)) + + case '{ ($x: Seq[_]).length == ($y: Int) } => + QExpr.Size(parse(x), parse(y)) + + case AsTerm(Apply(Select(lhs, "<="), List(rhs))) => + QExpr.Lte(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Apply(Select(lhs, ">="), List(rhs))) => + QExpr.Gte(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Apply(Select(lhs, "=="), List(rhs))) => + QExpr.Eq(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Apply(Select(lhs, "<"), List(rhs))) => + QExpr.Lt(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Apply(Select(lhs, ">"), List(rhs))) => + QExpr.Gt(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Apply(Select(lhs, "!="), List(rhs))) => + QExpr.Ne(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Apply(TypeApply(Select(lhs @ Select(_, _), "contains"), _), List(rhs))) => + QExpr.Eq(parse(lhs.asExpr), parse(rhs.asExpr)) + + case AsTerm(Select(Apply(TypeApply(Select(lhs @ Select(_, _), "contains"), _), List(rhs)), "unary_!")) => + QExpr.Ne(parse(lhs.asExpr), parse(rhs.asExpr)) + + case '{ type t; ($s: Seq[`t`]).contains($x: `t`) } => + QExpr.In(parse(x), parseIterable(s)) + + case '{ type t; !($s: Seq[`t`]).contains($x: `t`) } => + QExpr.Nin(parse(x), parseIterable(s)) + + case '{ type t; ($s: Set[`t`]).contains($x: `t`) } => + QExpr.In(parse(x), parseIterable(s)) + + case '{ type t; !($s: Set[`t`]).contains($x: `t`) } => + QExpr.Nin(parse(x), parseIterable(s)) + + case '{ ($x: Iterable[_]).isEmpty } => + QExpr.Size(parse(x), QExpr.Constant(0)) + + case '{ ($x: Option[_]).isEmpty } => + QExpr.Exists(parse(x), QExpr.Constant(false)) + + case '{ ($x: Option[_]).isDefined } => + QExpr.Exists(parse(x), QExpr.Constant(true)) + + case PropSelector(name, path) if name == lambdaParamName => + QExpr.Prop(path) + + case '{ lift($x: t) } => + QExpr.ScalaCode(x) + + case '{ unchecked($x: t) } => + QExpr.Subquery(x) + + case '{ !($x: Boolean) } => + QExpr.Not(parse(x)) + + case AsTerm(Literal(DoubleConstant(c))) => + makeConst(c) + + case AsTerm(Literal(FloatConstant(c))) => + makeConst(c) + + case AsTerm(Literal(LongConstant(c))) => + makeConst(c) + + case AsTerm(Literal(IntConstant(c))) => + makeConst(c) + + case AsTerm(Literal(ShortConstant(c))) => + makeConst(c) + + case AsTerm(Literal(ByteConstant(c))) => + makeConst(c) + + case AsTerm(Literal(StringConstant(c))) => + makeConst(c) + + case AsTerm(Literal(CharConstant(c))) => + makeConst(c) + + case AsTerm(Literal(BooleanConstant(c))) => + makeConst(c) + + case _ => + report.errorAndAbort("Unexpected expr while parsing AST: " + input.show + s"; term: ${showTerm(input.asTerm)}") + } + + parse(lambdaBody) + } + + val (param, rhs) = unwrapLambda(input.asTerm) + doParse(param, rhs.asExpr) + } + + override def parseUExpr(input: Expr[UpdateDslNode[_]])(using quotes: Quotes): UExpr = { + import quotes.reflect.* + + given makeConst[T]: MakeConst[T, UExpr] = (t: T) => UExpr.Constant(t) + + // format: off + @tailrec + def parseUpdater[DocT]( + expr: Expr[Updater[DocT]], + acc: List[FieldUpdateExpr] + ): UExpr.Update = + expr match { + case '{ update[docT] } => + UExpr.Update(acc) + + case '{ type t; ($updater: Updater[docT]).set[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Set(UExpr.Prop(prop), value) :: acc) + + case '{ type t; ($updater: Updater[docT]).setOpt[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Set(UExpr.Prop(prop), value) :: acc) + + case '{type t; ($updater: Updater[docT]).unset[`t`]($selectProp)} => + val prop = parsePropSelector(selectProp) + parseUpdater(updater, FieldUpdateExpr.Unset(UExpr.Prop(prop)) :: acc) + + case '{ type t; ($updater: Updater[docT]).inc[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + Expr.summon[Numeric[t]] match { + case Some(_) => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Inc(UExpr.Prop(prop), value) :: acc) + case _ => report.errorAndAbort(s"Trying to $$inc field that is not numeric") + } + + case '{ type t; ($updater: Updater[docT]).mul[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + Expr.summon[Numeric[t]] match { + case Some(_) => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Mul(UExpr.Prop(prop), value) :: acc) + case _ => report.errorAndAbort(s"Trying to $$mul field that is not numeric") + } + + case '{ type t; ($updater: Updater[docT]).min[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Min(UExpr.Prop(prop), value) :: acc) + + case '{ type t; ($updater: Updater[docT]).max[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Max(UExpr.Prop(prop), value) :: acc) + + case '{ type t; ($updater: Updater[docT]).rename[`t`]($selectProp, ($valueExpr: String)) } => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.Rename(UExpr.Prop(prop), value) :: acc) + + case '{ type t; ($updater: Updater[docT]).setOnInsert[`t`, `t`]($selectProp, ($valueExpr: `t`)) } => + val prop = parsePropSelector(selectProp) + val value = getValue(valueExpr) + parseUpdater(updater, FieldUpdateExpr.SetOnInsert(UExpr.Prop(prop), value) :: acc) + + case _ => + report.errorAndAbort(s"Unexpected expr while parsing an 'update': ${expr.show}") + } + + input match { + case '{ $updater: Updater[docT] } => parseUpdater(updater, Nil) + + case _ => report.errorAndAbort(s"Unexpected expr while parsing an AST for 'update': ${input.show}") + } + } + //format: on + + private def getValue(expr: Expr[Any])(using Quotes): UExpr = + given makeConst[T]: MakeConst[T, UExpr] = (t: T) => UExpr.Constant(t) + expr match + case '{ lift($x) } => UExpr.ScalaCode(x) + case '{ $constant: t } => getConstant(constant) + + private def parsePropSelector[DocT, PropT](select: Expr[DocT => PropT])(using quotes: Quotes): List[String] = { + import quotes.reflect.* + + def showTerm(term: Term) = term.show(using Printer.TreeStructure) + + def extractBody(lambda: Term): Term = + lambda match { + case Inlined(_, _, inlined) => + extractBody(inlined) + case Lambda(_, body) => + body + case term => + report.errorAndAbort(s"Expected lambda, got ${showTerm(term)}") + } + + def extractSelectors(body: Term, acc: List[String] = Nil): List[String] = + body match { + case Select(from, field) => + extractSelectors(from, field +: acc) + case Ident(_) => + acc + case Apply(TypeApply(Ident("!!"), _), List(term)) => + extractSelectors(term, acc) + case term => + report.errorAndAbort(s"Expected selectors, got ${showTerm(term)}") + } + + extractSelectors(extractBody(select.asTerm)) + } + +} diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/Backend.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/Backend.scala new file mode 100644 index 0000000..e4a6bbb --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/Backend.scala @@ -0,0 +1,27 @@ +package ru.tinkoff.oolong + +import scala.quoted.Expr +import scala.quoted.Quotes + +private[oolong] trait Backend[Ast, OptimizableRepr, TargetRepr] { + + /** + * Translate AST into a form that allows us to do backend optimizations. + */ + def opt(ast: Ast)(using quotes: Quotes): OptimizableRepr + + /** + * Render the final optimized version of a query. Output will be displayed as a compilation message. + */ + def render(query: OptimizableRepr)(using quotes: Quotes): String + + /** + * Translate an optimized query into the target represantion; also lift the result into Expr[]. + */ + def target(optimized: OptimizableRepr)(using quotes: Quotes): Expr[TargetRepr] + + /** + * Perform optimizations that are specific to this backend. + */ + def optimize(query: OptimizableRepr): OptimizableRepr = query +} diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/LogicalOptimizer.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/LogicalOptimizer.scala new file mode 100644 index 0000000..15b9f52 --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/LogicalOptimizer.scala @@ -0,0 +1,48 @@ +package ru.tinkoff.oolong + +private[oolong] object LogicalOptimizer { + + def optimize(ast: QExpr): QExpr = { + + // Example: + // + // grandparent: And(And(x, y), z)) + // / \ + // parent: And(x, y) z + // / \ + // x y + // + // should be transformed into + // + // grandparent: And(x, y, z) + // / | \ + // x y z + def flatten(grandparent: QExpr): QExpr = grandparent match { + case QExpr.And(parents) => + val newParents = parents.flatMap { + case QExpr.And(children) => children + case parent => List(parent) + } + QExpr.And(newParents) + case QExpr.Or(parents) => + val newParents = parents.flatMap { + case QExpr.Or(children) => children + case parent => List(parent) + } + QExpr.Or(newParents) + case _ => + grandparent + } + + ast match { + case QExpr.And(children) => flatten(QExpr.And(children.map(optimize))) + case QExpr.Or(children) => flatten(QExpr.Or(children.map(optimize))) + case QExpr.Gte(x, y) => QExpr.Gte(optimize(x), optimize(y)) + case QExpr.Lte(x, y) => QExpr.Lte(optimize(x), optimize(y)) + case QExpr.Eq(x, y) => QExpr.Eq(optimize(x), optimize(y)) + case QExpr.Plus(x, y) => QExpr.Plus(optimize(x), optimize(y)) + case QExpr.Minus(x, y) => QExpr.Plus(optimize(x), optimize(y)) + case _ => ast + } + } +} diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/QExpr.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/QExpr.scala new file mode 100644 index 0000000..8645f0b --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/QExpr.scala @@ -0,0 +1,49 @@ +package ru.tinkoff.oolong + +import scala.quoted.Expr + +import ru.tinkoff.oolong.QExpr + +sealed private[oolong] trait QExpr + +private[oolong] object QExpr { + + case class Gte(x: QExpr, y: QExpr) extends QExpr + + case class Lte(x: QExpr, y: QExpr) extends QExpr + + case class Gt(x: QExpr, y: QExpr) extends QExpr + + case class Lt(x: QExpr, y: QExpr) extends QExpr + + case class Eq(x: QExpr, y: QExpr) extends QExpr + + case class Ne(x: QExpr, y: QExpr) extends QExpr + + case class Not(x: QExpr) extends QExpr + + case class In(x: QExpr, y: List[QExpr] | QExpr) extends QExpr + case class Nin(x: QExpr, y: List[QExpr] | QExpr) extends QExpr + + case class And(children: List[QExpr]) extends QExpr + + case class Or(children: List[QExpr]) extends QExpr + + case class Plus(x: QExpr, y: QExpr) extends QExpr + + case class Minus(x: QExpr, y: QExpr) extends QExpr + + case class Prop(path: List[String]) extends QExpr + + case class Constant[T](s: T) extends QExpr + + case class ScalaCode(code: Expr[Any]) extends QExpr + + case class ScalaCodeIterable(code: Expr[Iterable[Any]]) extends QExpr + + case class Subquery(code: Expr[Any]) extends QExpr + + case class Exists(x: QExpr, y: QExpr) extends QExpr + + case class Size(x: QExpr, y: QExpr) extends QExpr +} diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/UExpr.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/UExpr.scala new file mode 100644 index 0000000..5b182b8 --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/UExpr.scala @@ -0,0 +1,40 @@ +package ru.tinkoff.oolong + +import scala.quoted.Expr + +import ru.tinkoff.oolong.UExpr + +sealed private[oolong] trait UExpr + +private[oolong] object UExpr { + + case class Update(ops: List[FieldUpdateExpr]) extends UExpr + + case class Prop(path: List[String]) extends UExpr + + case class Constant[T](t: T) extends UExpr + + case class ScalaCode(code: Expr[Any]) extends UExpr + + sealed abstract class FieldUpdateExpr(prop: Prop) + + object FieldUpdateExpr { + + case class Set(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop: Prop) + + case class Inc(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop) + + case class Unset(prop: Prop) extends FieldUpdateExpr(prop) + + case class Min(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop) + + case class Max(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop) + + case class Mul(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop) + + case class Rename(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop) + + case class SetOnInsert(prop: Prop, expr: UExpr) extends FieldUpdateExpr(prop) + } + +} diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala new file mode 100644 index 0000000..7c814ad --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala @@ -0,0 +1,81 @@ +package ru.tinkoff.oolong + +import scala.quoted.* + +private[oolong] object Utils { + + def useWithinMacro(name: String) = + scala.sys.error(s"`$name` should only be used within `compile` macro") + + object AsIterable { + def unapply[T: Type](expr: Expr[Iterable[T]])(using q: Quotes): Option[Iterable[Expr[T]]] = { + import q.reflect.* + def rec(tree: Term): Option[Iterable[Expr[T]]] = tree match { + case Repeated(elems, _) => Some(elems.map(x => x.asExprOf[T])) + case Typed(e, _) => rec(e) + case Block(Nil, e) => rec(e) + case Apply(_, List(e)) => rec(e) + case Inlined(_, Nil, e) => rec(e) + case _ => None + } + rec(expr.asTerm) + } + } + + object AnonfunBlock { + def unapply(using quotes: Quotes)(term: quotes.reflect.Term): Option[(String, quotes.reflect.Term)] = { + import quotes.reflect.* + term match { + case Block( + List( + DefDef( + "$anonfun", + List(List(ValDef(paramName, _, _))), + _, + Some(rhs) + ) + ), + Closure(Ident("$anonfun"), _) + ) => + Some((paramName, rhs)) + case _ => None + } + } + } + + object AsTerm { + def unapply(expr: Expr[Any])(using quotes: Quotes): Option[quotes.reflect.Term] = { + import quotes.reflect.* + Some(expr.asTerm) + } + } + + object PropSelector { + private def parse(using quotes: Quotes)( + term: quotes.reflect.Term + ): Option[(String, List[String])] = { + import quotes.reflect.* + + def loop(current: Term, acc: List[String]): Option[(String, List[String])] = + current match { + case Select(next, field) => + loop(next, field :: acc) + case Apply(TypeApply(Ident("!!"), _), List(next)) => + loop(next, acc) + case Ident(name) => + Some((name, acc)) + case _ => + None + } + + loop(term, Nil) + } + + def unapply(using quotes: Quotes)( + expr: Expr[_] + ): Option[(String, List[String])] = { + import quotes.reflect.* + parse(expr.asTerm) + } + } +} diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/dsl/Dsl.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/dsl/Dsl.scala new file mode 100644 index 0000000..16794ca --- /dev/null +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/dsl/Dsl.scala @@ -0,0 +1,56 @@ +package ru.tinkoff.oolong.dsl + +import ru.tinkoff.oolong.Utils.* + +/** + * Lift `a` into the target representation. The expression for `a` won't be analyzed by the macro, it'll be passed + * through to the final stage. Use this for runtime values. + */ +def lift[A](a: A): A = + useWithinMacro("lift") + +/** + * Wrapper for a subquery that's already in the target representation. + */ +def unchecked[A](subquery: Any): A = + useWithinMacro("unchecked") + +extension [A](a: Option[A]) + /** + * Unwrap the underlying type out of option + */ + def !! : A = useWithinMacro("!!") + +sealed trait UpdateDslNode[A] + +class Updater[DocT] extends UpdateDslNode[Nothing] { + def set[PropT, ValueT](selectProp: DocT => PropT, value: ValueT)(using + PropT =:= ValueT + ): Updater[DocT] = + useWithinMacro("set") + def setOpt[PropT, ValueT](selectProp: DocT => Option[PropT], value: ValueT)(using + PropT =:= ValueT + ): Updater[DocT] = + useWithinMacro("setOpt") + def inc[PropT, ValueT](selectProp: DocT => PropT, value: ValueT)(using + PropT =:= ValueT, + ): Updater[DocT] = useWithinMacro("inc") + def mul[PropT, ValueT](selectProp: DocT => PropT, value: ValueT)(using + PropT =:= ValueT, + ): Updater[DocT] = useWithinMacro("mul") + def min[PropT, ValueT](selectProp: DocT => PropT, value: ValueT)(using + PropT =:= ValueT, + ): Updater[DocT] = useWithinMacro("mul") + def max[PropT, ValueT](selectProp: DocT => PropT, value: ValueT)(using + PropT =:= ValueT, + ): Updater[DocT] = useWithinMacro("mul") + def unset[PropT](selectProp: DocT => PropT): Updater[DocT] = useWithinMacro("unset") + def rename[PropT](selectProp: DocT => PropT, newName: String): Updater[DocT] = useWithinMacro("rename") + def setOnInsert[PropT, ValueT](selectProp: DocT => PropT, value: ValueT)(using + PropT =:= ValueT, + ): Updater[DocT] = useWithinMacro("setOnInsert") + +} + +def update[A]: Updater[A] = + useWithinMacro("update") diff --git a/oolong-core/src/test/scala/ru/tinkoff/oolong/PropSelectorSpec.scala b/oolong-core/src/test/scala/ru/tinkoff/oolong/PropSelectorSpec.scala new file mode 100644 index 0000000..7ba9fac --- /dev/null +++ b/oolong-core/src/test/scala/ru/tinkoff/oolong/PropSelectorSpec.scala @@ -0,0 +1,55 @@ +package ru.tinkoff.oolong.mongo + +import org.scalatest.funsuite.AnyFunSuite + +import ru.tinkoff.oolong.PropSelectorTestMacro.prop +import ru.tinkoff.oolong.dsl.* + +class PropSelectorSpec extends AnyFunSuite { + + final case class Address( + countryCode: String, + countryAddress: CountryAddress, + ) + + final case class CountryAddress( + city: String, + cityAddress: Option[CityAddress] + ) + + final case class CityAddress( + street: String, + building: Long, + buildingAddress: BuildingAddress + ) + + final case class BuildingAddress( + apartment: Int, + apartmentAddress: Option[ApartmentAddress] + ) + + final case class ApartmentAddress( + room: Int, + ) + + test("basic field selector") { + val result = prop[Address](addr => addr.countryCode) + + assert(result == Some("addr", List("countryCode"))) + } + test("long field selector") { + val result = prop[Address](addr => addr.countryAddress.city) + + assert(result == Some("addr", List("countryAddress", "city"))) + } + test("one optional field selector") { + val result = prop[Address](addr => addr.countryAddress.cityAddress.!!.street) + + assert(result == Some("addr", List("countryAddress", "cityAddress", "street"))) + } + test("repeated optional field selector") { + val result = prop[Address](addr => addr.countryAddress.cityAddress.!!.buildingAddress.apartmentAddress.!!.room) + + assert(result == Some("addr", List("countryAddress", "cityAddress", "buildingAddress", "apartmentAddress", "room"))) + } +} diff --git a/oolong-core/src/test/scala/ru/tinkoff/oolong/PropSelectorTestMacro.scala b/oolong-core/src/test/scala/ru/tinkoff/oolong/PropSelectorTestMacro.scala new file mode 100644 index 0000000..3086fc3 --- /dev/null +++ b/oolong-core/src/test/scala/ru/tinkoff/oolong/PropSelectorTestMacro.scala @@ -0,0 +1,26 @@ +package ru.tinkoff.oolong + +import scala.quoted.* + +import ru.tinkoff.oolong.Utils.AnonfunBlock +import ru.tinkoff.oolong.Utils.PropSelector + +object PropSelectorTestMacro { + inline def prop[A](inline expr: A => Any): Option[(String, List[String])] = + ${ propImpl('expr) } + + def propImpl[A](expr: Expr[A => Any])(using quotes: Quotes): Expr[Option[(String, List[String])]] = { + import quotes.reflect.* + + def preprocess(term: Term): Term = term match { + case Inlined(_, _, next) => preprocess(next) + case AnonfunBlock(_, body) => preprocess(body) + case _ => term + } + + val inlined = preprocess(expr.asTerm).asExpr + + Expr(PropSelector.unapply(inlined)) + } + +} diff --git a/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/BsonUtils.scala b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/BsonUtils.scala new file mode 100644 index 0000000..b83f5bd --- /dev/null +++ b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/BsonUtils.scala @@ -0,0 +1,28 @@ +package ru.tinkoff.oolong.mongo + +import scala.quoted.Expr +import scala.quoted.Quotes + +import org.mongodb.scala.bson.* +import org.mongodb.scala.bson.BsonValue + +import ru.tinkoff.oolong.bson.BsonEncoder +import ru.tinkoff.oolong.bson.given + +private[oolong] object BsonUtils { + + def extractLifted(expr: Expr[Any])(using q: Quotes): Expr[BsonValue] = + import q.reflect.* + expr match { + case '{ $s: Long } => '{ ${ s }.bson } + case '{ $s: Int } => '{ ${ s }.bson } + case '{ $s: String } => '{ ${ s }.bson } + case '{ $s: Boolean } => '{ ${ s }.bson } + case '{ $s: t } => + Expr.summon[BsonEncoder[t]] match { + case Some(encoder) => '{ ${ encoder }.bson(${ s }) } + case _ => report.errorAndAbort(s"Didn't find bson encoder for type ${TypeRepr.of[t].show}") + } + } + +} diff --git a/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoQueryCompiler.scala b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoQueryCompiler.scala new file mode 100644 index 0000000..ed8a9d0 --- /dev/null +++ b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoQueryCompiler.scala @@ -0,0 +1,212 @@ +package ru.tinkoff.oolong.mongo + +import scala.quoted.Expr +import scala.quoted.Quotes + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonBoolean +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonDouble +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonInt64 +import org.mongodb.scala.bson.BsonString +import org.mongodb.scala.bson.BsonValue + +import ru.tinkoff.oolong.* +import ru.tinkoff.oolong.bson.* +import ru.tinkoff.oolong.mongo.MongoQueryNode as MQ + +object MongoQueryCompiler extends Backend[QExpr, MQ, BsonDocument] { + + override def opt(ast: QExpr)(using quotes: Quotes): MongoQueryNode = { + import quotes.reflect.* + + ast match { + case QExpr.Prop(path) => MQ.Field(path) + case QExpr.Gte(x, y) => MQ.OnField(getField(x), MQ.Gte(opt(y))) + case QExpr.Lte(x, y) => MQ.OnField(getField(x), MQ.Lte(opt(y))) + case QExpr.Gt(x, y) => MQ.OnField(getField(x), MQ.Gt(opt(y))) + case QExpr.Lt(x, y) => MQ.OnField(getField(x), MQ.Lt(opt(y))) + case QExpr.Eq(x, y) => MQ.OnField(getField(x), MQ.Eq(opt(y))) + case QExpr.Ne(x, y) => MQ.OnField(getField(x), MQ.Ne(opt(y))) + case QExpr.In(x, exprs) => + MQ.OnField( + getField(x), + MQ.In(handleArrayConds(exprs)) + ) + case QExpr.Nin(x, exprs) => + MQ.OnField( + getField(x), + MQ.Nin(handleArrayConds(exprs)) + ) + case QExpr.And(exprs) => MQ.And(exprs map opt) + case QExpr.Or(exprs) => MQ.Or(exprs map opt) + case QExpr.Constant(s) => MQ.Constant(s) + case QExpr.Exists(x, y) => MQ.OnField(getField(x), MQ.Exists(opt(y))) + case QExpr.Size(x, y) => MQ.OnField(getField(x), MQ.Size(opt(y))) + case QExpr.ScalaCode(code) => MQ.ScalaCode(code) + case QExpr.ScalaCodeIterable(iter) => MQ.ScalaCodeIterable(iter) + case QExpr.Subquery(code) => + code match { + case '{ $doc: BsonDocument } => MQ.Subquery(doc) + case _ => + report.errorAndAbort(s"Expected the subquery inside 'unchecked(...)' to have 'org.mongodb.scala.bson.BsonDocument' type, but the subquery is '${code.show}'") + } + case not: QExpr.Not => handleInnerNot(not) + } + } + + def getField(f: QExpr)(using quotes: Quotes): MQ.Field = + import quotes.reflect.* + f match + case QExpr.Prop(path) => MQ.Field(path) + case _ => report.errorAndAbort("Field is of wrong type") + + def handleInnerNot(not: QExpr.Not)(using quotes: Quotes): MongoQueryNode = + import quotes.reflect.* + not.x match + case QExpr.Gte(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Gte(opt(y)))) + case QExpr.Lte(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Lte(opt(y)))) + case QExpr.Gt(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Gt(opt(y)))) + case QExpr.Lt(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Lt(opt(y)))) + case QExpr.Eq(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Eq(opt(y)))) + case QExpr.Ne(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Ne(opt(y)))) + case QExpr.Size(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Size(opt(y)))) + case QExpr.In(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.In(handleArrayConds(y)))) + case QExpr.Nin(x, y) => MQ.OnField(getField(x), MQ.Not(MQ.Nin(handleArrayConds(y)))) + case _ => report.errorAndAbort("Wrong operator inside $not") + + def handleArrayConds(x: List[QExpr] | QExpr)(using quotes: Quotes): List[MQ] | MQ = + x match + case list: List[QExpr @unchecked] => list map opt + case expr: QExpr => opt(expr) + + override def render(node: MongoQueryNode)(using quotes: Quotes): String = + import quotes.reflect.* + node match + case MQ.OnField(prop, x) => "{ " + "\"" + prop.path.mkString(".") + "\"" + ": " + render(x) + " }" + case MQ.Gte(x) => "{ $gte: " + render(x) + " }" + case MQ.Lte(x) => "{ $lte: " + render(x) + " }" + case MQ.Gt(x) => "{ $gt: " + render(x) + " }" + case MQ.Lt(x) => "{ $lt: " + render(x) + " }" + case MQ.Eq(x) => "{ $eq: " + render(x) + " }" + case MQ.Ne(x) => "{ $ne: " + render(x) + " }" + case MQ.Not(x) => "{ $not: " + render(x) + " }" + case MQ.Size(x) => "{ $size: " + render(x) + " }" + case MQ.In(exprs) => "{ $in: [" + renderArrays(exprs) + "] }" + case MQ.Nin(exprs) => "{ $nin: [" + renderArrays(exprs) + "] }" + case MQ.And(exprs) => "{ $and: [ " + exprs.map(render).mkString(", ") + " ] }" + case MQ.Or(exprs) => "{ $or: [ " + exprs.map(render).mkString(", ") + " ] }" + case MQ.Exists(x) => " { $exists: " + render(x) + " }" + case MQ.Constant(s: String) => "\"" + s + "\"" + case MQ.Constant(s: Any) => s.toString // also limit + case MQ.ScalaCode(_) => "?" + case MQ.ScalaCodeIterable(_) => "?" + case MQ.Subquery(doc) => "{...}" + case MQ.Field(field) => + report.errorAndAbort(s"There is not filter condition on field ${field.mkString(".")}") + + def renderArrays(x: List[MQ] | MQ)(using Quotes): String = x match + case list: List[MQ @unchecked] => list.map(render).mkString(", ") + case node: MQ => render(node) + + override def target(optRepr: MongoQueryNode)(using quotes: Quotes): Expr[BsonDocument] = + import quotes.reflect.* + optRepr match { + case and: MQ.And => handleAnd(and) + case or: MQ.Or => handleOr(or) + case MQ.OnField(prop, x) => '{ BsonDocument(${ Expr(prop.path.mkString(".")) } -> ${ target(x) }) } + case MQ.Gte(x) => + '{ BsonDocument("$gte" -> ${ handleValues(x) }) } + case MQ.Lte(x) => + '{ BsonDocument("$lte" -> ${ handleValues(x) }) } + case MQ.Gt(x) => + '{ BsonDocument("$gt" -> ${ handleValues(x) }) } + case MQ.Lt(x) => + '{ BsonDocument("$lt" -> ${ handleValues(x) }) } + case MQ.Eq(x) => + '{ BsonDocument("$eq" -> ${ handleValues(x) }) } + case MQ.Ne(x) => + '{ BsonDocument("$ne" -> ${ handleValues(x) }) } + case MQ.Size(x) => + '{ BsonDocument("$size" -> ${ handleValues(x) }) } + case MQ.In(exprs) => + '{ + BsonDocument("$in" -> ${ handleArrayCond(exprs) }) + } + case MQ.Nin(exprs) => + '{ + BsonDocument("$nin" -> ${ handleArrayCond(exprs) }) + } + case MQ.Not(x) => + '{ BsonDocument("$not" -> ${ target(x) }) } + case MQ.Exists(x) => + '{ BsonDocument("$exists" -> ${ handleValues(x) }) } + case MQ.Subquery(doc) => doc + case _ => report.errorAndAbort("given node can't be in that position") + } + + def handleArrayCond(x: List[MQ] | MQ)(using q: Quotes): Expr[BsonValue] = + import q.reflect.* + x match + case list: List[MQ @unchecked] => + '{ + BsonArray.fromIterable(${ + Expr.ofList(list.map(handleValues)) + }) + } + case MQ.ScalaCodeIterable(expr) => + expr match + case '{ $l: Iterable[t] } => + Expr.summon[BsonEncoder[t]] match { + case Some(encoder) => '{ BsonArray.fromIterable(${ l } map (s => ${ encoder }.bson(s))) } + case _ => report.errorAndAbort(s"Didn't find bson encoder for type ${TypeRepr.of[t].show}") + } + case _ => report.errorAndAbort("Incorrect condition for array") + + def handleAnd(and: MQ.And)(using q: Quotes): Expr[BsonDocument] = + '{ + BsonDocument("$and" -> BsonArray.fromIterable(${ + Expr.ofList(and.exprs.map(target)) + })) + } + + def handleOr(or: MQ.Or)(using q: Quotes): Expr[BsonDocument] = + '{ + BsonDocument("$or" -> BsonArray.fromIterable(${ + Expr.ofList(or.exprs.map(target)) + })) + } + + def handleValues(expr: MongoQueryNode)(using q: Quotes): Expr[BsonValue] = + import q.reflect.* + expr match { + case MQ.Constant(i: Long) => + '{ BsonInt64.apply(${ Expr(i: Long) }) } + case MQ.Constant(i: Int) => + '{ BsonInt32.apply(${ Expr(i: Int) }) } + case MQ.Constant(i: Short) => + '{ BsonInt32.apply(${ Expr(i: Short) }) } + case MQ.Constant(i: Byte) => + '{ BsonInt32.apply(${ Expr(i: Byte) }) } + case MQ.Constant(s: Double) => + '{ BsonDouble.apply(${ Expr(s: Double) }) } + case MQ.Constant(s: Float) => + '{ BsonDouble.apply(${ Expr(s: Float) }) } + case MQ.Constant(s: String) => + '{ BsonString.apply(${ Expr(s: String) }) } + case MQ.Constant(s: Char) => + '{ BsonString.apply(${ Expr((s: Char).toString) }) } + case MQ.Constant(b: Boolean) => + '{ BsonBoolean.apply(${ Expr(b: Boolean) }) } + case MQ.ScalaCode(code) => BsonUtils.extractLifted(code) + case _ => report.errorAndAbort(s"Given type is not literal constant") + } + + def extractField(expr: MongoQueryNode)(using q: Quotes): Expr[String] = + import q.reflect.* + expr match + case MQ.Field(path) => Expr(path.mkString(".")) + case _ => report.errorAndAbort("field should be string") + +} diff --git a/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoQueryNode.scala b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoQueryNode.scala new file mode 100644 index 0000000..ab78455 --- /dev/null +++ b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoQueryNode.scala @@ -0,0 +1,39 @@ +package ru.tinkoff.oolong.mongo + +import scala.quoted.Expr + +import org.mongodb.scala.bson.BsonDocument + +import ru.tinkoff.oolong.mongo.MongoQueryNode as MQ + +sealed trait MongoQueryNode + +case object MongoQueryNode { + case class Field(path: List[String]) extends MQ + + case class OnField(field: Field, expr: MQ) extends MQ + + // add super class for Gte, Lte, Eq, etc? + case class Not(x: MQ) extends MQ + + case class Gte(x: MQ) extends MQ + case class Lte(x: MQ) extends MQ + case class Gt(x: MQ) extends MQ + case class Lt(x: MQ) extends MQ + case class Eq(x: MQ) extends MQ + case class Ne(x: MQ) extends MQ + case class In(x: List[MQ] | MQ) extends MQ + case class Nin(x: List[MQ] | MQ) extends MQ + // add super class for OR and AND? + + case class And(exprs: List[MQ]) extends MQ + case class Or(exprs: List[MQ]) extends MQ + case class Constant[T](s: T) extends MQ + case class ScalaCode(code: Expr[Any]) extends MQ + case class ScalaCodeIterable(code: Expr[Iterable[Any]]) extends MQ + case class Subquery(code: Expr[BsonDocument]) extends MQ + + case class Exists(x: MQ) extends MQ + + case class Size(x: MQ) extends MQ +} diff --git a/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoUpdateCompiler.scala b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoUpdateCompiler.scala new file mode 100644 index 0000000..31addc6 --- /dev/null +++ b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoUpdateCompiler.scala @@ -0,0 +1,164 @@ +package ru.tinkoff.oolong.mongo + +import scala.quoted.Expr +import scala.quoted.Quotes + +import org.mongodb.scala.bson.BsonBoolean +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonDouble +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonInt64 +import org.mongodb.scala.bson.BsonString +import org.mongodb.scala.bson.BsonValue + +import ru.tinkoff.oolong.* +import ru.tinkoff.oolong.UExpr.FieldUpdateExpr +import ru.tinkoff.oolong.mongo.MongoUpdateNode as MU + +object MongoUpdateCompiler extends Backend[UExpr, MU, BsonDocument] { + + def opt(ast: UExpr)(using quotes: Quotes): MU = { + import quotes.reflect.* + + ast match { + case UExpr.Update(ops) => + MU.Update(ops.map { + case FieldUpdateExpr.Set(prop, expr) => MU.MongoUpdateOp.Set(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.Inc(prop, expr) => MU.MongoUpdateOp.Inc(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.Max(prop, expr) => MU.MongoUpdateOp.Max(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.Min(prop, expr) => MU.MongoUpdateOp.Min(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.Mul(prop, expr) => MU.MongoUpdateOp.Mul(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.Rename(prop, expr) => MU.MongoUpdateOp.Rename(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.SetOnInsert(prop, expr) => MU.MongoUpdateOp.SetOnInsert(MU.Prop(prop.path), opt(expr)) + case FieldUpdateExpr.Unset(prop) => MU.MongoUpdateOp.Unset(MU.Prop(prop.path)) + }) + case UExpr.ScalaCode(code) => MU.ScalaCode(code) + case UExpr.Constant(t) => MU.Constant(t) + case _ => report.errorAndAbort("Unexpected expr " + pprint(ast)) + } + } + + def render(query: MU)(using quotes: Quotes): String = query match { + case MU.Update(ops) => + List( + renderOps( + ops.collect { case s: MU.MongoUpdateOp.Set => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$set"), + renderOps( + ops.collect { case s: MU.MongoUpdateOp.Inc => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$inc"), + renderOps(ops.collect { case s: MU.MongoUpdateOp.Unset => s }.map(op => render(op.prop) + ": " + "\"\""))( + "$unset" + ), + renderOps( + ops.collect { case s: MU.MongoUpdateOp.Max => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$max"), + renderOps( + ops.collect { case s: MU.MongoUpdateOp.Min => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$min"), + renderOps( + ops.collect { case s: MU.MongoUpdateOp.Mul => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$mul"), + renderOps( + ops.collect { case s: MU.MongoUpdateOp.Rename => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$rename"), + renderOps( + ops.collect { case s: MU.MongoUpdateOp.SetOnInsert => s }.map(op => render(op.prop) + ": " + render(op.value)) + )("$setOnInsert") + ).flatten + .mkString("{\n", ",\n", "\n}") + + case MU.Prop(path) => + "\"" + path.mkString(".") + "\"" + + case MU.Constant(s: String) => + "\"" + s + "\"" + + case MU.Constant(s: Any) => + s.toString // also limit + + case MU.ScalaCode(_) => + "{...}" + } + + def renderOps(ops: List[String])(op: String) = + ops match + case Nil => None + case list => Some(s"\t $op: { " + list.mkString(", ") + " }") + + def target(optRepr: MU)(using quotes: Quotes): Expr[BsonDocument] = { + import quotes.reflect.* + + def targetOps(setters: List[MU.MongoUpdateOp]): List[Expr[(String, BsonValue)]] = + setters.map { case op: MU.MongoUpdateOp => + val key = op.prop.path.mkString(".") + val valueExpr = handleValues(op.value) + '{ ${ Expr(key) } -> $valueExpr } + } + + optRepr match { + case MU.Update(ops) => + val tSetters = targetOps(ops.collect { case s: MU.MongoUpdateOp.Set => s }) + val tUnsets = targetOps(ops.collect { case s: MU.MongoUpdateOp.Unset => s }) + val tIncs = targetOps(ops.collect { case s: MU.MongoUpdateOp.Inc => s }) + val tMaxs = targetOps(ops.collect { case s: MU.MongoUpdateOp.Max => s }) + val tMins = targetOps(ops.collect { case s: MU.MongoUpdateOp.Min => s }) + val tMuls = targetOps(ops.collect { case s: MU.MongoUpdateOp.Mul => s }) + val tRenames = targetOps(ops.collect { case s: MU.MongoUpdateOp.Rename => s }) + val tSetOnInserts = targetOps(ops.collect { case s: MU.MongoUpdateOp.SetOnInsert => s }) + + // format: off + def updaterGroup(groupName: String, updaters: List[Expr[(String, BsonValue)]]): Option[Expr[(String, BsonDocument)]] = + if (updaters.isEmpty) + None + else + Some('{ + ${ Expr(groupName) } -> BsonDocument(${ Expr.ofList(updaters)} ) + }) + + val updateList: List[Expr[(String, BsonDocument)]] = List( + updaterGroup("$set", tSetters), + updaterGroup("$unset", tUnsets), + updaterGroup("$inc", tIncs), + updaterGroup("$max", tMaxs), + updaterGroup("$min", tMins), + updaterGroup("$mul", tMuls), + updaterGroup("$rename", tRenames), + updaterGroup("$setOnInsert", tSetOnInserts), + ).flatten + + '{ + BsonDocument( + ${ Expr.ofList(updateList) } + ) + } + //format: on + case _ => report.errorAndAbort(s"Unexpected expr " + pprint(optRepr)) + } + } + + def handleValues(expr: MongoUpdateNode)(using q: Quotes): Expr[BsonValue] = + import q.reflect.* + expr match { + case MU.Constant(i: Long) => + '{ BsonInt64.apply(${ Expr(i: Long) }) } + case MU.Constant(i: Int) => + '{ BsonInt32.apply(${ Expr(i: Int) }) } + case MU.Constant(i: Short) => + '{ BsonInt32.apply(${ Expr(i: Short) }) } + case MU.Constant(i: Byte) => + '{ BsonInt32.apply(${ Expr(i: Byte) }) } + case MU.Constant(s: Double) => + '{ BsonDouble.apply(${ Expr(s: Double) }) } + case MU.Constant(s: Float) => + '{ BsonDouble.apply(${ Expr(s: Float) }) } + case MU.Constant(s: String) => + '{ BsonString.apply(${ Expr(s: String) }) } + case MU.Constant(s: Char) => + '{ BsonString.apply(${ Expr((s: Char).toString) }) } + case MU.Constant(b: Boolean) => + '{ BsonBoolean.apply(${ Expr(b: Boolean) }) } + case MU.ScalaCode(code) => BsonUtils.extractLifted(code) + case _ => report.errorAndAbort(s"Given type is not literal constant") + } +} diff --git a/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoUpdateNode.scala b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoUpdateNode.scala new file mode 100644 index 0000000..e35d930 --- /dev/null +++ b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/MongoUpdateNode.scala @@ -0,0 +1,29 @@ +package ru.tinkoff.oolong.mongo + +import scala.quoted.Expr + +import ru.tinkoff.oolong.mongo.MongoUpdateNode as MU + +sealed trait MongoUpdateNode + +case object MongoUpdateNode { + case class Prop(path: List[String]) extends MU + + case class Update(setters: List[MongoUpdateOp]) extends MU + + case class Constant[T](t: T) extends MU + + case class ScalaCode(code: Expr[Any]) extends MU + + sealed abstract class MongoUpdateOp(val prop: Prop, val value: MU) extends MU + object MongoUpdateOp { + case class Set(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + case class Inc(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + case class Unset(override val prop: Prop) extends MongoUpdateOp(prop, MU.Constant("")) + case class Max(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + case class Min(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + case class Mul(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + case class Rename(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + case class SetOnInsert(override val prop: Prop, override val value: MU) extends MongoUpdateOp(prop, value) + } +} diff --git a/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/QueryCompiler.scala b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/QueryCompiler.scala new file mode 100644 index 0000000..ac1f83e --- /dev/null +++ b/oolong-mongo/src/main/scala/ru/tinkoff/oolong/mongo/QueryCompiler.scala @@ -0,0 +1,51 @@ +package ru.tinkoff.oolong.mongo + +import scala.quoted.* + +import org.mongodb.scala.bson.BsonDocument + +import ru.tinkoff.oolong.* +import ru.tinkoff.oolong.dsl.* + +/** + * Compile a BSON description of the update. + * @param input + * Description of the update written in oolong DSL. + */ +inline def compileUpdate(inline input: UpdateDslNode[_]): BsonDocument = ${ compileUpdateImpl('input) } + +/** + * Compile a BSON query. + * @param input + * Scala code describing the query. + */ +inline def query[Doc](inline input: Doc => Boolean): BsonDocument = ${ queryImpl('input) } + +private[oolong] def compileUpdateImpl(input: Expr[UpdateDslNode[_]])(using quotes: Quotes): Expr[BsonDocument] = { + import quotes.reflect.* + import MongoUpdateCompiler.* + + val ast = DefaultAstParser.parseUExpr(input) + + val optRepr = opt(ast) + val optimized = optimize(optRepr) + + report.info("AST:\n" + pprint(ast) + "\nGenerated Mongo query:\n" + render(optimized)) + + target(optimized) +} + +private[oolong] def queryImpl[Doc: Type](input: Expr[Doc => Boolean])(using quotes: Quotes): Expr[BsonDocument] = { + import quotes.reflect.* + import MongoQueryCompiler.* + + val ast = DefaultAstParser.parseQExpr(input) + val optimizedAst = LogicalOptimizer.optimize(ast) + + val optRepr = opt(optimizedAst) + val optimized = optimize(optRepr) + + report.info("Optimized AST:\n" + pprint(optimizedAst) + "\nGenerated Mongo query:\n" + render(optimized)) + + target(optimized) +} diff --git a/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/OolongMongoSpec.scala b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/OolongMongoSpec.scala new file mode 100644 index 0000000..d0e3d41 --- /dev/null +++ b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/OolongMongoSpec.scala @@ -0,0 +1,157 @@ +package ru.tinkoff.oolong.mongo + +import java.util.concurrent.atomic.AtomicReference +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Await +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.concurrent.duration.* +import scala.util.Failure +import scala.util.Random +import scala.util.Success + +import com.dimafeng.testcontainers.ForAllTestContainer +import com.dimafeng.testcontainers.MongoDBContainer +import org.mongodb.scala.MongoClient +import org.mongodb.scala.ObservableFuture +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonString +import org.scalatest.BeforeAndAfterAll +import org.scalatest.compatible.Assertion +import org.scalatest.flatspec.AsyncFlatSpec + +import ru.tinkoff.oolong.bson.* +import ru.tinkoff.oolong.bson.given +import ru.tinkoff.oolong.dsl.* + +class OolongMongoSpec 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]("testColection") + + override def beforeAll(): Unit = { + val documents = List( + TestClass("1", 1, InnerClass("qwe"), Nil), + TestClass("2", 2, InnerClass("asd"), Nil) + ) + + 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 "find document in a collection with query with compile-time constant" in { + for { + res <- collection.find(query[TestClass](_.field1 == "1")).head() + v <- BsonDecoder[TestClass].fromBson(res) match { + case Failure(exception) => Future.failed(exception) + case Success(value) => Future.successful(value) + } + } yield assert(v == TestClass("1", 1, InnerClass("qwe"), Nil)) + } + + it should "find documents in a collection with query with runtime constant" in { + val q = query[TestClass](_.field2 <= lift(Random.between(3, 100))) + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 2) + } + + it should "find both documents with OR operator" in { + val q = query[TestClass](x => x.field2 == 1 || x.field2 == 2) + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 2) + } + + it should "compile queries with >= and <=" in { + val q = query[TestClass](x => x.field2 >= 2 && x.field2 <= 10) + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile when called inside a function" in { + + def makeQuery(id: Int) = query[TestClass](_.field2 == lift(id)) + + for { + res <- collection.find(makeQuery(1)).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with `!`" in { + val q = query[TestClass](x => !(x.field2 == 100500)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 2) + } + + it should "compile queries with `unchecked`" in { + // format: off + val q = query[TestClass](_.field1 == "100500" || unchecked( + BsonDocument(Seq( + ("field2", BsonDocument(Seq( + ("$eq", BsonInt32(1)) + ))) + )) + )) + // format: on + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with `.contains` #1" in { + val q = query[TestClass](x => lift(Set(2, 3)).contains(x.field2)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with `.contains` #2" in { + val q = query[TestClass](x => lift(Set(1, 2, 3).filter(_ >= 2)).contains(x.field2)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + it should "compile queries with `.contains` #3" in { + val q = query[TestClass](x => lift(Set(InnerClass("qwe"), InnerClass("asd"))).contains(x.field3)) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 2) + } + + it should "compile queries with nested objects" in { + val q = query[TestClass](_.field3 == lift(InnerClass("qwe"))) + + for { + res <- collection.find(q).toFuture() + } yield assert(res.size == 1) + } + + // TODO: test updates +// it should "compile updates" in { +// val upd = compileUpdate { +// update[CompanySuccess] +// .set(_.from, 2) +// .set(_.field4, liftU(Field("qweasd"))) +// } +// } + +} diff --git a/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/QuerySpec.scala b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/QuerySpec.scala new file mode 100644 index 0000000..261fc62 --- /dev/null +++ b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/QuerySpec.scala @@ -0,0 +1,306 @@ +package ru.tinkoff.oolong.mongo + +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonBoolean +import org.mongodb.scala.bson.BsonDateTime +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonDouble +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonInt64 +import org.mongodb.scala.bson.BsonString +import org.scalatest.funsuite.AnyFunSuite + +import ru.tinkoff.oolong.bson.BsonEncoder +import ru.tinkoff.oolong.bson.given +import ru.tinkoff.oolong.dsl.* + +class QuerySpec extends AnyFunSuite { + + case class TestClass( + intField: Int, + stringField: String, + dateField: LocalDate, + innerClassField: InnerClass, + optionField: Option[Long], + optionInnerClassField: Option[InnerClass], + listField: List[Double] + ) + + case class InnerClass( + fieldOne: String, + fieldTwo: Int + ) derives BsonEncoder + + test("$eq") { + + val q = query[TestClass](_.intField == 2) + + assert(q == BsonDocument("intField" -> BsonDocument("$eq" -> BsonInt32(2)))) + } + + test("$gt") { + + val q = query[TestClass](_.intField > 2) + + assert(q == BsonDocument("intField" -> BsonDocument("$gt" -> BsonInt32(2)))) + } + + test("$gte") { + + val q = query[TestClass](_.intField >= 2) + + assert(q == BsonDocument("intField" -> BsonDocument("$gte" -> BsonInt32(2)))) + } + + test("$in") { + + val q = query[TestClass](f => List(1, 2, 3).contains(f.intField)) + + val q1 = query[TestClass](_.listField.contains(1.1)) + + val q2 = query[TestClass](!_.listField.contains(1.1)) + + assert( + q == BsonDocument( + "intField" -> BsonDocument("$in" -> BsonArray.fromIterable(List(BsonInt32(1), BsonInt32(2), BsonInt32(3)))) + ) + ) + } + + test("$nin") { + + val q = query[TestClass](f => !List(4, 5, 6).contains(f.intField)) + + assert( + q == BsonDocument( + "intField" -> BsonDocument("$nin" -> BsonArray.fromIterable(List(BsonInt32(4), BsonInt32(5), BsonInt32(6)))) + ) + ) + } + + test("$lt") { + + val q = query[TestClass](_.intField < 2) + + assert(q == BsonDocument("intField" -> BsonDocument("$lt" -> BsonInt32(2)))) + } + + test("$lte") { + + val q = query[TestClass](_.intField <= 2) + + assert(q == BsonDocument("intField" -> BsonDocument("$lte" -> BsonInt32(2)))) + } + + test("$ne") { + + val q = query[TestClass](_.stringField != "some") + + assert(q == BsonDocument("stringField" -> BsonDocument("$ne" -> BsonString("some")))) + } + + test("test with lift(...) for custom types") { + val q = query[TestClass](_.dateField == lift(LocalDate.of(2020, 12, 12))) + + assert( + q == BsonDocument( + "dateField" -> BsonDocument( + "$eq" -> BsonDateTime( + java.util.Date.from( + LocalDate + .of(2020, 12, 12) + .atStartOfDay() + .atZone(ZoneOffset.UTC) + .toInstant + ) + ) + ) + ) + ) + } + + test("test with lift(...) for case classes ") { + val q = query[TestClass](_.innerClassField == lift(InnerClass("one", 2))) + + assert( + q == BsonDocument( + "innerClassField" -> BsonDocument( + "$eq" -> BsonDocument("fieldOne" -> BsonString("one"), "fieldTwo" -> BsonInt32(2)) + ) + ) + ) + } + + test("test with lift(...) for arrays") { + + val q = query[TestClass](f => lift(List(1, 2, 3).filter(_ != 2)).contains(f.intField)) + + assert( + q == BsonDocument( + "intField" -> BsonDocument("$in" -> BsonArray.fromIterable(List(BsonInt32(1), BsonInt32(3)))) + ) + ) + } + + test("test with lift(...) for sets") { + + val q = query[TestClass](f => lift(List(1, 2, 3).filter(_ != 2)).contains(f.intField)) + + assert( + q == BsonDocument( + "intField" -> BsonDocument("$in" -> BsonArray.fromIterable(List(BsonInt32(1), BsonInt32(3)))) + ) + ) + } + + test("$and") { + val q = query[TestClass](f => f.intField == 3 && f.stringField != "some") + + assert( + q == BsonDocument( + "$and" -> BsonArray.fromIterable( + List( + BsonDocument("intField" -> BsonDocument("$eq" -> BsonInt32(3))), + BsonDocument("stringField" -> BsonDocument("$ne" -> BsonString("some"))) + ) + ) + ) + ) + } + + test("$or") { + val q = query[TestClass](f => f.intField == 3 || f.stringField != "some") + + assert( + q == BsonDocument( + "$or" -> BsonArray.fromIterable( + List( + BsonDocument("intField" -> BsonDocument("$eq" -> BsonInt32(3))), + BsonDocument("stringField" -> BsonDocument("$ne" -> BsonString("some"))) + ) + ) + ) + ) + } + + test("$not") { + val q = query[TestClass](f => !(f.intField == 3)) + + assert( + q == BsonDocument( + "intField" -> BsonDocument("$not" -> BsonDocument("$eq" -> BsonInt32(3))) + ) + ) + } + + test("$exists true") { + val q = query[TestClass](f => f.optionField.isDefined) + assert(q == BsonDocument("optionField" -> BsonDocument("$exists" -> BsonBoolean(true)))) + } + + test("$exists false") { + val q = query[TestClass](f => f.optionField.isEmpty) + assert(q == BsonDocument("optionField" -> BsonDocument("$exists" -> BsonBoolean(false)))) + } + + test("raw Bson in a query") { + val q = query[TestClass]( + _.intField == 2 && unchecked( + BsonDocument( + "innerClassField" -> BsonDocument( + "$eq" -> BsonDocument("fieldOne" -> BsonString("one"), "fieldTwo" -> BsonInt32(2)) + ) + ) + ) + ) + + assert( + q == BsonDocument( + "$and" -> BsonArray.fromIterable( + List( + BsonDocument("intField" -> BsonDocument("$eq" -> BsonInt32(2))), + BsonDocument( + "innerClassField" -> BsonDocument( + "$eq" -> BsonDocument("fieldOne" -> BsonString("one"), "fieldTwo" -> BsonInt32(2)) + ) + ) + ) + ) + ) + ) + + } + + test("query with !! operator for Option[_] fields") { + val q = query[TestClass](_.optionInnerClassField.!!.fieldTwo == 2) + + assert(q == BsonDocument("optionInnerClassField.fieldTwo" -> BsonDocument("$eq" -> BsonInt32(2)))) + } + + test("$size with .empty") { + val q = query[TestClass](_.listField.isEmpty) + + assert(q == BsonDocument("listField" -> BsonDocument("$size" -> BsonInt32(0)))) + } + + test("$size with .size == ?") { + val q = query[TestClass](_.listField.size == 2) + + assert(q == BsonDocument("listField" -> BsonDocument("$size" -> BsonInt32(2)))) + } + + test("$size with .length == ?") { + val q = query[TestClass](_.listField.length == 2) + + assert(q == BsonDocument("listField" -> BsonDocument("$size" -> BsonInt32(2)))) + } + + test("$eq for element in collection") { + + val q = query[TestClass](_.listField.contains(1.1)) + + assert( + q == BsonDocument( + "listField" -> BsonDocument("$eq" -> BsonDouble(1.1)) + ) + ) + } + + test("$ne for element in collection") { + + val q = query[TestClass](!_.listField.contains(1.1)) + + assert( + q == BsonDocument( + "listField" -> BsonDocument("$ne" -> BsonDouble(1.1)) + ) + ) + } + + test("$eq for element in Option[_] field") { + + val q = query[TestClass](_.optionField.contains(2L)) + + assert( + q == BsonDocument( + "optionField" -> BsonDocument("$eq" -> BsonInt64(2L)) + ) + ) + } + + test("$ne for element in Option[_] field") { + + val q = query[TestClass](!_.optionField.contains(2L)) + + assert( + q == BsonDocument( + "optionField" -> BsonDocument("$ne" -> BsonInt64(2L)) + ) + ) + } + +} diff --git a/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/TestClass.scala b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/TestClass.scala new file mode 100644 index 0000000..d335600 --- /dev/null +++ b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/TestClass.scala @@ -0,0 +1,17 @@ +package ru.tinkoff.oolong.mongo + +import ru.tinkoff.oolong.bson.* +import ru.tinkoff.oolong.bson.given + +case class TestClass( + field1: String, + field2: Int, + field3: InnerClass, + field4: List[Int], +) derives BsonEncoder, + BsonDecoder + +case class InnerClass( + innerField: String +) derives BsonEncoder, + BsonDecoder diff --git a/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/UpdateSpec.scala b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/UpdateSpec.scala new file mode 100644 index 0000000..fcaaa91 --- /dev/null +++ b/oolong-mongo/src/test/scala/ru/tinkoff/oolong/mongo/UpdateSpec.scala @@ -0,0 +1,132 @@ +package ru.tinkoff.oolong.mongo + +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonBoolean +import org.mongodb.scala.bson.BsonDateTime +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonInt64 +import org.mongodb.scala.bson.BsonString +import org.scalatest.funsuite.AnyFunSuite + +import ru.tinkoff.oolong.bson.BsonEncoder +import ru.tinkoff.oolong.bson.given +import ru.tinkoff.oolong.dsl.* + +class UpdateSpec extends AnyFunSuite { + + case class TestClass( + intField: Int, + stringField: String, + dateField: LocalDate, + innerClassField: InnerClass, + optionField: Option[Long], + optionInnerClassField: Option[InnerClass] + ) + + case class InnerClass( + fieldOne: String, + fieldTwo: Int + ) derives BsonEncoder + + test("$set for regular fields") { + val q = compileUpdate { + update[TestClass] + .set(_.intField, 2) + } + + assert(q == BsonDocument("$set" -> BsonDocument("intField" -> BsonInt32(2)))) + } + + test("$set for Option[_] fields") { + val q = compileUpdate { + update[TestClass] + .setOpt(_.optionField, 2L) + } + + assert(q == BsonDocument("$set" -> BsonDocument("optionField" -> BsonInt64(2)))) + } + + test("$inc") { + val q = compileUpdate { + update[TestClass] + .inc(_.intField, 1) + } + + assert(q == BsonDocument("$inc" -> BsonDocument("intField" -> BsonInt32(1)))) + } + + test("$mul") { + val q = compileUpdate { + update[TestClass] + .mul(_.intField, 10) + } + + assert(q == BsonDocument("$mul" -> BsonDocument("intField" -> BsonInt32(10)))) + } + + test("$max") { + val q = compileUpdate { + update[TestClass] + .max(_.intField, 10) + } + + assert(q == BsonDocument("$max" -> BsonDocument("intField" -> BsonInt32(10)))) + } + + test("$min") { + val q = compileUpdate { + update[TestClass] + .min(_.intField, 10) + } + + assert(q == BsonDocument("$min" -> BsonDocument("intField" -> BsonInt32(10)))) + } + + test("$rename") { + val q = compileUpdate { + update[TestClass] + .rename(_.intField, "newFieldName") + } + + assert(q == BsonDocument("$rename" -> BsonDocument("intField" -> BsonString("newFieldName")))) + } + + test("$unset") { + val q = compileUpdate { + update[TestClass] + .unset(_.intField) + } + + assert(q == BsonDocument("$unset" -> BsonDocument("intField" -> BsonString("")))) + } + + test("$setOnInsert") { + val q = compileUpdate { + update[TestClass] + .setOnInsert(_.intField, 14) + } + + assert(q == BsonDocument("$setOnInsert" -> BsonDocument("intField" -> BsonInt32(14)))) + } + + test("several update operators combined") { + val q = compileUpdate { + update[TestClass] + .unset(_.dateField) + .setOpt(_.optionField, 2L) + .set(_.intField, 19) + } + + assert( + q == BsonDocument( + ("$set" -> BsonDocument("optionField" -> BsonInt64(2L), "intField" -> BsonInt32(19))), + ("$unset" -> BsonDocument("dateField" -> BsonString(""))) + ) + ) + } +} diff --git a/project/Settings.scala b/project/Settings.scala new file mode 100644 index 0000000..954cb49 --- /dev/null +++ b/project/Settings.scala @@ -0,0 +1,31 @@ +import scalafix.sbt.ScalafixPlugin.autoImport.scalafixResolvers + +import coursierapi.{MavenRepository => CoursierMvnRepo} +import sbt.Keys._ +import sbt._ + +object Settings { + val common = Seq( + organization := "ru.tinkoff", + version := "0.1", + scalaVersion := "3.1.3", + Compile / packageDoc / publishArtifact := false, + Compile / packageSrc / publishArtifact := false, + Compile / doc / sources := Seq.empty, + scalacOptions ++= Seq( + // For reference: https://docs.scala-lang.org/scala3/guides/migration/options-lookup.html + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-feature", + "-language:higherKinds", + "-language:implicitConversions", + "-Xtarget:11", + "-unchecked", + "-Ykind-projector", + "-Xcheck-macros" + ), + semanticdbEnabled := true + ) +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..b46cfa1 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 1.6.2 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..5272c9d --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,4 @@ +addDependencyTreePlugin +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.34") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") +addSbtPlugin("ch.epfl.scala" % "sbt-missinglink" % "0.3.2")