diff --git a/dsl/src/commonMain/kotlin/query/FilterQuery.kt b/dsl/src/commonMain/kotlin/query/FilterQuery.kt index c5936ce6..4a017fee 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQuery.kt @@ -25,6 +25,7 @@ import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.path.* import opensavvy.ktmongo.dsl.tree.CompoundBsonNode +import org.intellij.lang.annotations.Language import kotlin.reflect.KProperty1 /** @@ -119,6 +120,9 @@ import kotlin.reflect.KProperty1 * - [`$all`][containsAll] * - [`$elemMatch`][any] * + * Text query: + * - [`$regex`][regex] + * * If you can't find an operator you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/4). */ @KtMongoDsl @@ -2457,5 +2461,124 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { @KtMongoDsl fun expr(block: AggregationOperators.() -> Value) + // endregion + // region $regex + + /** + * Matches documents where the field corresponds to a given regex expression. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * ) + * + * collection.find { + * User::name.regex("John .*") + * } + * ``` + * + * ### Indexing + * + * If possible, prefer using a `"^"` prefix. For example, if we know that a pattern will only be present + * at the start of a string, `"^foo"` will use indexes, whereas `"foo"` will not. + * + * Avoid using `.*` at the start and end of a pattern. `"foo"` is identical to `"foo.*"`, but the former + * can use indexes and the latter cannot. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/regex) + * - [Syntax sheet](https://www.pcre.org/current/doc/html/pcre2syntax.html) + * + * @param caseInsensitive If `true`, the result is matched even if its case doesn't match. + * Note that this also disables index usage (even case-insensitive indexes) and ignores collation. + * @param dotAll If `true`, the dot character (`.`) can match newlines. + * @param extended If `true`, whitespace (except in character classes) is ignored, + * and segments starting from an unescaped pound (`#`) until a newline are ignored, similarly to a Python comment. + * ```kotlin + * User::name.regex( + * pattern = """ + * abc # This is a comment, it's not part of the pattern + * 123 + * """.trimIndent(), + * extended = true, + * ) + * ``` + * which is identical to the non-extended pattern `"abc123"`. + * @param matchEachLine If `true`, the special characters `^` and `$` match the beginning and end + * of each line, instead of matching the beginning and end of the entire string. + * Therefore, `"^S"` will match `"First line\nSecond line"`, which would not match otherwise. + */ + @KtMongoDsl + fun Field.regex( + @Language("JSRegexp") pattern: String, + caseInsensitive: Boolean = false, + dotAll: Boolean = false, + extended: Boolean = false, + matchEachLine: Boolean = false, + ) { + this { regex(pattern, caseInsensitive, dotAll, extended, matchEachLine) } + } + + /** + * Matches documents where the field corresponds to a given regex expression. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * ) + * + * collection.find { + * User::name.regex("John .*") + * } + * ``` + * + * ### Indexing + * + * If possible, prefer using a `"^"` prefix. For example, if we know that a pattern will only be present + * at the start of a string, `"^foo"` will use indexes, whereas `"foo"` will not. + * + * Avoid using `.*` at the start and end of a pattern. `"foo"` is identical to `"foo.*"`, but the former + * can use indexes and the latter cannot. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/regex) + * - [Syntax sheet](https://www.pcre.org/current/doc/html/pcre2syntax.html) + * + * @param caseInsensitive If `true`, the result is matched even if its case doesn't match. + * Note that this also disables index usage (even case-insensitive indexes) and ignores collation. + * @param dotAll If `true`, the dot character (`.`) can match newlines. + * @param extended If `true`, whitespace (except in character classes) is ignored, + * and segments starting from an unescaped pound (`#`) until a newline are ignored, similarly to a Python comment. + * ```kotlin + * User::name.regex( + * pattern = """ + * abc # This is a comment, it's not part of the pattern + * 123 + * """.trimIndent(), + * extended = true, + * ) + * ``` + * which is identical to the non-extended pattern `"abc123"`. + * @param matchEachLine If `true`, the special characters `^` and `$` match the beginning and end + * of each line, instead of matching the beginning and end of the entire string. + * Therefore, `"^S"` will match `"First line\nSecond line"`, which would not match otherwise. + */ + @KtMongoDsl + fun KProperty1.regex( + @Language("JSRegexp") pattern: String, + caseInsensitive: Boolean = false, + dotAll: Boolean = false, + extended: Boolean = false, + matchEachLine: Boolean = false, + ) { + this { regex(pattern, caseInsensitive, dotAll, extended, matchEachLine) } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt index bc3bc002..7322088a 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -21,6 +21,7 @@ import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.path.FieldDsl import opensavvy.ktmongo.dsl.tree.CompoundBsonNode +import org.intellij.lang.annotations.Language /** * DSL for MongoDB operators that are used as predicates in conditions in a context where the targeted field is already @@ -759,5 +760,67 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { } // endregion + // region $regex + + /** + * Matches documents where the field corresponds to a given regex expression. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * ) + * + * collection.find { + * User::name { + * regex("John .*") + * } + * } + * ``` + * + * ### Indexing + * + * If possible, prefer using a `"^"` prefix. For example, if we know that a pattern will only be present + * at the start of a string, `"^foo"` will use indexes, whereas `"foo"` will not. + * + * Avoid using `.*` at the start and end of a pattern. `"foo"` is identical to `"foo.*"`, but the former + * can use indexes and the latter cannot. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/regex) + * - [Syntax sheet](https://www.pcre.org/current/doc/html/pcre2syntax.html) + * + * @param caseInsensitive If `true`, the result is matched even if its case doesn't match. + * Note that this also disables index usage (even case-insensitive indexes) and ignores collation. + * @param dotAll If `true`, the dot character (`.`) can match newlines. + * @param extended If `true`, whitespace (except in character classes) is ignored, + * and segments starting from an unescaped pound (`#`) until a newline are ignored, similarly to a Python comment. + * ```kotlin + * User::name.regex( + * pattern = """ + * abc # This is a comment, it's not part of the pattern + * 123 + * """.trimIndent(), + * extended = true, + * ) + * ``` + * which is identical to the non-extended pattern `"abc123"`. + * @param matchEachLine If `true`, the special characters `^` and `$` match the beginning and end + * of each line, instead of matching the beginning and end of the entire string. + * Therefore, `"^S"` will match `"First line\nSecond line"`, which would not match otherwise. + * @see FilterQuery.regex Shorthand syntax + */ + @KtMongoDsl + fun regex( + @Language("JSRegexp") pattern: String, + caseInsensitive: Boolean = false, + dotAll: Boolean = false, + extended: Boolean = false, + matchEachLine: Boolean = false, + ) + + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index 079cec4c..ea73ef13 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -279,6 +279,54 @@ private class FilterQueryPredicateImpl( } } + // endregion + // region $regex + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @KtMongoDsl + override fun regex( + pattern: String, + caseInsensitive: Boolean, + dotAll: Boolean, + extended: Boolean, + matchEachLine: Boolean, + ) { + accept(RegexBsonNode(pattern, caseInsensitive, dotAll, extended, matchEachLine, context)) + } + + @LowLevelApi + private class RegexBsonNode( + val pattern: String, + val caseInsensitive: Boolean, + val dotAll: Boolean, + val extended: Boolean, + val matchEachLine: Boolean, + context: BsonContext, + ) : PredicateBsonNodeNode(context) { + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeRegularExpression( + "\$regex", + pattern, + buildString { + // ⚠ Must be in alphabetical order + + if (caseInsensitive) + append('i') + + if (matchEachLine) + append('m') + + if (dotAll) + append('s') + + if (extended) + append('x') + } + ) + } + } + // endregion } diff --git a/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt b/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt index d44876eb..66bbbb12 100644 --- a/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt +++ b/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt @@ -40,6 +40,8 @@ val oid = "\$oid" val elemMatch = "\$elemMatch" val expr = "\$expr" val getField = "\$getField" +val regex = "\$regex" +val regularExpression = "\$regularExpression" class Pet( val name: String, diff --git a/dsl/src/commonTest/kotlin/query/filter/RegexTest.kt b/dsl/src/commonTest/kotlin/query/filter/RegexTest.kt new file mode 100644 index 00000000..288f76ec --- /dev/null +++ b/dsl/src/commonTest/kotlin/query/filter/RegexTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025, OpenSavvy and contributors. + * + * 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. + */ + +package opensavvy.ktmongo.dsl.query.filter + +import opensavvy.ktmongo.dsl.query.shouldBeBson +import opensavvy.prepared.runner.kotest.PreparedSpec + +class RegexTest : PreparedSpec({ + suite("Regex") { + test("Basic usage") { + filter { + User::name.regex("foo.*") + } shouldBeBson """ + { + "name": { + "$regex": { + "$regularExpression": { + "pattern": "foo.*", + "options": "" + } + } + } + } + """.trimIndent() + } + + test("Case insensitive") { + filter { + User::name.regex("^acme", caseInsensitive = true) + } shouldBeBson """ + { + "name": { + "$regex": { + "$regularExpression": { + "pattern": "^acme", + "options": "i" + } + } + } + } + """.trimIndent() + } + + test("Case insensitive grouping") { + filter { + User::name.regex("(?i)a(?-i)cme") + } shouldBeBson """ + { + "name": { + "$regex": { + "$regularExpression": { + "pattern": "(?i)a(?-i)cme", + "options": "" + } + } + } + } + """.trimIndent() + } + + test("Similar to SQL LIKE") { + filter { + User::name.regex("789$") + } shouldBeBson """ + { + "name": { + "$regex": { + "$regularExpression": { + "pattern": "789$", + "options": "" + } + } + } + } + """.trimIndent() + } + + test("Multiline match") { + filter { + User::name.regex("^S", matchEachLine = true) + } shouldBeBson """ + { + "name": { + "$regex": { + "$regularExpression": { + "pattern": "^S", + "options": "m" + } + } + } + } + """.trimIndent() + } + + test("Pattern with comments") { + filter { + User::name.regex( + pattern = """ + abc # category code + 123 # item number + """.trimIndent(), + extended = true, + ) + } shouldBeBson """ + { + "name": { + "$regex": { + "$regularExpression": { + "pattern": "abc # category code\n123 # item number", + "options": "x" + } + } + } + } + """.trimIndent() + } + } +}) -- 2.51.2 From 51c6397a3dec088745eb512eb94406fd3d426e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 15 Apr 2025 23:50:35 +0200 Subject: [PATCH 2/2] test(dsl): Improve IntelliJ highlighting for extended JSON expected values --- dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt | 3 ++- dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt index 50498c46..49a4093a 100644 --- a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt @@ -22,6 +22,7 @@ import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.query.shouldBeBson import opensavvy.ktmongo.dsl.query.testContext import opensavvy.ktmongo.dsl.tree.BsonNode +import org.intellij.lang.annotations.Language val count = "\$count" val limit = "\$limit" @@ -71,6 +72,6 @@ class TestPipeline( } -infix fun Pipeline<*>.shouldBeBson(expected: String) { +infix fun Pipeline<*>.shouldBeBson(@Language("MongoDB-JSON") expected: String) { this.toString() shouldBeBson expected } diff --git a/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt b/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt index f263b9cc..462e2027 100644 --- a/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt +++ b/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt @@ -18,10 +18,11 @@ package opensavvy.ktmongo.dsl.query import io.kotest.matchers.shouldBe import opensavvy.ktmongo.bson.BsonContext +import org.intellij.lang.annotations.Language expect fun testContext(): BsonContext -infix fun String.shouldBeBson(expected: String) { +infix fun String.shouldBeBson(@Language("MongoDB-JSON") expected: String) { this shouldBe expected .replace("\n", "") .replace("\t", "")