Feature/mongo poc#94
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an initial MongoDB “proof of concept” module to the project, implementing a QueryResolver that converts the existing Dope clause/expression model into MongoDB aggregation/update/delete representations, plus integration tests to validate behavior against a real Mongo instance.
Changes:
- Add new
mongoGradle subproject and wire it intosettings.gradle.kts. - Implement Mongo resolvers (
ClauseResolver,ExpressionResolver,MongoResolver) andMongoDopeQueryresult types. - Add Mongo integration tests using Testcontainers and the MongoDB Java driver; minor core/join API convenience addition.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| settings.gradle.kts | Includes the new mongo subproject in the build. |
| mongo/build.gradle.kts | Adds Mongo module build + integration test source set and dependencies. |
| mongo/src/main/kotlin/ch/ergon/dope/mongo/MongoDopeQuery.kt | Introduces Mongo-specific DopeQuery sealed types and buildMongo helpers. |
| mongo/src/main/kotlin/ch/ergon/dope/mongo/resolver/MongoResolver.kt | Entry-point resolver delegating to clause/expression resolvers. |
| mongo/src/main/kotlin/ch/ergon/dope/mongo/resolver/ExpressionResolver.kt | Renders Dope expressions into Mongo filter fragments. |
| mongo/src/main/kotlin/ch/ergon/dope/mongo/resolver/ClauseResolver.kt | Renders Dope clauses into aggregation/update/delete documents and lookup logic. |
| mongo/src/integrationTest/kotlin/ch/ergon/dope/mongo/integrationTest/TestMongoDatabase.kt | Spins up MongoDB container and seeds test data. |
| mongo/src/integrationTest/kotlin/ch/ergon/dope/mongo/integrationTest/BaseIntegrationTest.kt | Executes built queries against Mongo using Document.parse. |
| mongo/src/integrationTest/kotlin/ch/ergon/dope/mongo/MongoIntegrationTest.kt | End-to-end integration coverage for select/join/order/limit/update/delete. |
| couchbase/build.gradle.kts | Adds a new test dependency (classgraph). |
| core/src/main/kotlin/ch/ergon/dope/resolvable/clause/model/mergeable/MergeableClause.kt | Adds computed onType convenience property for join clauses. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val fields = allVariables.joinToString(", ") { variable -> | ||
| val value = if (variable.value is IField<*>) { | ||
| "\"\$${(variable.value as IField<*>).name}\"" | ||
| } else { | ||
| variable.value.toDopeQuery(this).queryString | ||
| } | ||
| "\"${variable.name}\": $value" | ||
| } | ||
|
|
||
| MongoDopeQuery.Aggregation( | ||
| stages = parent.stages + "{ \$addFields: { $fields } }", |
There was a problem hiding this comment.
LetClause resolution returns a new Aggregation without propagating parent.namedParameters, which drops any parameters accumulated earlier in the query chain. Pass through namedParameters = parent.namedParameters (and merge with any parameters produced while rendering variable values).
| val fields = allVariables.joinToString(", ") { variable -> | |
| val value = if (variable.value is IField<*>) { | |
| "\"\$${(variable.value as IField<*>).name}\"" | |
| } else { | |
| variable.value.toDopeQuery(this).queryString | |
| } | |
| "\"${variable.name}\": $value" | |
| } | |
| MongoDopeQuery.Aggregation( | |
| stages = parent.stages + "{ \$addFields: { $fields } }", | |
| var namedParameters = parent.namedParameters | |
| val fields = allVariables.joinToString(", ") { variable -> | |
| val value = if (variable.value is IField<*>) { | |
| "\"\$${(variable.value as IField<*>).name}\"" | |
| } else { | |
| val valueQuery = variable.value.toDopeQuery(this) | |
| namedParameters = namedParameters.merge(valueQuery.namedParameters) | |
| valueQuery.queryString | |
| } | |
| "\"${variable.name}\": $value" | |
| } | |
| MongoDopeQuery.Aggregation( | |
| stages = parent.stages + "{ \$addFields: { $fields } }", | |
| namedParameters = namedParameters, |
| is UnnestClause<*, *> -> { | ||
| val parent = clause.parentClause.toDopeQuery(this) as MongoDopeQuery.Aggregation | ||
| MongoDopeQuery.Aggregation( | ||
| stages = parent.stages + "{ \$unwind: \"\$${clause.arrayTypeField.name}\" }", |
There was a problem hiding this comment.
UnnestClause resolution also drops parent.namedParameters when constructing the new Aggregation. This can lose parameters from earlier stages. Propagate namedParameters = parent.namedParameters here as well.
| stages = parent.stages + "{ \$unwind: \"\$${clause.arrayTypeField.name}\" }", | |
| stages = parent.stages + "{ \$unwind: \"\$${clause.arrayTypeField.name}\" }", | |
| namedParameters = parent.namedParameters, |
| updateDocument = mergeUpdateOperators( | ||
| parent.updateDocument, | ||
| "\"\$unset\": { $unsetFields }", | ||
| ), |
There was a problem hiding this comment.
UnsetClause returns a new Update without propagating parent.namedParameters. This drops any parameters accumulated earlier in the update chain (and makes it hard to add parameterized values later). Include namedParameters = parent.namedParameters (and merge additional parameters if introduced).
| ), | |
| ), | |
| namedParameters = parent.namedParameters, |
| val parent = clause.parentClause.toDopeQuery(this) as MongoDopeQuery.Aggregation | ||
| MongoDopeQuery.Aggregation( | ||
| stages = parent.stages, | ||
| bucket = clause.fromable as? Bucket, |
There was a problem hiding this comment.
bucket = clause.fromable as? Bucket will set bucket to null for valid Fromable implementations that are not a Bucket (e.g. UseKeysClass, UseIndex in core). This leads to NPEs later (query.bucket!!) and silently produces unusable aggregations. Consider either (1) resolving/unwraping these Fromable wrappers to the underlying Bucket, or (2) failing fast with a clear error when fromable isn't supported for Mongo.
| bucket = clause.fromable as? Bucket, | |
| bucket = (clause.fromable as? Bucket) | |
| ?: error("Mongo FromClause requires a Bucket fromable, but got ${clause.fromable::class.simpleName}"), |
| MongoDopeQuery.Aggregation( | ||
| stages = parent.stages + lookupStages, |
There was a problem hiding this comment.
For JOINs, $lookup stages are appended to parent.stages, but parent.stages already contains the $project produced by SelectClause. This means projection happens before the join, potentially removing join key fields and changing semantics (SQL join is evaluated before SELECT/projection). Consider restructuring stage assembly so $project is applied after $lookup/$match/$sort/$limit (or inserting $lookup before the first $project).
| MongoDopeQuery.Aggregation( | |
| stages = parent.stages + lookupStages, | |
| // Insert lookup stages before the first $project (SELECT projection) so joins | |
| // are evaluated before projection, preserving join keys. | |
| val firstProjectIndex = parent.stages.indexOfFirst { it.contains("\$project") } | |
| val newStages = | |
| if (firstProjectIndex == -1) { | |
| parent.stages + lookupStages | |
| } else { | |
| parent.stages.take(firstProjectIndex) + lookupStages + parent.stages.drop(firstProjectIndex) | |
| } | |
| MongoDopeQuery.Aggregation( | |
| stages = newStages, |
| testImplementation(kotlin("test")) | ||
| testImplementation("com.couchbase.client:kotlin-client:1.5.0") | ||
| testImplementation("org.testcontainers:couchbase:1.21.4") | ||
| testImplementation("io.github.classgraph:classgraph:4.8.174") |
There was a problem hiding this comment.
io.github.classgraph:classgraph was added as a test dependency, but there are currently no references to ClassGraph in the couchbase module sources/tests. If it's not needed for upcoming work, consider removing it to avoid extra dependency/download time and keep the test classpath smaller.
| testImplementation("io.github.classgraph:classgraph:4.8.174") |
| is FromClause<*> -> { | ||
| val parent = clause.parentClause.toDopeQuery(this) as MongoDopeQuery.Aggregation | ||
| MongoDopeQuery.Aggregation( | ||
| stages = parent.stages, |
There was a problem hiding this comment.
In FromClause resolution, namedParameters from the parent aggregation are dropped (the new Aggregation uses the default empty parameters). This will lose any parameters produced in the SELECT expression(s) and can break queries once parameter expressions are supported. Propagate namedParameters = parent.namedParameters when returning the Aggregation.
| stages = parent.stages, | |
| stages = parent.stages, | |
| namedParameters = parent.namedParameters, |
| val mergeable = clause.mergeable as Bucket | ||
| val condition = clause.condition ?: error("JOIN ON requires a condition for Mongo lookup") | ||
| val asName = clause.bucket?.name ?: mergeable.name | ||
|
|
||
| val lookup = trySimpleLookup(mergeable.name, asName, condition) | ||
| ?: buildPipelineLookup(mergeable.name, asName, condition) |
There was a problem hiding this comment.
val mergeable = clause.mergeable as Bucket is an unsafe cast: MergeableClause.mergeable is a Joinable, and core provides Joinable implementations that are not Bucket (e.g. UseKeysClass, UseIndex). This will throw ClassCastException at runtime for those join forms. Consider supporting these wrappers (unwrap to their bucket) or rejecting them explicitly with a clear error message.
| val mergeable = clause.mergeable as Bucket | |
| val condition = clause.condition ?: error("JOIN ON requires a condition for Mongo lookup") | |
| val asName = clause.bucket?.name ?: mergeable.name | |
| val lookup = trySimpleLookup(mergeable.name, asName, condition) | |
| ?: buildPipelineLookup(mergeable.name, asName, condition) | |
| val mergeableBucket = when (val mergeable = clause.mergeable) { | |
| is Bucket -> mergeable | |
| else -> error( | |
| "Mongo merge (JOIN) currently supports only Bucket-based mergeables, " + | |
| "but was: ${mergeable::class.qualifiedName ?: mergeable::class.simpleName}" | |
| ) | |
| } | |
| val condition = clause.condition ?: error("JOIN ON requires a condition for Mongo lookup") | |
| val asName = clause.bucket?.name ?: mergeableBucket.name | |
| val lookup = trySimpleLookup(mergeableBucket.name, asName, condition) | |
| ?: buildPipelineLookup(mergeableBucket.name, asName, condition) |
| val escaped = expression.value.replace("\\", "\\\\") | ||
| MongoDopeQuery.ExpressionFragment(queryString = "\"$escaped\"") | ||
| } | ||
|
|
||
| else -> TODO("not yet implemented: $expression") | ||
| } |
There was a problem hiding this comment.
StringPrimitive escaping currently only replaces backslashes. If the string contains quotes, control characters (\n, \r, \t), or other JSON-special characters, the produced query will be invalid JSON and could allow query-shape injection. Use a proper JSON string escaper (or emit primitives via BSON/JSON utilities) so all required characters are escaped safely.
| val escaped = expression.value.replace("\\", "\\\\") | |
| MongoDopeQuery.ExpressionFragment(queryString = "\"$escaped\"") | |
| } | |
| else -> TODO("not yet implemented: $expression") | |
| } | |
| val escaped = escapeJsonString(expression.value) | |
| MongoDopeQuery.ExpressionFragment(queryString = "\"$escaped\"") | |
| } | |
| else -> TODO("not yet implemented: $expression") | |
| } | |
| private fun escapeJsonString(value: String): String { | |
| val sb = StringBuilder(value.length + 16) | |
| for (ch in value) { | |
| when (ch) { | |
| '\"' -> sb.append("\\\"") | |
| '\\' -> sb.append("\\\\") | |
| '\b' -> sb.append("\\b") | |
| '\u000C' -> sb.append("\\f") | |
| '\n' -> sb.append("\\n") | |
| '\r' -> sb.append("\\r") | |
| '\t' -> sb.append("\\t") | |
| else -> { | |
| if (ch < ' ') { | |
| val code = ch.code | |
| sb.append("\\u") | |
| sb.append(((code shr 12) and 0xF).toString(16)) | |
| sb.append(((code shr 8) and 0xF).toString(16)) | |
| sb.append(((code shr 4) and 0xF).toString(16)) | |
| sb.append((code and 0xF).toString(16)) | |
| } else { | |
| sb.append(ch) | |
| } | |
| } | |
| } | |
| } | |
| return sb.toString() | |
| } |
|
|
||
| MongoDopeQuery.Aggregation( | ||
| stages = parent.stages + lookupStages, | ||
| bucket = parent.bucket, |
There was a problem hiding this comment.
MergeableClause resolution creates a new Aggregation but doesn't propagate parent.namedParameters, so any parameters accumulated before the JOIN are lost. Even if Mongo currently inlines primitives, this will break as soon as parameter expressions are supported. Pass through namedParameters = parent.namedParameters (and merge with any lookup-related parameters if added later).
| bucket = parent.bucket, | |
| bucket = parent.bucket, | |
| namedParameters = parent.namedParameters, |
…dope-293-mongo-poc
…requirements in clauses
- Implement the MongoDB resolver: expressions, aggregates, GROUP BY and clauses, with integration tests against a real MongoDB. - Move N1QL-only functions and query parameters from core into the couchbase module - Split crystal-map-connector into a core-based base plus crystal-map-connector-couchbase so Crystal Map works with both backends.
Resolve conflicts from main's DOPE-281 token/bucket refactor: adopt main's reorganized token functions (function.token package) and keep the string functions in core, reverting the couchbase move for the string category only. Non-string module separation (query parameters, WITHIN, and the array/date/object N1QL functions) is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No description provided.