diff --git a/example.php b/example.php index 7264171e03..75fb607f18 100644 --- a/example.php +++ b/example.php @@ -82,13 +82,11 @@ function configureSDK(SDK $sdk, array $overrides = []): SDK { ], ]; - // Deep-merge exclude services so overrides add to defaults rather than replacing - if (isset($overrides['exclude']['services']) && isset($defaults['exclude']['services'])) { - $overrides['exclude']['services'] = [ - ...$defaults['exclude']['services'], - ...$overrides['exclude']['services'], - ]; + $exclude = $defaults['exclude']; + foreach ($overrides['exclude'] ?? [] as $kind => $rules) { + $exclude[$kind] = [...($exclude[$kind] ?? []), ...$rules]; } + $overrides['exclude'] = $exclude; $config = [...$defaults, ...$overrides]; @@ -264,11 +262,22 @@ function buildStaticSpecification(): Specification { // CLI if (!$requestedSdk || $requestedSdk === 'cli') { + // CLI go.mod replace-pins github.com/appwrite/sdk-for-go/v7 to ../go. + $go = new SDK(new Go(), buildSpecification($spec)); + configureSDK($go, [ + 'platform' => 'server', + 'gitUserName' => 'appwrite', + 'gitRepoName' => 'sdk-for-go', + 'version' => '7.2.0-rc.3', + ]); + $go->generate(__DIR__ . '/examples/go'); + $language = new CLI(); $language->setExecutableName('appwrite'); $language->setLogo($cliLogo); $language->setLogoUnescaped($cliLogoUnescaped); $language->setNPMPackage('appwrite-cli'); + $language->setSdkPath('../go'); $sdk = new SDK($language, buildSpecification($spec)); $sdk->setTest(false); @@ -332,11 +341,11 @@ function buildStaticSpecification(): Specification { if (!$requestedSdk || $requestedSdk === 'go') { $sdk = new SDK(new Go(), buildSpecification($spec)); // The version decides the major-version suffix Go requires from v2 on, - // so examples/go declares `sdk-for-go/v6` like the published module. + // so examples/go declares `sdk-for-go/v7` like the published module. configureSDK($sdk, [ 'gitUserName' => 'appwrite', 'gitRepoName' => 'sdk-for-go', - 'version' => '6.2.0', + 'version' => '7.2.0-rc.3', ]); $sdk->generate(__DIR__ . '/examples/go'); } diff --git a/src/SDK/Language.php b/src/SDK/Language.php index a384eadc41..1beeac2509 100644 --- a/src/SDK/Language.php +++ b/src/SDK/Language.php @@ -583,17 +583,23 @@ protected function getSchemaEnumName(Schema|Parameter $value, ?Specification $sp return $name; } + $source = $this->getSchema($value); + $source = $source instanceof ArraySchema ? $source->items : $source; + foreach ($spec?->schemas ?? [] as $modelName => $model) { if (!$model instanceof ObjectSchema) { continue; } foreach ($model->properties as $propertyName => $property) { - if ($this->getEnumSchema($property) === $enumSchema) { + $propertySource = $this->getSchema($property); + $propertySource = $propertySource instanceof ArraySchema ? $propertySource->items : $propertySource; + if ($propertySource === $source) { return \ucfirst($modelName) . \ucfirst($propertyName); } } } - return ''; + + return $value instanceof Parameter ? $value->name : ''; } protected function getSpecificationSchemaName(Schema $schema, Specification $spec): string diff --git a/src/SDK/Language/Android.php b/src/SDK/Language/Android.php index e47049d336..e788514a6d 100644 --- a/src/SDK/Language/Android.php +++ b/src/SDK/Language/Android.php @@ -130,6 +130,11 @@ public function getFiles(): array 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/Query.kt', 'template' => '/android/library/src/main/java/io/package/Query.kt.twig', ], + [ + 'scope' => 'default', + 'destination' => '/library/src/test/java/{{ sdk.namespace | caseSlash }}/QueryTest.kt', + 'template' => '/kotlin/src/test/kotlin/io/appwrite/QueryTest.kt.twig', + ], [ 'scope' => 'default', 'destination' => '/library/src/main/java/{{ sdk.namespace | caseSlash }}/Operator.kt', diff --git a/src/SDK/Language/Apple.php b/src/SDK/Language/Apple.php index 1145688d5b..8de77b2c38 100644 --- a/src/SDK/Language/Apple.php +++ b/src/SDK/Language/Apple.php @@ -95,6 +95,11 @@ public function getFiles(): array 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Query.swift', 'template' => 'swift/Sources/Query.swift.twig', ], + [ + 'scope' => 'default', + 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Query/Builder.swift', + 'template' => 'swift/Sources/Query/Builder.swift.twig', + ], [ 'scope' => 'default', 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Operator.swift', diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 8de2d0b2a1..c7d50acf32 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -42,6 +42,7 @@ class CLI extends Go // silently break every installer if they did not. 'npmPackage' => 'appwrite-cli', 'sdkImportPath' => self::SDK_MODULE, + 'sdkPath' => '', // ASCII art above the main help screen. Templates escape it for their // target syntax rather than sharing one encoded representation. 'logo' => '', @@ -98,6 +99,16 @@ public function setNPMPackage(string $name): self return $this; } + /** + * Local directory go.mod replaces the published Go SDK with. Empty keeps the published module. + */ + public function setSdkPath(string $path): self + { + $this->setParam('sdkPath', $path); + + return $this; + } + /** * Configure the Homebrew tap (`/homebrew-`) hosting the formula. */ diff --git a/src/SDK/Language/Dart.php b/src/SDK/Language/Dart.php index 9a3bba50ab..3a911abc8d 100644 --- a/src/SDK/Language/Dart.php +++ b/src/SDK/Language/Dart.php @@ -341,6 +341,11 @@ public function getFiles(): array 'destination' => '/lib/query.dart', 'template' => 'dart/lib/query.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/lib/query/builder.dart', + 'template' => 'dart/lib/query/builder.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/lib/operator.dart', diff --git a/src/SDK/Language/DotNet.php b/src/SDK/Language/DotNet.php index 50681e8234..95308e4abb 100644 --- a/src/SDK/Language/DotNet.php +++ b/src/SDK/Language/DotNet.php @@ -376,6 +376,11 @@ public function getFiles(): array 'destination' => '{{ spec.info.title | caseUcfirst }}/Query.cs', 'template' => 'dotnet/Package/Query.cs.twig', ], + [ + 'scope' => 'default', + 'destination' => '{{ spec.info.title | caseUcfirst }}/Query/Builder.cs', + 'template' => 'dotnet/Package/Query/Builder.cs.twig', + ], [ 'scope' => 'default', 'destination' => '{{ spec.info.title | caseUcfirst }}/Operator.cs', diff --git a/src/SDK/Language/Flutter.php b/src/SDK/Language/Flutter.php index 2b79fc306a..e9dfc723cf 100644 --- a/src/SDK/Language/Flutter.php +++ b/src/SDK/Language/Flutter.php @@ -96,6 +96,11 @@ public function getFiles(): array 'destination' => '/lib/query.dart', 'template' => 'dart/lib/query.dart.twig', ], + [ + 'scope' => 'default', + 'destination' => '/lib/query/builder.dart', + 'template' => 'dart/lib/query/builder.dart.twig', + ], [ 'scope' => 'default', 'destination' => '/lib/operator.dart', diff --git a/src/SDK/Language/Go.php b/src/SDK/Language/Go.php index 51a561b9b6..f816ad946d 100644 --- a/src/SDK/Language/Go.php +++ b/src/SDK/Language/Go.php @@ -108,6 +108,11 @@ public function getFiles(): array 'destination' => 'query/query.go', 'template' => 'go/query.go.twig', ], + [ + 'scope' => 'default', + 'destination' => 'query/builder.go', + 'template' => 'go/query/builder.go.twig', + ], [ 'scope' => 'default', 'destination' => 'operator/operator.go', diff --git a/src/SDK/Language/Kotlin.php b/src/SDK/Language/Kotlin.php index cd8823a196..80be2e7075 100644 --- a/src/SDK/Language/Kotlin.php +++ b/src/SDK/Language/Kotlin.php @@ -532,6 +532,11 @@ public function getFiles(): array 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Query.kt', 'template' => '/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig', ], + [ + 'scope' => 'default', + 'destination' => '/src/test/kotlin/{{ sdk.namespace | caseSlash }}/QueryTest.kt', + 'template' => '/kotlin/src/test/kotlin/io/appwrite/QueryTest.kt.twig', + ], [ 'scope' => 'default', 'destination' => '/src/main/kotlin/{{ sdk.namespace | caseSlash }}/Operator.kt', diff --git a/src/SDK/Language/PHP.php b/src/SDK/Language/PHP.php index bbe64fb6b9..d16bb29ad5 100644 --- a/src/SDK/Language/PHP.php +++ b/src/SDK/Language/PHP.php @@ -273,6 +273,11 @@ public function getFiles(): array 'destination' => 'src/{{ namespace | caseNamespacePath }}/Query.php', 'template' => 'php/src/Query.php.twig', ], + [ + 'scope' => 'default', + 'destination' => 'src/{{ namespace | caseNamespacePath }}/Query/Builder.php', + 'template' => 'php/src/Query/Builder.php.twig', + ], [ 'scope' => 'default', 'destination' => 'tests/{{ namespace | caseNamespacePath }}/QueryTest.php', diff --git a/src/SDK/Language/Ruby.php b/src/SDK/Language/Ruby.php index aa9493d7c9..929c0c19b3 100644 --- a/src/SDK/Language/Ruby.php +++ b/src/SDK/Language/Ruby.php @@ -167,6 +167,11 @@ public function getFiles(): array 'destination' => 'lib/{{ spec.info.title | caseDash }}/query.rb', 'template' => 'ruby/lib/container/query.rb.twig', ], + [ + 'scope' => 'default', + 'destination' => 'lib/{{ spec.info.title | caseDash }}/query/builder.rb', + 'template' => 'ruby/lib/container/query/builder.rb.twig', + ], [ 'scope' => 'default', 'destination' => 'lib/{{ spec.info.title | caseDash }}/operator.rb', @@ -283,7 +288,8 @@ public function getParamExample(Schema|Parameter $param, string $lang = ''): str $output .= $this->isPermissionString($example) ? $this->getPermissionExample($example) : $example; break; case self::TYPE_OBJECT: - $output .= $this->jsonToHash(json_decode((string) $example, true)); + $decoded = \is_array($example) ? $example : json_decode((string) $example, true); + $output .= $this->jsonToHash(\is_array($decoded) ? $decoded : []); break; case self::TYPE_BOOLEAN: $output .= ($example) ? 'true' : 'false'; diff --git a/src/SDK/Language/Rust.php b/src/SDK/Language/Rust.php index 4d238ef689..586e5d341a 100644 --- a/src/SDK/Language/Rust.php +++ b/src/SDK/Language/Rust.php @@ -194,8 +194,13 @@ public function getFiles(): array ], [ "scope" => "default", - "destination" => "src/query.rs", - "template" => "rust/src/query.rs.twig", + "destination" => "src/query/mod.rs", + "template" => "rust/src/query/mod.rs.twig", + ], + [ + "scope" => "default", + "destination" => "src/query/builder.rs", + "template" => "rust/src/query/builder.rs.twig", ], [ "scope" => "default", @@ -955,4 +960,13 @@ protected function formatArrayItemExample(mixed $value, Schema $items): string }, }; } + + #[Override] + public function postGenerate(string $target): void + { + $legacy = $target . '/src/query.rs'; + if (\is_file($legacy) && \is_dir($target . '/src/query')) { + \unlink($legacy); + } + } } diff --git a/src/SDK/Language/Swift.php b/src/SDK/Language/Swift.php index bc0aba1b25..1936770ef1 100644 --- a/src/SDK/Language/Swift.php +++ b/src/SDK/Language/Swift.php @@ -197,6 +197,11 @@ public function getFiles(): array 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Query.swift', 'template' => 'swift/Sources/Query.swift.twig', ], + [ + 'scope' => 'default', + 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Query/Builder.swift', + 'template' => 'swift/Sources/Query/Builder.swift.twig', + ], [ 'scope' => 'default', 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Operator.swift', diff --git a/src/SDK/Language/Unity.php b/src/SDK/Language/Unity.php index dac2d79137..09745ecedb 100644 --- a/src/SDK/Language/Unity.php +++ b/src/SDK/Language/Unity.php @@ -169,6 +169,11 @@ public function getFiles(): array 'destination' => 'Assets/Runtime/Core/Query.cs', 'template' => 'dotnet/Package/Query.cs.twig', ], + [ + 'scope' => 'default', + 'destination' => 'Assets/Runtime/Core/Query/Builder.cs', + 'template' => 'dotnet/Package/Query/Builder.cs.twig', + ], [ 'scope' => 'default', 'destination' => 'Assets/Runtime/Core/Role.cs', diff --git a/src/SDK/SDK.php b/src/SDK/SDK.php index 016e478373..85de8a578e 100644 --- a/src/SDK/SDK.php +++ b/src/SDK/SDK.php @@ -1697,6 +1697,7 @@ protected function getSchemaDependencies(Schema $schema): array protected function getEnumName(Schema|Parameter $value): string { + $original = $this->getSchema($value); $enumSchema = $this->language->getEnumSchema($value); if ($enumSchema->enum === []) { return ''; @@ -1704,6 +1705,7 @@ protected function getEnumName(Schema|Parameter $value): string return (string) (($enumSchema instanceof StringSchema ? $enumSchema->enumName : null) ?? $enumSchema->title ?? $this->schemaEnumNames[\spl_object_id($enumSchema)] + ?? $this->schemaEnumNames[\spl_object_id($original)] ?? ($value instanceof Parameter ? $value->name : '')); } diff --git a/templates/android/library/src/main/java/io/package/Client.kt.twig b/templates/android/library/src/main/java/io/package/Client.kt.twig index 5b31f828e7..171cdab5d9 100644 --- a/templates/android/library/src/main/java/io/package/Client.kt.twig +++ b/templates/android/library/src/main/java/io/package/Client.kt.twig @@ -335,7 +335,10 @@ class Client @JvmOverloads constructor( headers: Map = mapOf(), params: Map = mapOf(), ): Request { - val filteredParams = params.filterValues { it != null } + // flatten() resolves Query/Query.Builder values before the null filter, + // so a builder that renders to null is dropped like any other null. + val flattenedParams = params.mapValues { flatten(it.value) } + val filteredParams = flattenedParams.filterValues { it != null } val requestHeaders = this.headers.toHeaders().newBuilder() .addAll(headers.toHeaders()) @@ -398,7 +401,7 @@ class Client @JvmOverloads constructor( } builder.build() } else { - params + flattenedParams .toJsonRequestBody() .toRequestBody("application/json".toMediaType()) } @@ -742,4 +745,44 @@ class Client @JvmOverloads constructor( } }) } + + fun flatten(value: Any?): Any? { + if (value is Query.Builder) { + return value.build() + } + if (value is Query) { + return value.toString() + } + + val items = when (value) { + is Array<*> -> value.asList() + is Collection<*> -> value + else -> return value + } + + val out = ArrayList(items.size) + var changed = false + for (item in items) { + val flat = flatten(item) + if (item is Query.Builder || item is Query) { + if (flat is Collection<*>) { + out.addAll(flat) + } else { + out.add(flat) + } + changed = true + continue + } + if ((item is Array<*> || item is Collection<*>) && flat is Collection<*> && flat.all { it is String }) { + out.addAll(flat) + changed = true + continue + } + if (flat !== item) { + changed = true + } + out.add(flat) + } + return if (changed) out else value + } } diff --git a/templates/android/library/src/main/java/io/package/Query.kt.twig b/templates/android/library/src/main/java/io/package/Query.kt.twig index c7dd692801..adf28d2741 100644 --- a/templates/android/library/src/main/java/io/package/Query.kt.twig +++ b/templates/android/library/src/main/java/io/package/Query.kt.twig @@ -520,6 +520,377 @@ class Query( */ fun notTouches(attribute: String, values: List) = Query("notTouches", attribute, listOf(values)).toJson() + /** + * Count rows of [attribute], optionally aliased. + * + * @param attribute The attribute to count. Defaults to `*`. + * @param alias Optional result alias. + * @returns The query string. + */ + fun count(attribute: String = "*", alias: String = "") = Query("count", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Count distinct values of [attribute], optionally aliased. + * + * @param attribute The attribute to count. + * @param alias Optional result alias. + * @returns The query string. + */ + fun countDistinct(attribute: String, alias: String = "") = Query("countDistinct", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Sum values of [attribute], optionally aliased. + * + * @param attribute The attribute to sum. + * @param alias Optional result alias. + * @returns The query string. + */ + fun sum(attribute: String, alias: String = "") = Query("sum", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Average values of [attribute], optionally aliased. + * + * @param attribute The attribute to average. + * @param alias Optional result alias. + * @returns The query string. + */ + fun avg(attribute: String, alias: String = "") = Query("avg", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Minimum value of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun min(attribute: String, alias: String = "") = Query("min", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Maximum value of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun max(attribute: String, alias: String = "") = Query("max", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Standard deviation of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun stddev(attribute: String, alias: String = "") = Query("stddev", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Population standard deviation of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun stddevPop(attribute: String, alias: String = "") = Query("stddevPop", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Sample standard deviation of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun stddevSamp(attribute: String, alias: String = "") = Query("stddevSamp", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Variance of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun variance(attribute: String, alias: String = "") = Query("variance", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Population variance of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun varPop(attribute: String, alias: String = "") = Query("varPop", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Sample variance of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun varSamp(attribute: String, alias: String = "") = Query("varSamp", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Bitwise AND of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun bitAnd(attribute: String, alias: String = "") = Query("bitAnd", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Bitwise OR of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun bitOr(attribute: String, alias: String = "") = Query("bitOr", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Bitwise XOR of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun bitXor(attribute: String, alias: String = "") = Query("bitXor", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Group results by [attributes]. + * + * @param attributes Attributes to group by. + * @returns The query string. + */ + fun groupBy(attributes: List) = Query("groupBy", null, attributes).toJson() + + /** + * Filter grouped results. [queries] are nested like `and`/`or`. + * + * @param queries Nested query strings. + * @returns The query string. + */ + fun having(queries: List) = Query("having", null, queries.map { it.fromJson>() }).toJson() + + /** + * Select distinct rows. + * + * @returns The query string. + */ + fun distinct() = Query("distinct").toJson() + + /** + * Column-to-column join ON condition. + * + * @param left Left-hand column. + * @param right Right-hand column. + * @param operator Comparison operator. Defaults to `=`. + * @returns The query string. + */ + fun on(left: String, right: String, operator: String = "=") = Query("on", values = listOf(left, operator, right)).toJson() + + /** + * Inner join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun join(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("join", table, left, right, operator, alias) + + /** + * Inner join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun join(table: String, on: List) = nestedJoin("join", table, "", on) + + /** + * Inner join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun join(table: String, alias: String, on: List) = nestedJoin("join", table, alias, on) + + /** + * Left join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun leftJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("leftJoin", table, left, right, operator, alias) + + /** + * Left join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun leftJoin(table: String, on: List) = nestedJoin("leftJoin", table, "", on) + + /** + * Left join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun leftJoin(table: String, alias: String, on: List) = nestedJoin("leftJoin", table, alias, on) + + /** + * Right join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun rightJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("rightJoin", table, left, right, operator, alias) + + /** + * Right join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun rightJoin(table: String, on: List) = nestedJoin("rightJoin", table, "", on) + + /** + * Right join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun rightJoin(table: String, alias: String, on: List) = nestedJoin("rightJoin", table, alias, on) + + /** + * Full outer join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun fullOuterJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("fullOuterJoin", table, left, right, operator, alias) + + /** + * Full outer join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun fullOuterJoin(table: String, on: List) = nestedJoin("fullOuterJoin", table, "", on) + + /** + * Full outer join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun fullOuterJoin(table: String, alias: String, on: List) = nestedJoin("fullOuterJoin", table, alias, on) + + /** + * Cross join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param alias Optional table alias. + * @returns The query string. + */ + fun crossJoin(table: String, alias: String = "") = Query("crossJoin", table, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Filter resources where [attribute] covers the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun covers(attribute: String, values: List) = Query("covers", attribute, listOf(values)).toJson() + + /** + * Filter resources where [attribute] does not cover the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun notCovers(attribute: String, values: List) = Query("notCovers", attribute, listOf(values)).toJson() + + /** + * Filter resources where [attribute] is spatially equal to the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun spatialEquals(attribute: String, values: List) = Query("spatialEquals", attribute, listOf(values)).toJson() + + /** + * Filter resources where [attribute] is not spatially equal to the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun notSpatialEquals(attribute: String, values: List) = Query("notSpatialEquals", attribute, listOf(values)).toJson() + + /** + * Return limit and offset queries for 1-based page pagination. + * + * @param page 1-based page number. + * @param perPage Rows per page. Defaults to 25. + * @returns Limit and offset query strings. + */ + fun page(page: Int, perPage: Int = 25): List { + require(page >= 1) { "Page must be >= 1, got $page" } + require(perPage >= 1) { "Per page must be >= 1, got $perPage" } + return listOf(limit(perPage), offset((page - 1) * perPage)) + } + + /** + * Start a fluent query builder. + * + * @returns A new query builder. + */ + fun builder() = Builder() + + private fun joinQuery(method: String, table: String, left: String, right: String, operator: String, alias: String): String { + val values = if (alias.isNotEmpty()) listOf(left, operator, right, alias) else listOf(left, operator, right) + return Query(method, table, values).toJson() + } + + private fun nestedJoin(method: String, table: String, alias: String, on: List): String { + val values = mutableListOf() + if (alias.isNotEmpty()) { + values.add(alias) + } + on.forEach { values.add(it.fromJson>()) } + return Query(method, table, values).toJson() + } + /** * Parse the value to a list of values. * @@ -533,4 +904,196 @@ class Query( } } } + + class Builder { + private val queries = mutableListOf() + + fun build(): List = queries.toList() + + fun equal(attribute: String, value: Any) = push(Query.equal(attribute, value)) + + fun notEqual(attribute: String, value: Any) = push(Query.notEqual(attribute, value)) + + fun lessThan(attribute: String, value: Any) = push(Query.lessThan(attribute, value)) + + fun lessThanEqual(attribute: String, value: Any) = push(Query.lessThanEqual(attribute, value)) + + fun greaterThan(attribute: String, value: Any) = push(Query.greaterThan(attribute, value)) + + fun greaterThanEqual(attribute: String, value: Any) = push(Query.greaterThanEqual(attribute, value)) + + fun search(attribute: String, value: String) = push(Query.search(attribute, value)) + + fun isNull(attribute: String) = push(Query.isNull(attribute)) + + fun isNotNull(attribute: String) = push(Query.isNotNull(attribute)) + + fun between(attribute: String, start: Any, end: Any) = push(Query.between(attribute, start, end)) + + fun startsWith(attribute: String, value: String) = push(Query.startsWith(attribute, value)) + + fun endsWith(attribute: String, value: String) = push(Query.endsWith(attribute, value)) + + fun select(attributes: List) = push(Query.select(attributes)) + + fun cursorAfter(documentId: String) = push(Query.cursorAfter(documentId)) + + fun cursorBefore(documentId: String) = push(Query.cursorBefore(documentId)) + + fun orderAsc(attribute: String) = push(Query.orderAsc(attribute)) + + fun orderDesc(attribute: String) = push(Query.orderDesc(attribute)) + + fun orderRandom() = push(Query.orderRandom()) + + fun limit(limit: Int) = push(Query.limit(limit)) + + fun offset(offset: Int) = push(Query.offset(offset)) + + fun contains(attribute: String, value: Any) = push(Query.contains(attribute, value)) + + fun containsAny(attribute: String, value: List) = push(Query.containsAny(attribute, value)) + + fun containsAll(attribute: String, value: List) = push(Query.containsAll(attribute, value)) + + fun notContains(attribute: String, value: Any) = push(Query.notContains(attribute, value)) + + fun notSearch(attribute: String, value: String) = push(Query.notSearch(attribute, value)) + + fun notBetween(attribute: String, start: Any, end: Any) = push(Query.notBetween(attribute, start, end)) + + fun notStartsWith(attribute: String, value: String) = push(Query.notStartsWith(attribute, value)) + + fun notEndsWith(attribute: String, value: String) = push(Query.notEndsWith(attribute, value)) + + fun regex(attribute: String, pattern: String) = push(Query.regex(attribute, pattern)) + + fun exists(attributes: List) = push(Query.exists(attributes)) + + fun notExists(attributes: List) = push(Query.notExists(attributes)) + + fun createdBefore(value: String) = push(Query.createdBefore(value)) + + fun createdAfter(value: String) = push(Query.createdAfter(value)) + + fun createdBetween(start: String, end: String) = push(Query.createdBetween(start, end)) + + fun updatedBefore(value: String) = push(Query.updatedBefore(value)) + + fun updatedAfter(value: String) = push(Query.updatedAfter(value)) + + fun updatedBetween(start: String, end: String) = push(Query.updatedBetween(start, end)) + + fun or(queries: List) = push(Query.or(queries)) + + fun and(queries: List) = push(Query.and(queries)) + + fun elemMatch(attribute: String, queries: List) = push(Query.elemMatch(attribute, queries)) + + fun distanceEqual(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceEqual(attribute, values, distance, meters)) + + fun distanceNotEqual(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceNotEqual(attribute, values, distance, meters)) + + fun distanceGreaterThan(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceGreaterThan(attribute, values, distance, meters)) + + fun distanceLessThan(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceLessThan(attribute, values, distance, meters)) + + fun intersects(attribute: String, values: List) = push(Query.intersects(attribute, values)) + + fun notIntersects(attribute: String, values: List) = push(Query.notIntersects(attribute, values)) + + fun crosses(attribute: String, values: List) = push(Query.crosses(attribute, values)) + + fun notCrosses(attribute: String, values: List) = push(Query.notCrosses(attribute, values)) + + fun overlaps(attribute: String, values: List) = push(Query.overlaps(attribute, values)) + + fun notOverlaps(attribute: String, values: List) = push(Query.notOverlaps(attribute, values)) + + fun touches(attribute: String, values: List) = push(Query.touches(attribute, values)) + + fun notTouches(attribute: String, values: List) = push(Query.notTouches(attribute, values)) + + fun count(attribute: String = "*", alias: String = "") = push(Query.count(attribute, alias)) + + fun countDistinct(attribute: String, alias: String = "") = push(Query.countDistinct(attribute, alias)) + + fun sum(attribute: String, alias: String = "") = push(Query.sum(attribute, alias)) + + fun avg(attribute: String, alias: String = "") = push(Query.avg(attribute, alias)) + + fun min(attribute: String, alias: String = "") = push(Query.min(attribute, alias)) + + fun max(attribute: String, alias: String = "") = push(Query.max(attribute, alias)) + + fun stddev(attribute: String, alias: String = "") = push(Query.stddev(attribute, alias)) + + fun stddevPop(attribute: String, alias: String = "") = push(Query.stddevPop(attribute, alias)) + + fun stddevSamp(attribute: String, alias: String = "") = push(Query.stddevSamp(attribute, alias)) + + fun variance(attribute: String, alias: String = "") = push(Query.variance(attribute, alias)) + + fun varPop(attribute: String, alias: String = "") = push(Query.varPop(attribute, alias)) + + fun varSamp(attribute: String, alias: String = "") = push(Query.varSamp(attribute, alias)) + + fun bitAnd(attribute: String, alias: String = "") = push(Query.bitAnd(attribute, alias)) + + fun bitOr(attribute: String, alias: String = "") = push(Query.bitOr(attribute, alias)) + + fun bitXor(attribute: String, alias: String = "") = push(Query.bitXor(attribute, alias)) + + fun groupBy(attributes: List) = push(Query.groupBy(attributes)) + + fun having(queries: List) = push(Query.having(queries)) + + fun distinct() = push(Query.distinct()) + + fun join(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.join(table, left, right, operator, alias)) + + fun join(table: String, on: List) = push(Query.join(table, on)) + + fun join(table: String, alias: String, on: List) = push(Query.join(table, alias, on)) + + fun leftJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.leftJoin(table, left, right, operator, alias)) + + fun leftJoin(table: String, on: List) = push(Query.leftJoin(table, on)) + + fun leftJoin(table: String, alias: String, on: List) = push(Query.leftJoin(table, alias, on)) + + fun rightJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.rightJoin(table, left, right, operator, alias)) + + fun rightJoin(table: String, on: List) = push(Query.rightJoin(table, on)) + + fun rightJoin(table: String, alias: String, on: List) = push(Query.rightJoin(table, alias, on)) + + fun fullOuterJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.fullOuterJoin(table, left, right, operator, alias)) + + fun fullOuterJoin(table: String, on: List) = push(Query.fullOuterJoin(table, on)) + + fun fullOuterJoin(table: String, alias: String, on: List) = push(Query.fullOuterJoin(table, alias, on)) + + fun crossJoin(table: String, alias: String = "") = push(Query.crossJoin(table, alias)) + + fun vectorDot(attribute: String, vector: List) = push(Query.vectorDot(attribute, vector)) + + fun vectorCosine(attribute: String, vector: List) = push(Query.vectorCosine(attribute, vector)) + + fun vectorEuclidean(attribute: String, vector: List) = push(Query.vectorEuclidean(attribute, vector)) + + fun covers(attribute: String, values: List) = push(Query.covers(attribute, values)) + + fun notCovers(attribute: String, values: List) = push(Query.notCovers(attribute, values)) + + fun spatialEquals(attribute: String, values: List) = push(Query.spatialEquals(attribute, values)) + + fun notSpatialEquals(attribute: String, values: List) = push(Query.notSpatialEquals(attribute, values)) + + fun page(page: Int, perPage: Int = 25) = push(Query.page(page, perPage)) + + private fun push(query: String) = apply { queries.add(query) } + + private fun push(query: List) = apply { queries.addAll(query) } + } } diff --git a/templates/apple/Package.swift.twig b/templates/apple/Package.swift.twig index c94ca67404..1ab713ee72 100644 --- a/templates/apple/Package.swift.twig +++ b/templates/apple/Package.swift.twig @@ -15,8 +15,12 @@ let package = Package( name: "{{spec.info.title | caseUcfirst}}", targets: [ "{{spec.info.title | caseUcfirst}}", + {%~ if allEnums is not empty %} "{{spec.info.title | caseUcfirst}}Enums", + {%~ endif %} + {%~ if definitions is not empty %} "{{spec.info.title | caseUcfirst}}Models", + {%~ endif %} "JSONCodable", ] ) diff --git a/templates/apple/Sources/Client.swift.twig b/templates/apple/Sources/Client.swift.twig index 90b3aa62fd..af7d7f0637 100644 --- a/templates/apple/Sources/Client.swift.twig +++ b/templates/apple/Sources/Client.swift.twig @@ -345,7 +345,10 @@ open class Client { sink: ((ByteBuffer) -> Void)? = nil, converter: ((Any) throws -> T)? = nil ) async throws -> T { - let validParams = params.filter { $0.value != nil } + let validParams = + params + .mapValues { flatten($0) } + .filter { $0.value != nil } let queryParameters = method == "GET" && !validParams.isEmpty @@ -753,6 +756,69 @@ open class Client { value: "{{ spec.info.title | caseLower }}-\(operatingSystem)://\(packageInfo.packageName)" ) } + + public func flatten(_ value: Any?) -> Any? { + return flattened(value).value + } + + private func flattened(_ value: Any?) -> (value: Any?, changed: Bool) { + if let builder = value as? Query.Builder { + return (builder.build(), true) + } + if let query = value as? Query { + return (query.description, true) + } + if value is [String] { + return (value, false) + } + guard let items = arrayElements(value) else { + return (value, false) + } + + var out: [Any] = [] + var changed = false + for item in items { + let flat = flattened(item) + changed = changed || flat.changed + if item is Query.Builder, let queries = flat.value as? [String] { + out.append(contentsOf: queries) + } else if let entry = flat.value { + out.append(entry) + } + } + return changed ? (out, true) : (value, false) + } + + private func arrayElements(_ value: Any?) -> [Any]? { + guard let value, !(value is any StringProtocol) else { + return nil + } + if let typed = value as? [Query.Builder] { + return typed + } + if let typed = value as? [Query] { + return typed + } + if let typed = value as? [String] { + return typed + } + if let array = value as? NSArray { + return (0.. {{ language.params.sdkPath }} +{% endif %} diff --git a/templates/cli/internal/generator/testdata/client.databases.ts b/templates/cli/internal/generator/testdata/client.databases.ts index 2d86c2007a..6941a7c0e8 100644 --- a/templates/cli/internal/generator/testdata/client.databases.ts +++ b/templates/cli/internal/generator/testdata/client.databases.ts @@ -25,6 +25,37 @@ const createQueryBuilder = (): QueryBuilder => ({ cursorBefore: (documentId) => Query.cursorBefore(documentId), or: (...queries) => Query.or(queries), and: (...queries) => Query.and(queries), + count: (attribute = '*', alias = '') => Query.count(attribute, alias), + countDistinct: (attribute, alias = '') => Query.countDistinct(attribute, alias), + sum: (attribute, alias = '') => Query.sum(attribute, alias), + avg: (attribute, alias = '') => Query.avg(attribute, alias), + min: (attribute, alias = '') => Query.min(attribute, alias), + max: (attribute, alias = '') => Query.max(attribute, alias), + stddev: (attribute, alias = '') => Query.stddev(attribute, alias), + stddevPop: (attribute, alias = '') => Query.stddevPop(attribute, alias), + stddevSamp: (attribute, alias = '') => Query.stddevSamp(attribute, alias), + variance: (attribute, alias = '') => Query.variance(attribute, alias), + varPop: (attribute, alias = '') => Query.varPop(attribute, alias), + varSamp: (attribute, alias = '') => Query.varSamp(attribute, alias), + bitAnd: (attribute, alias = '') => Query.bitAnd(attribute, alias), + bitOr: (attribute, alias = '') => Query.bitOr(attribute, alias), + bitXor: (attribute, alias = '') => Query.bitXor(attribute, alias), + groupBy: (attributes) => Query.groupBy(attributes), + having: (queries) => Query.having(queries), + distinct: () => Query.distinct(), + on: (left, right, operator = '=') => Query.on(left, right, operator), + join: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias), + leftJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.leftJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + rightJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.rightJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + fullOuterJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.fullOuterJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + crossJoin: (table, alias = '') => Query.crossJoin(table, alias), + vectorDot: (attribute, vector) => Query.vectorDot(attribute, vector), + vectorCosine: (attribute, vector) => Query.vectorCosine(attribute, vector), + vectorEuclidean: (attribute, vector) => Query.vectorEuclidean(attribute, vector), + covers: (attribute, values) => Query.covers(attribute, values), + notCovers: (attribute, values) => Query.notCovers(attribute, values), + spatialEquals: (attribute, values) => Query.spatialEquals(attribute, values), + notSpatialEquals: (attribute, values) => Query.notSpatialEquals(attribute, values), }); const tableIdMap: Record> = Object.create(null); diff --git a/templates/cli/internal/generator/testdata/client.types.ts b/templates/cli/internal/generator/testdata/client.types.ts index f686e08720..c155f3b9a2 100644 --- a/templates/cli/internal/generator/testdata/client.types.ts +++ b/templates/cli/internal/generator/testdata/client.types.ts @@ -111,6 +111,37 @@ export type QueryBuilder = { cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string; + count: (attribute?: string, alias?: string) => string; + countDistinct: (attribute: string, alias?: string) => string; + sum: (attribute: string, alias?: string) => string; + avg: (attribute: string, alias?: string) => string; + min: (attribute: string, alias?: string) => string; + max: (attribute: string, alias?: string) => string; + stddev: (attribute: string, alias?: string) => string; + stddevPop: (attribute: string, alias?: string) => string; + stddevSamp: (attribute: string, alias?: string) => string; + variance: (attribute: string, alias?: string) => string; + varPop: (attribute: string, alias?: string) => string; + varSamp: (attribute: string, alias?: string) => string; + bitAnd: (attribute: string, alias?: string) => string; + bitOr: (attribute: string, alias?: string) => string; + bitXor: (attribute: string, alias?: string) => string; + groupBy: (attributes: string[]) => string; + having: (queries: string[]) => string; + distinct: () => string; + on: (left: string, right: string, operator?: string) => string; + join: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + leftJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + rightJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + fullOuterJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + crossJoin: (table: string, alias?: string) => string; + vectorDot: (attribute: string, vector: number[]) => string; + vectorCosine: (attribute: string, vector: number[]) => string; + vectorEuclidean: (attribute: string, vector: number[]) => string; + covers: (attribute: string, values: any[]) => string; + notCovers: (attribute: string, values: any[]) => string; + spatialEquals: (attribute: string, values: any[]) => string; + notSpatialEquals: (attribute: string, values: any[]) => string; } export type DatabaseId = "main" | "analytics"; @@ -140,7 +171,7 @@ export type DatabaseTableMap = { "books"?: ((BooksZinesCreate & { $id?: string; $permissions?: string[] }) | string)[] | null; }>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: Authors[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: Authors[] }>; }; "Books & Zines": { create: (data: { @@ -153,7 +184,7 @@ export type DatabaseTableMap = { "pages"?: number | null; }>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: BooksZines[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: BooksZines[] }>; } }; "analytics": { @@ -168,14 +199,14 @@ export type DatabaseTableMap = { "at": string; }>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: Events[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: Events[] }>; }; "Empty": { create: (data: Record, options?: { rowId?: string; permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; get: (id: string) => Promise; update: (id: string, data: Partial>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: Empty[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: Empty[] }>; } } }; diff --git a/templates/cli/internal/generator/testdata/server.databases.ts b/templates/cli/internal/generator/testdata/server.databases.ts index bbc31051db..24ceff642b 100644 --- a/templates/cli/internal/generator/testdata/server.databases.ts +++ b/templates/cli/internal/generator/testdata/server.databases.ts @@ -25,6 +25,37 @@ const createQueryBuilder = (): QueryBuilder => ({ cursorBefore: (documentId) => Query.cursorBefore(documentId), or: (...queries) => Query.or(queries), and: (...queries) => Query.and(queries), + count: (attribute = '*', alias = '') => Query.count(attribute, alias), + countDistinct: (attribute, alias = '') => Query.countDistinct(attribute, alias), + sum: (attribute, alias = '') => Query.sum(attribute, alias), + avg: (attribute, alias = '') => Query.avg(attribute, alias), + min: (attribute, alias = '') => Query.min(attribute, alias), + max: (attribute, alias = '') => Query.max(attribute, alias), + stddev: (attribute, alias = '') => Query.stddev(attribute, alias), + stddevPop: (attribute, alias = '') => Query.stddevPop(attribute, alias), + stddevSamp: (attribute, alias = '') => Query.stddevSamp(attribute, alias), + variance: (attribute, alias = '') => Query.variance(attribute, alias), + varPop: (attribute, alias = '') => Query.varPop(attribute, alias), + varSamp: (attribute, alias = '') => Query.varSamp(attribute, alias), + bitAnd: (attribute, alias = '') => Query.bitAnd(attribute, alias), + bitOr: (attribute, alias = '') => Query.bitOr(attribute, alias), + bitXor: (attribute, alias = '') => Query.bitXor(attribute, alias), + groupBy: (attributes) => Query.groupBy(attributes), + having: (queries) => Query.having(queries), + distinct: () => Query.distinct(), + on: (left, right, operator = '=') => Query.on(left, right, operator), + join: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias), + leftJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.leftJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + rightJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.rightJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + fullOuterJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.fullOuterJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + crossJoin: (table, alias = '') => Query.crossJoin(table, alias), + vectorDot: (attribute, vector) => Query.vectorDot(attribute, vector), + vectorCosine: (attribute, vector) => Query.vectorCosine(attribute, vector), + vectorEuclidean: (attribute, vector) => Query.vectorEuclidean(attribute, vector), + covers: (attribute, values) => Query.covers(attribute, values), + notCovers: (attribute, values) => Query.notCovers(attribute, values), + spatialEquals: (attribute, values) => Query.spatialEquals(attribute, values), + notSpatialEquals: (attribute, values) => Query.notSpatialEquals(attribute, values), }); const tableIdMap: Record> = Object.create(null); diff --git a/templates/cli/internal/generator/testdata/server.types.ts b/templates/cli/internal/generator/testdata/server.types.ts index 0245ce6a05..dfd3d1b5c9 100644 --- a/templates/cli/internal/generator/testdata/server.types.ts +++ b/templates/cli/internal/generator/testdata/server.types.ts @@ -111,6 +111,37 @@ export type QueryBuilder = { cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string; + count: (attribute?: string, alias?: string) => string; + countDistinct: (attribute: string, alias?: string) => string; + sum: (attribute: string, alias?: string) => string; + avg: (attribute: string, alias?: string) => string; + min: (attribute: string, alias?: string) => string; + max: (attribute: string, alias?: string) => string; + stddev: (attribute: string, alias?: string) => string; + stddevPop: (attribute: string, alias?: string) => string; + stddevSamp: (attribute: string, alias?: string) => string; + variance: (attribute: string, alias?: string) => string; + varPop: (attribute: string, alias?: string) => string; + varSamp: (attribute: string, alias?: string) => string; + bitAnd: (attribute: string, alias?: string) => string; + bitOr: (attribute: string, alias?: string) => string; + bitXor: (attribute: string, alias?: string) => string; + groupBy: (attributes: string[]) => string; + having: (queries: string[]) => string; + distinct: () => string; + on: (left: string, right: string, operator?: string) => string; + join: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + leftJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + rightJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + fullOuterJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + crossJoin: (table: string, alias?: string) => string; + vectorDot: (attribute: string, vector: number[]) => string; + vectorCosine: (attribute: string, vector: number[]) => string; + vectorEuclidean: (attribute: string, vector: number[]) => string; + covers: (attribute: string, values: any[]) => string; + notCovers: (attribute: string, values: any[]) => string; + spatialEquals: (attribute: string, values: any[]) => string; + notSpatialEquals: (attribute: string, values: any[]) => string; } export type DatabaseId = "main" | "analytics"; @@ -140,7 +171,7 @@ export type DatabaseTableMap = { "books"?: ((BooksZinesCreate & { $id?: string; $permissions?: string[] }) | string)[] | null; }>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: Authors[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: Authors[] }>; }; "Books & Zines": { create: (data: { @@ -153,7 +184,7 @@ export type DatabaseTableMap = { "pages"?: number | null; }>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: BooksZines[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: BooksZines[] }>; createMany: (rows: Array<{ "title": string; "pages"?: number | null; @@ -161,8 +192,8 @@ export type DatabaseTableMap = { updateMany: (data: Partial<{ "title": string; "pages"?: number | null; - }>, options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]; transactionId?: string }) => Promise<{ total: number; rows: BooksZines[] }>; - deleteMany: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]; transactionId?: string }) => Promise<{ total: number; rows: BooksZines[] }>; + }>, options?: { queries?: (q: QueryBuilder) => string[]; transactionId?: string }) => Promise<{ total: number; rows: BooksZines[] }>; + deleteMany: (options?: { queries?: (q: QueryBuilder) => string[]; transactionId?: string }) => Promise<{ total: number; rows: BooksZines[] }>; } }; "analytics": { @@ -177,7 +208,7 @@ export type DatabaseTableMap = { "at": string; }>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: Events[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: Events[] }>; createMany: (rows: Array<{ "name": string; "at": string; @@ -185,18 +216,18 @@ export type DatabaseTableMap = { updateMany: (data: Partial<{ "name": string; "at": string; - }>, options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Events[] }>; - deleteMany: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Events[] }>; + }>, options?: { queries?: (q: QueryBuilder) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Events[] }>; + deleteMany: (options?: { queries?: (q: QueryBuilder) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Events[] }>; }; "Empty": { create: (data: Record, options?: { rowId?: string; permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; get: (id: string) => Promise; update: (id: string, data: Partial>, options?: { permissions?: (permission: { read: (role: RoleString) => string; write: (role: RoleString) => string; create: (role: RoleString) => string; update: (role: RoleString) => string; delete: (role: RoleString) => string }, role: { any: () => RoleString; user: (userId: string, status?: string) => RoleString; users: (status?: string) => RoleString; guests: () => RoleString; team: (teamId: string, role?: string) => RoleString; member: (memberId: string) => RoleString; label: (label: string) => RoleString }) => string[]; transactionId?: string }) => Promise; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[] }) => Promise<{ total: number; rows: Empty[] }>; + list: (options?: { queries?: (q: QueryBuilder) => string[] }) => Promise<{ total: number; rows: Empty[] }>; createMany: (rows: Array & { $id?: string; $permissions?: string[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: Empty[] }>; - updateMany: (data: Partial>, options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Empty[] }>; - deleteMany: (options?: { queries?: (q: { equal: >(field: K, value: QueryableFieldValue) => string; notEqual: >(field: K, value: QueryableFieldValue) => string; lessThan: >(field: K, value: QueryableFieldValue) => string; lessThanEqual: >(field: K, value: QueryableFieldValue) => string; greaterThan: >(field: K, value: QueryableFieldValue) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue) => string; contains: >(field: K, value: QueryableFieldValue) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue, end: QueryableFieldValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Empty[] }>; + updateMany: (data: Partial>, options?: { queries?: (q: QueryBuilder) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Empty[] }>; + deleteMany: (options?: { queries?: (q: QueryBuilder) => string[]; transactionId?: string }) => Promise<{ total: number; rows: Empty[] }>; } } }; diff --git a/templates/cli/internal/generator/typescript.go b/templates/cli/internal/generator/typescript.go index 0dbf71c800..2d3cd50577 100644 --- a/templates/cli/internal/generator/typescript.go +++ b/templates/cli/internal/generator/typescript.go @@ -34,7 +34,7 @@ const permissionCallbackInline = `(permission: { read: (role: RoleString) => str // queryCallbackInline is the same idea for the query callback, parameterised by // the row type so field names are checked. func queryCallbackInline(typeName string) string { - return `(q: { equal: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; notEqual: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; lessThan: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; lessThanEqual: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; greaterThan: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; greaterThanEqual: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; contains: >(field: K, value: QueryableFieldValue<` + typeName + `, K>) => string; search: >(field: K, value: string) => string; isNull: >(field: K) => string; isNotNull: >(field: K) => string; startsWith: >(field: K, value: string) => string; endsWith: >(field: K, value: string) => string; between: >(field: K, start: QueryableFieldValue<` + typeName + `, K>, end: QueryableFieldValue<` + typeName + `, K>) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; limit: (value: number) => string; offset: (value: number) => string; cursorAfter: (documentId: string) => string; cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string }) => string[]` + return `(q: QueryBuilder<` + typeName + `>) => string[]` } // TypeScript generates a typed SDK for a TypeScript project. diff --git a/templates/cli/internal/typegen/templates/databases.ts.hbs b/templates/cli/internal/typegen/templates/databases.ts.hbs index 3cbb215c76..099c6e28a8 100644 --- a/templates/cli/internal/typegen/templates/databases.ts.hbs +++ b/templates/cli/internal/typegen/templates/databases.ts.hbs @@ -29,6 +29,37 @@ const createQueryBuilder = (): QueryBuilder => ({ cursorBefore: (documentId) => Query.cursorBefore(documentId), or: (...queries) => Query.or(queries), and: (...queries) => Query.and(queries), + count: (attribute = '*', alias = '') => Query.count(attribute, alias), + countDistinct: (attribute, alias = '') => Query.countDistinct(attribute, alias), + sum: (attribute, alias = '') => Query.sum(attribute, alias), + avg: (attribute, alias = '') => Query.avg(attribute, alias), + min: (attribute, alias = '') => Query.min(attribute, alias), + max: (attribute, alias = '') => Query.max(attribute, alias), + stddev: (attribute, alias = '') => Query.stddev(attribute, alias), + stddevPop: (attribute, alias = '') => Query.stddevPop(attribute, alias), + stddevSamp: (attribute, alias = '') => Query.stddevSamp(attribute, alias), + variance: (attribute, alias = '') => Query.variance(attribute, alias), + varPop: (attribute, alias = '') => Query.varPop(attribute, alias), + varSamp: (attribute, alias = '') => Query.varSamp(attribute, alias), + bitAnd: (attribute, alias = '') => Query.bitAnd(attribute, alias), + bitOr: (attribute, alias = '') => Query.bitOr(attribute, alias), + bitXor: (attribute, alias = '') => Query.bitXor(attribute, alias), + groupBy: (attributes) => Query.groupBy(attributes), + having: (queries) => Query.having(queries), + distinct: () => Query.distinct(), + on: (left, right, operator = '=') => Query.on(left, right, operator), + join: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias), + leftJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.leftJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + rightJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.rightJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + fullOuterJoin: (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query.fullOuterJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias), + crossJoin: (table, alias = '') => Query.crossJoin(table, alias), + vectorDot: (attribute, vector) => Query.vectorDot(attribute, vector), + vectorCosine: (attribute, vector) => Query.vectorCosine(attribute, vector), + vectorEuclidean: (attribute, vector) => Query.vectorEuclidean(attribute, vector), + covers: (attribute, values) => Query.covers(attribute, values), + notCovers: (attribute, values) => Query.notCovers(attribute, values), + spatialEquals: (attribute, values) => Query.spatialEquals(attribute, values), + notSpatialEquals: (attribute, values) => Query.notSpatialEquals(attribute, values), }); {{{TABLE_ID_MAP}}} diff --git a/templates/cli/internal/typegen/templates/types.ts.hbs b/templates/cli/internal/typegen/templates/types.ts.hbs index ba0937dd1f..a89a1017e3 100644 --- a/templates/cli/internal/typegen/templates/types.ts.hbs +++ b/templates/cli/internal/typegen/templates/types.ts.hbs @@ -61,6 +61,37 @@ export type QueryBuilder = { cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string; + count: (attribute?: string, alias?: string) => string; + countDistinct: (attribute: string, alias?: string) => string; + sum: (attribute: string, alias?: string) => string; + avg: (attribute: string, alias?: string) => string; + min: (attribute: string, alias?: string) => string; + max: (attribute: string, alias?: string) => string; + stddev: (attribute: string, alias?: string) => string; + stddevPop: (attribute: string, alias?: string) => string; + stddevSamp: (attribute: string, alias?: string) => string; + variance: (attribute: string, alias?: string) => string; + varPop: (attribute: string, alias?: string) => string; + varSamp: (attribute: string, alias?: string) => string; + bitAnd: (attribute: string, alias?: string) => string; + bitOr: (attribute: string, alias?: string) => string; + bitXor: (attribute: string, alias?: string) => string; + groupBy: (attributes: string[]) => string; + having: (queries: string[]) => string; + distinct: () => string; + on: (left: string, right: string, operator?: string) => string; + join: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + leftJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + rightJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + fullOuterJoin: (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string; + crossJoin: (table: string, alias?: string) => string; + vectorDot: (attribute: string, vector: number[]) => string; + vectorCosine: (attribute: string, vector: number[]) => string; + vectorEuclidean: (attribute: string, vector: number[]) => string; + covers: (attribute: string, values: any[]) => string; + notCovers: (attribute: string, values: any[]) => string; + spatialEquals: (attribute: string, values: any[]) => string; + notSpatialEquals: (attribute: string, values: any[]) => string; } export type DatabaseId = {{{databaseIdType}}}; diff --git a/templates/cli/internal/typegen/templates_test.go b/templates/cli/internal/typegen/templates_test.go index 532cc0ca1f..248342a6e6 100644 --- a/templates/cli/internal/typegen/templates_test.go +++ b/templates/cli/internal/typegen/templates_test.go @@ -60,3 +60,37 @@ func TestRenderConstantsTemplate(t *testing.T) { t.Errorf("API_KEY block rendered despite requiresApiKey being false:\n%s", withoutKey) } } + +// The typed QueryBuilder must accept the same join forms as the SDK's Query, so +// a query written against one compiles against the other. +func TestQueryBuilderSupportsNestedJoinConditions(t *testing.T) { + joins := []string{"join", "leftJoin", "rightJoin", "fullOuterJoin"} + + types, err := Template(TemplateTypes) + if err != nil { + t.Fatal(err) + } + declarations := []string{"on: (left: string, right: string, operator?: string) => string;"} + for _, join := range joins { + declarations = append(declarations, join+": (table: string, leftOrAliasOrOn: string | string[], rightOrOn?: string | string[], operator?: string, alias?: string) => string;") + } + for _, want := range declarations { + if !strings.Contains(types, want) { + t.Errorf("%s does not declare %q", TemplateTypes, want) + } + } + + databases, err := Template(TemplateDatabases) + if err != nil { + t.Fatal(err) + } + implementations := []string{"on: (left, right, operator = '=') => Query.on(left, right, operator),"} + for _, join := range joins { + implementations = append(implementations, join+": (table, leftOrAliasOrOn, rightOrOn = '', operator = '=', alias = '') => Query."+join+"(table, leftOrAliasOrOn, rightOrOn, operator, alias),") + } + for _, want := range implementations { + if !strings.Contains(databases, want) { + t.Errorf("%s does not implement %q", TemplateDatabases, want) + } + } +} diff --git a/templates/dart/lib/package.dart.twig b/templates/dart/lib/package.dart.twig index 4965358cb1..e810b1a44d 100644 --- a/templates/dart/lib/package.dart.twig +++ b/templates/dart/lib/package.dart.twig @@ -27,6 +27,7 @@ export 'src/input_file.dart'; export 'src/upload_progress.dart'; part 'query.dart'; +part 'query/builder.dart'; part 'permission.dart'; part 'role.dart'; part 'id.dart'; diff --git a/templates/dart/lib/query.dart.twig b/templates/dart/lib/query.dart.twig index 09c0822d8f..59d72be585 100644 --- a/templates/dart/lib/query.dart.twig +++ b/templates/dart/lib/query.dart.twig @@ -317,4 +317,222 @@ class Query { /// Filter resources where [attribute] does not touch the given geometry. static String notTouches(String attribute, List values) => Query._('notTouches', attribute, [values]).toString(); + + /// Count documents, optionally aliased. + static String count([String attribute = '*', String alias = '']) => + Query._('count', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Count distinct values of [attribute], optionally aliased. + static String countDistinct(String attribute, [String alias = '']) => + Query._('countDistinct', attribute, alias.isNotEmpty ? alias : null) + .toString(); + + /// Sum values of [attribute], optionally aliased. + static String sum(String attribute, [String alias = '']) => + Query._('sum', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Average values of [attribute], optionally aliased. + static String avg(String attribute, [String alias = '']) => + Query._('avg', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Minimum value of [attribute], optionally aliased. + static String min(String attribute, [String alias = '']) => + Query._('min', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Maximum value of [attribute], optionally aliased. + static String max(String attribute, [String alias = '']) => + Query._('max', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Standard deviation of [attribute], optionally aliased. + static String stddev(String attribute, [String alias = '']) => + Query._('stddev', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Population standard deviation of [attribute], optionally aliased. + static String stddevPop(String attribute, [String alias = '']) => + Query._('stddevPop', attribute, alias.isNotEmpty ? alias : null) + .toString(); + + /// Sample standard deviation of [attribute], optionally aliased. + static String stddevSamp(String attribute, [String alias = '']) => + Query._('stddevSamp', attribute, alias.isNotEmpty ? alias : null) + .toString(); + + /// Variance of [attribute], optionally aliased. + static String variance(String attribute, [String alias = '']) => + Query._('variance', attribute, alias.isNotEmpty ? alias : null) + .toString(); + + /// Population variance of [attribute], optionally aliased. + static String varPop(String attribute, [String alias = '']) => + Query._('varPop', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Sample variance of [attribute], optionally aliased. + static String varSamp(String attribute, [String alias = '']) => + Query._('varSamp', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Bitwise AND of [attribute], optionally aliased. + static String bitAnd(String attribute, [String alias = '']) => + Query._('bitAnd', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Bitwise OR of [attribute], optionally aliased. + static String bitOr(String attribute, [String alias = '']) => + Query._('bitOr', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Bitwise XOR of [attribute], optionally aliased. + static String bitXor(String attribute, [String alias = '']) => + Query._('bitXor', attribute, alias.isNotEmpty ? alias : null).toString(); + + /// Group results by [attributes]. + static String groupBy(List attributes) => + Query._('groupBy', null, attributes).toString(); + + /// Filter grouped results. [queries] are nested like [and]/[or]. + static String having(List queries) => Query._( + 'having', + null, + queries.map((query) => jsonDecode(query)).toList(), + ).toString(); + + /// Select distinct rows. + static String distinct() => Query._('distinct').toString(); + + /// Column-to-column join ON condition. + static String on(String left, String right, [String operator = '=']) => + Query._('on', null, [left, operator, right]).toString(); + + /// Inner join. [table] is the joined collection/table ID. + static String join( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + Query._createJoin('join', table, leftOrAliasOrOn, rightOrOn, operator, alias); + + /// Left join. [table] is the joined collection/table ID. + static String leftJoin( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + Query._createJoin('leftJoin', table, leftOrAliasOrOn, rightOrOn, operator, alias); + + /// Right join. [table] is the joined collection/table ID. + static String rightJoin( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + Query._createJoin('rightJoin', table, leftOrAliasOrOn, rightOrOn, operator, alias); + + /// Full outer join. [table] is the joined collection/table ID. + static String fullOuterJoin( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + Query._createJoin('fullOuterJoin', table, leftOrAliasOrOn, rightOrOn, operator, alias); + + /// Cross join. [table] is the joined collection/table ID. + static String crossJoin(String table, [String alias = '']) => + Query._('crossJoin', table, alias.isNotEmpty ? alias : null).toString(); + + /// Filter resources where [attribute] covers the given geometry. + static String covers(String attribute, List values) => + Query._('covers', attribute, [values]).toString(); + + /// Filter resources where [attribute] does not cover the given geometry. + static String notCovers(String attribute, List values) => + Query._('notCovers', attribute, [values]).toString(); + + /// Filter resources where [attribute] is spatially equal to the given geometry. + static String spatialEquals(String attribute, List values) => + Query._('spatialEquals', attribute, [values]).toString(); + + /// Filter resources where [attribute] is not spatially equal to the given geometry. + static String notSpatialEquals(String attribute, List values) => + Query._('notSpatialEquals', attribute, [values]).toString(); + + /// Return limit and offset queries for 1-based page pagination. + static List page(int page, [int perPage = 25]) { + if (page < 1) { + throw ArgumentError('Page must be >= 1, got $page'); + } + if (perPage < 1) { + throw ArgumentError('Per page must be >= 1, got $perPage'); + } + + return [ + Query.limit(perPage), + Query.offset((page - 1) * perPage), + ]; + } + + /// Start a fluent query builder. + static QueryBuilder builder() => QueryBuilder(); + + static String _createJoin( + String method, + String table, + Object leftOrAliasOrOn, + Object rightOrOn, + String operator, + String alias, + ) { + if (leftOrAliasOrOn is List) { + return Query._nestedJoin(method, table, '', leftOrAliasOrOn); + } + if (rightOrOn is List) { + return Query._nestedJoin(method, table, leftOrAliasOrOn.toString(), rightOrOn); + } + + return Query._joinQuery( + method, + table, + leftOrAliasOrOn.toString(), + rightOrOn.toString(), + operator, + alias, + ); + } + + static String _nestedJoin( + String method, + String table, + String alias, + List on, + ) { + final values = []; + if (alias.isNotEmpty) { + values.add(alias); + } + for (final query in on) { + values.add(jsonDecode(query.toString())); + } + + return Query._(method, table, values).toString(); + } + + static String _joinQuery( + String method, + String table, + String left, + String right, + String operator, + String alias, + ) { + final values = [left, operator, right]; + if (alias.isNotEmpty) { + values.add(alias); + } + + return Query._(method, table, values).toString(); + } } diff --git a/templates/dart/lib/query/builder.dart.twig b/templates/dart/lib/query/builder.dart.twig new file mode 100644 index 0000000000..df819f3b2f --- /dev/null +++ b/templates/dart/lib/query/builder.dart.twig @@ -0,0 +1,303 @@ +part of '../{{ language.params.packageName }}.dart'; + +/// Fluent collector of query strings. Call [build] to produce the list +/// expected by list methods. +class QueryBuilder { + final List _queries = []; + + List build() => List.from(_queries); + + QueryBuilder equal(String attribute, dynamic value) => + _push(Query.equal(attribute, value)); + + QueryBuilder notEqual(String attribute, dynamic value) => + _push(Query.notEqual(attribute, value)); + + QueryBuilder lessThan(String attribute, dynamic value) => + _push(Query.lessThan(attribute, value)); + + QueryBuilder lessThanEqual(String attribute, dynamic value) => + _push(Query.lessThanEqual(attribute, value)); + + QueryBuilder greaterThan(String attribute, dynamic value) => + _push(Query.greaterThan(attribute, value)); + + QueryBuilder greaterThanEqual(String attribute, dynamic value) => + _push(Query.greaterThanEqual(attribute, value)); + + QueryBuilder search(String attribute, String value) => + _push(Query.search(attribute, value)); + + QueryBuilder isNull(String attribute) => _push(Query.isNull(attribute)); + + QueryBuilder isNotNull(String attribute) => + _push(Query.isNotNull(attribute)); + + QueryBuilder between(String attribute, dynamic start, dynamic end) => + _push(Query.between(attribute, start, end)); + + QueryBuilder startsWith(String attribute, String value) => + _push(Query.startsWith(attribute, value)); + + QueryBuilder endsWith(String attribute, String value) => + _push(Query.endsWith(attribute, value)); + + QueryBuilder select(List attributes) => + _push(Query.select(attributes)); + + QueryBuilder cursorAfter(String documentId) => + _push(Query.cursorAfter(documentId)); + + QueryBuilder cursorBefore(String documentId) => + _push(Query.cursorBefore(documentId)); + + QueryBuilder orderAsc(String attribute) => + _push(Query.orderAsc(attribute)); + + QueryBuilder orderDesc(String attribute) => + _push(Query.orderDesc(attribute)); + + QueryBuilder orderRandom() => _push(Query.orderRandom()); + + QueryBuilder limit(int limit) => _push(Query.limit(limit)); + + QueryBuilder offset(int offset) => _push(Query.offset(offset)); + + QueryBuilder contains(String attribute, dynamic value) => + _push(Query.contains(attribute, value)); + + QueryBuilder containsAny(String attribute, List value) => + _push(Query.containsAny(attribute, value)); + + QueryBuilder containsAll(String attribute, List value) => + _push(Query.containsAll(attribute, value)); + + QueryBuilder notContains(String attribute, dynamic value) => + _push(Query.notContains(attribute, value)); + + QueryBuilder notSearch(String attribute, String value) => + _push(Query.notSearch(attribute, value)); + + QueryBuilder notBetween(String attribute, dynamic start, dynamic end) => + _push(Query.notBetween(attribute, start, end)); + + QueryBuilder notStartsWith(String attribute, String value) => + _push(Query.notStartsWith(attribute, value)); + + QueryBuilder notEndsWith(String attribute, String value) => + _push(Query.notEndsWith(attribute, value)); + + QueryBuilder regex(String attribute, String pattern) => + _push(Query.regex(attribute, pattern)); + + QueryBuilder exists(List attributes) => + _push(Query.exists(attributes)); + + QueryBuilder notExists(List attributes) => + _push(Query.notExists(attributes)); + + QueryBuilder createdBefore(String value) => + _push(Query.createdBefore(value)); + + QueryBuilder createdAfter(String value) => + _push(Query.createdAfter(value)); + + QueryBuilder createdBetween(String start, String end) => + _push(Query.createdBetween(start, end)); + + QueryBuilder updatedBefore(String value) => + _push(Query.updatedBefore(value)); + + QueryBuilder updatedAfter(String value) => + _push(Query.updatedAfter(value)); + + QueryBuilder updatedBetween(String start, String end) => + _push(Query.updatedBetween(start, end)); + + QueryBuilder or(List queries) => _push(Query.or(queries)); + + QueryBuilder and(List queries) => _push(Query.and(queries)); + + QueryBuilder elemMatch(String attribute, List queries) => + _push(Query.elemMatch(attribute, queries)); + + QueryBuilder distanceEqual( + String attribute, + List values, + num distance, [ + bool meters = true, + ]) => + _push(Query.distanceEqual(attribute, values, distance, meters)); + + QueryBuilder distanceNotEqual( + String attribute, + List values, + num distance, [ + bool meters = true, + ]) => + _push(Query.distanceNotEqual(attribute, values, distance, meters)); + + QueryBuilder distanceGreaterThan( + String attribute, + List values, + num distance, [ + bool meters = true, + ]) => + _push(Query.distanceGreaterThan(attribute, values, distance, meters)); + + QueryBuilder distanceLessThan( + String attribute, + List values, + num distance, [ + bool meters = true, + ]) => + _push(Query.distanceLessThan(attribute, values, distance, meters)); + + QueryBuilder intersects(String attribute, List values) => + _push(Query.intersects(attribute, values)); + + QueryBuilder notIntersects(String attribute, List values) => + _push(Query.notIntersects(attribute, values)); + + QueryBuilder crosses(String attribute, List values) => + _push(Query.crosses(attribute, values)); + + QueryBuilder notCrosses(String attribute, List values) => + _push(Query.notCrosses(attribute, values)); + + QueryBuilder overlaps(String attribute, List values) => + _push(Query.overlaps(attribute, values)); + + QueryBuilder notOverlaps(String attribute, List values) => + _push(Query.notOverlaps(attribute, values)); + + QueryBuilder touches(String attribute, List values) => + _push(Query.touches(attribute, values)); + + QueryBuilder notTouches(String attribute, List values) => + _push(Query.notTouches(attribute, values)); + + QueryBuilder count([String attribute = '*', String alias = '']) => + _push(Query.count(attribute, alias)); + + QueryBuilder countDistinct(String attribute, [String alias = '']) => + _push(Query.countDistinct(attribute, alias)); + + QueryBuilder sum(String attribute, [String alias = '']) => + _push(Query.sum(attribute, alias)); + + QueryBuilder avg(String attribute, [String alias = '']) => + _push(Query.avg(attribute, alias)); + + QueryBuilder min(String attribute, [String alias = '']) => + _push(Query.min(attribute, alias)); + + QueryBuilder max(String attribute, [String alias = '']) => + _push(Query.max(attribute, alias)); + + QueryBuilder stddev(String attribute, [String alias = '']) => + _push(Query.stddev(attribute, alias)); + + QueryBuilder stddevPop(String attribute, [String alias = '']) => + _push(Query.stddevPop(attribute, alias)); + + QueryBuilder stddevSamp(String attribute, [String alias = '']) => + _push(Query.stddevSamp(attribute, alias)); + + QueryBuilder variance(String attribute, [String alias = '']) => + _push(Query.variance(attribute, alias)); + + QueryBuilder varPop(String attribute, [String alias = '']) => + _push(Query.varPop(attribute, alias)); + + QueryBuilder varSamp(String attribute, [String alias = '']) => + _push(Query.varSamp(attribute, alias)); + + QueryBuilder bitAnd(String attribute, [String alias = '']) => + _push(Query.bitAnd(attribute, alias)); + + QueryBuilder bitOr(String attribute, [String alias = '']) => + _push(Query.bitOr(attribute, alias)); + + QueryBuilder bitXor(String attribute, [String alias = '']) => + _push(Query.bitXor(attribute, alias)); + + QueryBuilder groupBy(List attributes) => + _push(Query.groupBy(attributes)); + + QueryBuilder having(List queries) => _push(Query.having(queries)); + + QueryBuilder distinct() => _push(Query.distinct()); + + QueryBuilder join( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + _push(Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + + QueryBuilder leftJoin( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + _push(Query.leftJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + + QueryBuilder rightJoin( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + _push(Query.rightJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + + QueryBuilder fullOuterJoin( + String table, + Object leftOrAliasOrOn, [ + Object rightOrOn = '', + String operator = '=', + String alias = '', + ]) => + _push(Query.fullOuterJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + + QueryBuilder crossJoin(String table, [String alias = '']) => + _push(Query.crossJoin(table, alias)); + + QueryBuilder vectorDot(String attribute, List vector) => + _push(Query.vectorDot(attribute, vector)); + + QueryBuilder vectorCosine(String attribute, List vector) => + _push(Query.vectorCosine(attribute, vector)); + + QueryBuilder vectorEuclidean(String attribute, List vector) => + _push(Query.vectorEuclidean(attribute, vector)); + + QueryBuilder covers(String attribute, List values) => + _push(Query.covers(attribute, values)); + + QueryBuilder notCovers(String attribute, List values) => + _push(Query.notCovers(attribute, values)); + + QueryBuilder spatialEquals(String attribute, List values) => + _push(Query.spatialEquals(attribute, values)); + + QueryBuilder notSpatialEquals(String attribute, List values) => + _push(Query.notSpatialEquals(attribute, values)); + + QueryBuilder page(int page, [int perPage = 25]) => + _push(Query.page(page, perPage)); + + QueryBuilder _push(Object query) { + if (query is List) { + _queries.addAll(query.cast()); + } else { + _queries.add(query as String); + } + return this; + } +} diff --git a/templates/dart/lib/src/client.dart.twig b/templates/dart/lib/src/client.dart.twig index 3611b09dbe..1f6891b8e2 100644 --- a/templates/dart/lib/src/client.dart.twig +++ b/templates/dart/lib/src/client.dart.twig @@ -70,6 +70,9 @@ abstract class Client { HttpMethod method = HttpMethod.post, }); + /// Flatten builders and nested query-string lists for request params. + dynamic flatten(dynamic value); + /// Send the API request. Future call( HttpMethod method, { diff --git a/templates/dart/lib/src/client_mixin.dart.twig b/templates/dart/lib/src/client_mixin.dart.twig index bef5027181..3332200868 100644 --- a/templates/dart/lib/src/client_mixin.dart.twig +++ b/templates/dart/lib/src/client_mixin.dart.twig @@ -1,4 +1,5 @@ import 'package:http/http.dart' as http; +import '../{{ language.params.packageName }}.dart' show QueryBuilder; import 'exception.dart'; import 'response.dart'; import 'dart:convert'; @@ -6,12 +7,18 @@ import 'dart:developer'; import 'enums.dart'; mixin ClientMixin { + dynamic flatten(dynamic value) => _flattenValue(value); + http.BaseRequest prepareRequest( HttpMethod method, { required Uri uri, required Map headers, required Map params, }) { + params = { + for (final entry in params.entries) entry.key: _flattenValue(entry.value), + }; + http.BaseRequest request = http.Request(method.name(), uri); if (headers['content-type'] == 'multipart/form-data') { request = http.MultipartRequest(method.name(), uri); @@ -152,3 +159,16 @@ mixin ClientMixin { } } } + +dynamic _flattenValue(dynamic value) { + if (value is QueryBuilder) { + return value.build(); + } + if (value is! List || !value.any((item) => item is QueryBuilder)) { + return value; + } + return [ + for (final item in value) + if (item is QueryBuilder) ...item.build() else item, + ]; +} diff --git a/templates/dart/lib/src/models/model.dart.twig b/templates/dart/lib/src/models/model.dart.twig index 8b3a491b35..f706026481 100644 --- a/templates/dart/lib/src/models/model.dart.twig +++ b/templates/dart/lib/src/models/model.dart.twig @@ -54,7 +54,7 @@ class {{ definitionName | caseUcfirst | overrideIdentifier }} implements Model { {% if not (property | schemaRequired) %} ) : null{% else %} ){% endif %}{% endif %}{% elseif property | usesEnumType %}{% set enumName = (property | enumName) %}{% if property | arraySchema %}{% if not (property | schemaRequired) %}map['{{ propertyName | escapeDollarSign }}'] != null ? {% endif %}List.from( -{% if not (property | schemaRequired) %} {% else %} {% endif %}map['{{ propertyName | escapeDollarSign }}'].map( +{% if not (property | schemaRequired) %} {% else %} {% endif %}((map['{{ propertyName | escapeDollarSign }}'] is List) ? map['{{ propertyName | escapeDollarSign }}'] : [map['{{ propertyName | escapeDollarSign }}']]).map( {% if not (property | schemaRequired) %} {% else %} {% endif %}(p) =>{% if enumName|length > 16 %}{{ "\n" }}{% if not (property | schemaRequired) %} {% else %} {% endif %}{% else %} {% endif %}enums.{{ enumName | caseUcfirst }}.values.firstWhere((e) => e.value == p), {% if not (property | schemaRequired) %} {% else %} {% endif %}), {% if not (property | schemaRequired) %} ) diff --git a/templates/dart/lib/src/models/request_model.dart.twig b/templates/dart/lib/src/models/request_model.dart.twig index 58c94b3b05..00c30e1f38 100644 --- a/templates/dart/lib/src/models/request_model.dart.twig +++ b/templates/dart/lib/src/models/request_model.dart.twig @@ -25,7 +25,7 @@ class {{ requestModelName | caseUcfirst | overrideIdentifier }} implements Model Map toMap() { return { {% for property in requestModel.properties %} - "{{ (property | schemaName) | escapeDollarSign }}": {% if (property | schemaModel) %}{% if (property | schemaType) == 'array' %}{{(property | schemaName) | escapeKeyword}}{% if not (property | schemaRequired) %}?{% endif %}.map((p) => p.toMap()).toList(){% else %}{{(property | schemaName) | escapeKeyword}}{% if not (property | schemaRequired) %}?{% endif %}.toMap(){% endif %}{% elseif property | usesEnumType %}{{(property | schemaName) | escapeKeyword}}{% if not (property | schemaRequired) %}?{% endif %}.value{% else %}{{(property | schemaName) | escapeKeyword }}{% endif %}, + "{{ (property | schemaName) | escapeDollarSign }}": {% if (property | schemaModel) %}{% if (property | schemaType) == 'array' %}{{(property | schemaName) | escapeKeyword}}{% if not (property | schemaRequired) %}?{% endif %}.map((p) => p.toMap()).toList(){% else %}{{(property | schemaName) | escapeKeyword}}{% if not (property | schemaRequired) %}?{% endif %}.toMap(){% endif %}{% elseif property | usesEnumType %}{{(property | schemaName) | escapeKeyword}}{% if not (property | schemaRequired) %}?{% endif %}{% if (property | schemaType) == 'array' %}.map((p) => p.value).toList(){% else %}.value{% endif %}{% else %}{{(property | schemaName) | escapeKeyword }}{% endif %}, {% endfor %} }; } diff --git a/templates/dart/test/query_test.dart.twig b/templates/dart/test/query_test.dart.twig index efc571a579..4e9eafcf62 100644 --- a/templates/dart/test/query_test.dart.twig +++ b/templates/dart/test/query_test.dart.twig @@ -357,4 +357,288 @@ void main() { expect(query['values'], ['2023-01-01', '2023-12-31']); expect(query['method'], 'between'); }); + + test('returns count with alias', () { + final query = jsonDecode(Query.count('*', 'total')); + expect(query['method'], 'count'); + expect(query['attribute'], '*'); + expect(query['values'], ['total']); + }); + + test('returns count without alias', () { + final query = jsonDecode(Query.count()); + expect(query['method'], 'count'); + expect(query['attribute'], '*'); + expect(query.containsKey('values'), isFalse); + }); + + test('returns countDistinct', () { + final query = jsonDecode(Query.countDistinct('year', 'uniqueYears')); + expect(query['method'], 'countDistinct'); + expect(query['attribute'], 'year'); + expect(query['values'], ['uniqueYears']); + }); + + test('returns sum avg min max', () { + expect(jsonDecode(Query.sum('price', 'total'))['method'], 'sum'); + expect(jsonDecode(Query.avg('price', 'avgPrice'))['method'], 'avg'); + expect(jsonDecode(Query.min('price', 'lowest'))['method'], 'min'); + expect(jsonDecode(Query.max('price', 'highest'))['method'], 'max'); + }); + + test('returns stddev', () { + expect(jsonDecode(Query.stddev('price', 'sd'))['method'], 'stddev'); + }); + + test('returns stddevPop', () { + expect(jsonDecode(Query.stddevPop('price', 'sdp'))['method'], 'stddevPop'); + }); + + test('returns stddevSamp', () { + expect(jsonDecode(Query.stddevSamp('price', 'sds'))['method'], 'stddevSamp'); + }); + + test('returns variance', () { + expect(jsonDecode(Query.variance('price', 'var'))['method'], 'variance'); + }); + + test('returns varPop', () { + expect(jsonDecode(Query.varPop('price', 'vp'))['method'], 'varPop'); + }); + + test('returns varSamp', () { + expect(jsonDecode(Query.varSamp('price', 'vs'))['method'], 'varSamp'); + }); + + test('returns bitAnd', () { + expect(jsonDecode(Query.bitAnd('flags', 'band'))['method'], 'bitAnd'); + }); + + test('returns bitOr', () { + expect(jsonDecode(Query.bitOr('flags', 'bor'))['method'], 'bitOr'); + }); + + test('returns bitXor', () { + expect(jsonDecode(Query.bitXor('flags', 'bxor'))['method'], 'bitXor'); + }); + + test('returns groupBy', () { + final query = jsonDecode(Query.groupBy(['status', 'year'])); + expect(query['method'], 'groupBy'); + expect(query['values'], ['status', 'year']); + expect(query.containsKey('attribute'), isFalse); + }); + + test('returns having', () { + final query = jsonDecode(Query.having([Query.greaterThan('total', 1)])); + expect(query['method'], 'having'); + expect(query['values'][0]['method'], 'greaterThan'); + expect(query['values'][0]['attribute'], 'total'); + expect(query['values'][0]['values'], [1]); + }); + + test('returns distinct', () { + final query = jsonDecode(Query.distinct()); + expect(query['method'], 'distinct'); + expect(query.containsKey('attribute'), isFalse); + expect(query.containsKey('values'), isFalse); + }); + + test('returns join', () { + final query = jsonDecode(Query.join('orders', '\$id', 'customerId')); + expect(query['method'], 'join'); + expect(query['attribute'], 'orders'); + expect(query['values'], ['\$id', '=', 'customerId']); + }); + + test('returns leftJoin with alias', () { + final query = jsonDecode( + Query.leftJoin('orders', '\$id', 'customerId', '=', 'ord'), + ); + expect(query['method'], 'leftJoin'); + expect(query['attribute'], 'orders'); + expect(query['values'], ['\$id', '=', 'customerId', 'ord']); + }); + + test('returns rightJoin', () { + final query = jsonDecode(Query.rightJoin('orders', '\$id', 'customerId')); + expect(query['method'], 'rightJoin'); + expect(query['values'], ['\$id', '=', 'customerId']); + }); + + test('returns fullOuterJoin', () { + final query = jsonDecode(Query.fullOuterJoin('orders', '\$id', 'customerId')); + expect(query['method'], 'fullOuterJoin'); + expect(query['values'], ['\$id', '=', 'customerId']); + }); + + test('returns on', () { + final query = jsonDecode(Query.on('\$id', 'customerId')); + expect(query['method'], 'on'); + expect(query['values'], ['\$id', '=', 'customerId']); + }); + + test('returns nested leftJoin', () { + final query = jsonDecode(Query.leftJoin('orders', 'ord', [ + Query.on('\$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ])); + expect(query['method'], 'leftJoin'); + expect(query['attribute'], 'orders'); + expect(query['values'][0], 'ord'); + expect(query['values'][1]['method'], 'on'); + expect(query['values'][2]['method'], 'equal'); + }); + + test('returns crossJoin', () { + final query = jsonDecode(Query.crossJoin('orders', 'ord')); + expect(query['method'], 'crossJoin'); + expect(query['attribute'], 'orders'); + expect(query['values'], ['ord']); + }); + + test('returns vectorDot', () { + final query = jsonDecode(Query.vectorDot('embedding', [0.1, 0.2])); + expect(query['method'], 'vectorDot'); + expect(query['attribute'], 'embedding'); + expect(query['values'], [ + [0.1, 0.2] + ]); + }); + + test('returns covers', () { + final query = jsonDecode(Query.covers('location', [0, 0])); + expect(query['method'], 'covers'); + expect(query['values'], [ + [0, 0] + ]); + }); + + test('returns notCovers', () { + final query = jsonDecode(Query.notCovers('location', [1, 2])); + expect(query['method'], 'notCovers'); + expect(query['values'], [ + [1, 2] + ]); + }); + + test('returns spatialEquals', () { + final query = jsonDecode(Query.spatialEquals('location', [1, 2])); + expect(query['method'], 'spatialEquals'); + expect(query['values'], [ + [1, 2] + ]); + }); + + test('returns notSpatialEquals', () { + final query = jsonDecode(Query.notSpatialEquals('location', [1, 2])); + expect(query['method'], 'notSpatialEquals'); + expect(query['values'], [ + [1, 2] + ]); + }); + + test('returns page', () { + final queries = Query.page(2, 10); + expect(queries.length, 2); + final limit = jsonDecode(queries[0]); + final offset = jsonDecode(queries[1]); + expect(limit['method'], 'limit'); + expect(limit['values'], [10]); + expect(offset['method'], 'offset'); + expect(offset['values'], [10]); + }); + + test('page rejects invalid', () { + expect(() => Query.page(0, 10), throwsA(isA())); + expect(() => Query.page(1, 0), throwsA(isA())); + }); + + test('builder build', () { + final expected = [ + Query.equal('status', 'published'), + Query.leftJoin('orders', '\$id', 'customerId', '=', 'ord'), + Query.count('*', 'total'), + Query.groupBy(['status']), + Query.limit(25), + ]; + + final built = Query.builder() + .equal('status', 'published') + .leftJoin('orders', '\$id', 'customerId', '=', 'ord') + .count('*', 'total') + .groupBy(['status']) + .limit(25) + .build(); + + expect(built, expected); + }); +{% if language.name == 'Dart' %} + + test('flatten spreads wrapped builder', () { + final client = Client(); + expect(client.flatten([Query.builder().limit(1)]), [Query.limit(1)]); + }); + + test('flatten keeps nested numeric arrays', () { + final client = Client(); + final geometry = [ + [1, 2], + [3, 4], + ]; + expect(client.flatten(geometry), geometry); + }); + + test('flatten keeps query string list', () { + final client = Client(); + expect(client.flatten([Query.limit(1)]), [Query.limit(1)]); + }); + + test('flatten keeps nested string lists', () { + final client = Client(); + final value = [ + ['a', 'b'], + ]; + expect(client.flatten(value), [ + ['a', 'b'], + ]); + }); + + test('flatten expands builder value', () { + final client = Client(); + expect(client.flatten(Query.builder().limit(1).offset(2)), [ + Query.limit(1), + Query.offset(2), + ]); + }); + + test('flatten splices builders into query list', () { + final client = Client(); + expect( + client.flatten([ + Query.limit(1), + Query.builder().offset(2).orderAsc('name'), + ]), + [Query.limit(1), Query.offset(2), Query.orderAsc('name')], + ); + }); + + test('flatten does not call build on other objects', () { + final client = Client(); + final value = _Buildable(); + expect(client.flatten(value), same(value)); + expect(value.calls, 0); + }); +{% endif %} } +{% if language.name == 'Dart' %} + +class _Buildable { + int calls = 0; + + List build() { + calls++; + return ['built']; + } +} +{% endif %} diff --git a/templates/dart/test/src/models/model_test.dart.twig b/templates/dart/test/src/models/model_test.dart.twig index 325b4eebb2..99e5a196ad 100644 --- a/templates/dart/test/src/models/model_test.dart.twig +++ b/templates/dart/test/src/models/model_test.dart.twig @@ -1,7 +1,7 @@ {# A number without an example still needs a literal of its own type. #} {% macro number_example(property) %}{% set example = property | schemaExample %}{% if example is not empty %}{{ example }}{% elseif (property | schemaType) == 'number' %}1.0{% else %}1{% endif %}{% endmacro %} {% macro sub_schema(definitions, property, indent = ' ', closeIndent = ' ') %}{% if (property | schemaModel) %}{% if (property | schemaType) == 'array' %}List<{{ (property | schemaModel) | caseUcfirst | overrideIdentifier }}>{% else %}{% set modelDefinition = definitions[(property | schemaModel)] ?? null %}{% set requiredProperties = modelDefinition ? modelDefinition.properties | filter(p => p | schemaRequired) : [] %}{{ (property | schemaModel) | caseUcfirst | overrideIdentifier }}({% if requiredProperties|length == 0 %}){% else %}{{ "\n" -}} -{% for property in requiredProperties %}{{ indent }}{{ (property | schemaName) | escapeKeyword }}: {% if (property | schemaType) == 'array' %}[]{% elseif (property | schemaType) == 'object' and ((property | schemaModel) == 'prefs' or (property | schemaModel) == 'preferences') %}Preferences(data: {}){% elseif (property | schemaType) == 'object' and (property | schemaModel) %}{{ _self.sub_schema(definitions, property, indent ~ ' ', indent) }}{% elseif (property | enumValues) is not empty %}{{ (property | enumName) | caseUcfirst }}.{{ ((property | enumKeys)[0] ?? (property | enumValues)[0]) | caseEnumKey }}{% elseif (property | schemaType) == 'string' %}{{ (property | schemaExample) | dartString }}{% elseif (property | schemaType) == 'boolean' %}true{% else %}{{ _self.number_example(property) }}{% endif %}, +{% for property in requiredProperties %}{{ indent }}{{ (property | schemaName) | escapeKeyword }}: {% if (property | schemaType) == 'array' %}[]{% elseif (property | schemaType) == 'object' and ((property | schemaModel) == 'prefs' or (property | schemaModel) == 'preferences') %}Preferences(data: {}){% elseif (property | schemaType) == 'object' and (property | schemaModel) %}{{ _self.sub_schema(definitions, property, indent ~ ' ', indent) }}{% elseif (property | schemaType) == 'object' %}{}{% elseif (property | enumValues) is not empty %}{{ (property | enumName) | caseUcfirst }}.{{ ((property | enumKeys)[0] ?? (property | enumValues)[0]) | caseEnumKey }}{% elseif (property | schemaType) == 'string' %}{{ (property | schemaExample) | dartString }}{% elseif (property | schemaType) == 'boolean' %}true{% else %}{{ _self.number_example(property) }}{% endif %}, {% endfor %}{{ closeIndent }}){% endif %}{% endif %}{% else %}{% if (property | schemaType) == 'object' and property.additionalProperties %}Map{% else %}{{ property | typeName }}{% endif %}{% endif %}{% endmacro %} import 'package:{{ language.params.packageName }}/models.dart'; {% if (definition.properties | filter(p => (p | enumValues) is not empty and (p | schemaType) != 'array') | length > 0) or (definition.properties | filter(p => (p | schemaRequired) and (p | schemaModel) and definitions[(p | schemaModel)] and (definitions[(p | schemaModel)].properties | filter(sp => (sp | schemaRequired) and (sp | enumValues) is not empty and (sp | schemaType) != 'array') | length > 0)) | length > 0) %} diff --git a/templates/deno/mod.ts.twig b/templates/deno/mod.ts.twig index 54e59c3d65..4f72937454 100644 --- a/templates/deno/mod.ts.twig +++ b/templates/deno/mod.ts.twig @@ -1,5 +1,5 @@ import { Client } from "./src/client.ts"; -import { Query } from "./src/query.ts"; +import { Query, QueryBuilder, flattenParam } from "./src/query.ts"; import { Permission } from "./src/permission.ts"; import { Role } from "./src/role.ts"; import { ID } from "./src/id.ts"; @@ -16,6 +16,8 @@ import { {{enum.title | caseUcfirst}} } from "./src/enums/{{enum.title | caseKeb export { Client, Query, + QueryBuilder, + flattenParam, Permission, Role, ID, @@ -32,4 +34,4 @@ export { }; export type { Models } from "./src/models.d.ts"; -export type { QueryTypes, QueryTypesList } from "./src/query.ts"; +export type { QueryTypes, QueryTypesList, QueryInput } from "./src/query.ts"; diff --git a/templates/deno/src/client.ts.twig b/templates/deno/src/client.ts.twig index 0826612e09..d0a1bf3027 100644 --- a/templates/deno/src/client.ts.twig +++ b/templates/deno/src/client.ts.twig @@ -1,4 +1,5 @@ import { {{ spec.info.title | caseUcfirst}}Exception } from './exception.ts'; +import { flattenParam } from './query.ts'; export interface Payload { [key: string]: any; @@ -125,6 +126,10 @@ export class Client { } async call(method: string, path: string = "", headers: Payload = {}, params: Payload = {}, responseType: string = "json") { + for (const key of Object.keys(params)) { + params[key] = flattenParam(params[key]); + } + headers = {...this.headers, ...headers}; const url = new URL(this.endpoint + path); diff --git a/templates/deno/src/query.ts.twig b/templates/deno/src/query.ts.twig index e5d829dd4f..2d35a37d1c 100644 --- a/templates/deno/src/query.ts.twig +++ b/templates/deno/src/query.ts.twig @@ -445,4 +445,603 @@ export class Query { */ static notTouches = (attribute: string, values: any[]): string => new Query("notTouches", attribute, [values]).toString(); + + /** + * Count documents, optionally aliased. + */ + static count = (attribute: string = "*", alias: string = ""): string => + new Query("count", attribute, alias !== "" ? alias : undefined).toString(); + + /** + * Count distinct values of attribute, optionally aliased. + */ + static countDistinct = (attribute: string, alias: string = ""): string => + new Query("countDistinct", attribute, alias !== "" ? alias : undefined).toString(); + + static sum = (attribute: string, alias: string = ""): string => + new Query("sum", attribute, alias !== "" ? alias : undefined).toString(); + + static avg = (attribute: string, alias: string = ""): string => + new Query("avg", attribute, alias !== "" ? alias : undefined).toString(); + + static min = (attribute: string, alias: string = ""): string => + new Query("min", attribute, alias !== "" ? alias : undefined).toString(); + + static max = (attribute: string, alias: string = ""): string => + new Query("max", attribute, alias !== "" ? alias : undefined).toString(); + + static stddev = (attribute: string, alias: string = ""): string => + new Query("stddev", attribute, alias !== "" ? alias : undefined).toString(); + + static stddevPop = (attribute: string, alias: string = ""): string => + new Query("stddevPop", attribute, alias !== "" ? alias : undefined).toString(); + + static stddevSamp = (attribute: string, alias: string = ""): string => + new Query("stddevSamp", attribute, alias !== "" ? alias : undefined).toString(); + + static variance = (attribute: string, alias: string = ""): string => + new Query("variance", attribute, alias !== "" ? alias : undefined).toString(); + + static varPop = (attribute: string, alias: string = ""): string => + new Query("varPop", attribute, alias !== "" ? alias : undefined).toString(); + + static varSamp = (attribute: string, alias: string = ""): string => + new Query("varSamp", attribute, alias !== "" ? alias : undefined).toString(); + + static bitAnd = (attribute: string, alias: string = ""): string => + new Query("bitAnd", attribute, alias !== "" ? alias : undefined).toString(); + + static bitOr = (attribute: string, alias: string = ""): string => + new Query("bitOr", attribute, alias !== "" ? alias : undefined).toString(); + + static bitXor = (attribute: string, alias: string = ""): string => + new Query("bitXor", attribute, alias !== "" ? alias : undefined).toString(); + + /** + * Group results by attributes. + */ + static groupBy = (attributes: string[]): string => + new Query("groupBy", undefined, attributes).toString(); + + /** + * Filter grouped results. `queries` are nested like `and`/`or`. + */ + static having = (queries: string[]): string => + new Query( + "having", + undefined, + queries.map((query) => JSON.parse(query)) + ).toString(); + + static distinct = (): string => + new Query("distinct").toString(); + + /** + * Column-to-column join ON condition. + */ + static on = ( + left: string, + right: string, + operator: string = "=" + ): string => + new Query("on", undefined, [left, operator, right]).toString(); + + /** + * Inner join. `table` is the joined collection/table ID. + */ + static join = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = "", + operator: string = "=", + alias: string = "" + ): string => + Query.createJoin("join", table, leftOrAliasOrOn, rightOrOn, operator, alias); + + static leftJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = "", + operator: string = "=", + alias: string = "" + ): string => + Query.createJoin("leftJoin", table, leftOrAliasOrOn, rightOrOn, operator, alias); + + static rightJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = "", + operator: string = "=", + alias: string = "" + ): string => + Query.createJoin("rightJoin", table, leftOrAliasOrOn, rightOrOn, operator, alias); + + static fullOuterJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = "", + operator: string = "=", + alias: string = "" + ): string => + Query.createJoin("fullOuterJoin", table, leftOrAliasOrOn, rightOrOn, operator, alias); + + static crossJoin = (table: string, alias: string = ""): string => + new Query("crossJoin", table, alias !== "" ? alias : undefined).toString(); + + static covers = (attribute: string, values: any[]): string => + new Query("covers", attribute, [values]).toString(); + + static notCovers = (attribute: string, values: any[]): string => + new Query("notCovers", attribute, [values]).toString(); + + static spatialEquals = (attribute: string, values: any[]): string => + new Query("spatialEquals", attribute, [values]).toString(); + + static notSpatialEquals = (attribute: string, values: any[]): string => + new Query("notSpatialEquals", attribute, [values]).toString(); + + /** + * Return limit and offset queries for 1-based page pagination. + */ + static page = (page: number, perPage: number = 25): string[] => { + if (page < 1) { + throw new Error("Page must be >= 1, got " + page); + } + if (perPage < 1) { + throw new Error("Per page must be >= 1, got " + perPage); + } + return [Query.limit(perPage), Query.offset((page - 1) * perPage)]; + }; + + static builder = (): Query.Builder => new Query.Builder(); + + private static createJoin( + method: string, + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = "", + operator: string = "=", + alias: string = "" + ): string { + if (Array.isArray(leftOrAliasOrOn)) { + return Query.nestedJoin(method, table, "", leftOrAliasOrOn); + } + if (Array.isArray(rightOrOn)) { + return Query.nestedJoin(method, table, leftOrAliasOrOn, rightOrOn); + } + return new Query( + method, + table, + alias !== "" + ? [leftOrAliasOrOn, operator, rightOrOn, alias] + : [leftOrAliasOrOn, operator, rightOrOn] + ).toString(); + } + + private static nestedJoin( + method: string, + table: string, + alias: string, + on: string[] + ): string { + const values: any[] = []; + if (alias !== "") { + values.push(alias); + } + for (const query of on) { + values.push(JSON.parse(query)); + } + return new Query(method, table, values).toString(); + } +} + +export type QueryInput = + | string + | Query + | Query.Builder + | QueryInput[]; + +/** + * Fluent collector of Query fragments. Call {@link Query.Builder.build} to + * produce the `string[]` expected by list/createQuery methods. + */ +export namespace Query { + export class Builder { + private readonly queries: string[] = []; + + build(): string[] { + return [...this.queries]; + } + + equal(attribute: string, value: QueryTypes): this { + return this.push(Query.equal(attribute, value)); + } + + notEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.notEqual(attribute, value)); + } + + lessThan(attribute: string, value: QueryTypes): this { + return this.push(Query.lessThan(attribute, value)); + } + + lessThanEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.lessThanEqual(attribute, value)); + } + + greaterThan(attribute: string, value: QueryTypes): this { + return this.push(Query.greaterThan(attribute, value)); + } + + greaterThanEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.greaterThanEqual(attribute, value)); + } + + search(attribute: string, value: string): this { + return this.push(Query.search(attribute, value)); + } + + isNull(attribute: string): this { + return this.push(Query.isNull(attribute)); + } + + isNotNull(attribute: string): this { + return this.push(Query.isNotNull(attribute)); + } + + between(attribute: string, start: string | number, end: string | number): this { + return this.push(Query.between(attribute, start, end)); + } + + startsWith(attribute: string, value: string): this { + return this.push(Query.startsWith(attribute, value)); + } + + endsWith(attribute: string, value: string): this { + return this.push(Query.endsWith(attribute, value)); + } + + select(attributes: string[]): this { + return this.push(Query.select(attributes)); + } + + cursorAfter(documentId: string): this { + return this.push(Query.cursorAfter(documentId)); + } + + cursorBefore(documentId: string): this { + return this.push(Query.cursorBefore(documentId)); + } + + orderAsc(attribute: string): this { + return this.push(Query.orderAsc(attribute)); + } + + orderDesc(attribute: string): this { + return this.push(Query.orderDesc(attribute)); + } + + orderRandom(): this { + return this.push(Query.orderRandom()); + } + + limit(limit: number): this { + return this.push(Query.limit(limit)); + } + + offset(offset: number): this { + return this.push(Query.offset(offset)); + } + + contains(attribute: string, value: string | any[]): this { + return this.push(Query.contains(attribute, value)); + } + + containsAny(attribute: string, value: any[]): this { + return this.push(Query.containsAny(attribute, value)); + } + + containsAll(attribute: string, value: any[]): this { + return this.push(Query.containsAll(attribute, value)); + } + + notContains(attribute: string, value: string | any[]): this { + return this.push(Query.notContains(attribute, value)); + } + + notSearch(attribute: string, value: string): this { + return this.push(Query.notSearch(attribute, value)); + } + + notBetween(attribute: string, start: string | number, end: string | number): this { + return this.push(Query.notBetween(attribute, start, end)); + } + + notStartsWith(attribute: string, value: string): this { + return this.push(Query.notStartsWith(attribute, value)); + } + + notEndsWith(attribute: string, value: string): this { + return this.push(Query.notEndsWith(attribute, value)); + } + + regex(attribute: string, pattern: string): this { + return this.push(Query.regex(attribute, pattern)); + } + + exists(attributes: string[]): this { + return this.push(Query.exists(attributes)); + } + + notExists(attributes: string[]): this { + return this.push(Query.notExists(attributes)); + } + + createdBefore(value: string): this { + return this.push(Query.createdBefore(value)); + } + + createdAfter(value: string): this { + return this.push(Query.createdAfter(value)); + } + + createdBetween(start: string, end: string): this { + return this.push(Query.createdBetween(start, end)); + } + + updatedBefore(value: string): this { + return this.push(Query.updatedBefore(value)); + } + + updatedAfter(value: string): this { + return this.push(Query.updatedAfter(value)); + } + + updatedBetween(start: string, end: string): this { + return this.push(Query.updatedBetween(start, end)); + } + + or(queries: string[]): this { + return this.push(Query.or(queries)); + } + + and(queries: string[]): this { + return this.push(Query.and(queries)); + } + + elemMatch(attribute: string, queries: string[]): this { + return this.push(Query.elemMatch(attribute, queries)); + } + + distanceEqual(attribute: string, values: any[], distance: number, meters: boolean = true): this { + return this.push(Query.distanceEqual(attribute, values, distance, meters)); + } + + distanceNotEqual(attribute: string, values: any[], distance: number, meters: boolean = true): this { + return this.push(Query.distanceNotEqual(attribute, values, distance, meters)); + } + + distanceGreaterThan(attribute: string, values: any[], distance: number, meters: boolean = true): this { + return this.push(Query.distanceGreaterThan(attribute, values, distance, meters)); + } + + distanceLessThan(attribute: string, values: any[], distance: number, meters: boolean = true): this { + return this.push(Query.distanceLessThan(attribute, values, distance, meters)); + } + + intersects(attribute: string, values: any[]): this { + return this.push(Query.intersects(attribute, values)); + } + + notIntersects(attribute: string, values: any[]): this { + return this.push(Query.notIntersects(attribute, values)); + } + + crosses(attribute: string, values: any[]): this { + return this.push(Query.crosses(attribute, values)); + } + + notCrosses(attribute: string, values: any[]): this { + return this.push(Query.notCrosses(attribute, values)); + } + + overlaps(attribute: string, values: any[]): this { + return this.push(Query.overlaps(attribute, values)); + } + + notOverlaps(attribute: string, values: any[]): this { + return this.push(Query.notOverlaps(attribute, values)); + } + + touches(attribute: string, values: any[]): this { + return this.push(Query.touches(attribute, values)); + } + + notTouches(attribute: string, values: any[]): this { + return this.push(Query.notTouches(attribute, values)); + } + + count(attribute: string = "*", alias: string = ""): this { + return this.push(Query.count(attribute, alias)); + } + + countDistinct(attribute: string, alias: string = ""): this { + return this.push(Query.countDistinct(attribute, alias)); + } + + sum(attribute: string, alias: string = ""): this { + return this.push(Query.sum(attribute, alias)); + } + + avg(attribute: string, alias: string = ""): this { + return this.push(Query.avg(attribute, alias)); + } + + min(attribute: string, alias: string = ""): this { + return this.push(Query.min(attribute, alias)); + } + + max(attribute: string, alias: string = ""): this { + return this.push(Query.max(attribute, alias)); + } + + stddev(attribute: string, alias: string = ""): this { + return this.push(Query.stddev(attribute, alias)); + } + + stddevPop(attribute: string, alias: string = ""): this { + return this.push(Query.stddevPop(attribute, alias)); + } + + stddevSamp(attribute: string, alias: string = ""): this { + return this.push(Query.stddevSamp(attribute, alias)); + } + + variance(attribute: string, alias: string = ""): this { + return this.push(Query.variance(attribute, alias)); + } + + varPop(attribute: string, alias: string = ""): this { + return this.push(Query.varPop(attribute, alias)); + } + + varSamp(attribute: string, alias: string = ""): this { + return this.push(Query.varSamp(attribute, alias)); + } + + bitAnd(attribute: string, alias: string = ""): this { + return this.push(Query.bitAnd(attribute, alias)); + } + + bitOr(attribute: string, alias: string = ""): this { + return this.push(Query.bitOr(attribute, alias)); + } + + bitXor(attribute: string, alias: string = ""): this { + return this.push(Query.bitXor(attribute, alias)); + } + + groupBy(attributes: string[]): this { + return this.push(Query.groupBy(attributes)); + } + + having(queries: string[]): this { + return this.push(Query.having(queries)); + } + + distinct(): this { + return this.push(Query.distinct()); + } + + join(table: string, leftOrAliasOrOn: string | string[], rightOrOn: string | string[] = "", operator: string = "=", alias: string = ""): this { + return this.push(Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + } + + leftJoin(table: string, leftOrAliasOrOn: string | string[], rightOrOn: string | string[] = "", operator: string = "=", alias: string = ""): this { + return this.push(Query.leftJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + } + + rightJoin(table: string, leftOrAliasOrOn: string | string[], rightOrOn: string | string[] = "", operator: string = "=", alias: string = ""): this { + return this.push(Query.rightJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + } + + fullOuterJoin(table: string, leftOrAliasOrOn: string | string[], rightOrOn: string | string[] = "", operator: string = "=", alias: string = ""): this { + return this.push(Query.fullOuterJoin(table, leftOrAliasOrOn, rightOrOn, operator, alias)); + } + + crossJoin(table: string, alias: string = ""): this { + return this.push(Query.crossJoin(table, alias)); + } + + vectorDot(attribute: string, vector: number[]): this { + return this.push(Query.vectorDot(attribute, vector)); + } + + vectorCosine(attribute: string, vector: number[]): this { + return this.push(Query.vectorCosine(attribute, vector)); + } + + vectorEuclidean(attribute: string, vector: number[]): this { + return this.push(Query.vectorEuclidean(attribute, vector)); + } + + covers(attribute: string, values: any[]): this { + return this.push(Query.covers(attribute, values)); + } + + notCovers(attribute: string, values: any[]): this { + return this.push(Query.notCovers(attribute, values)); + } + + spatialEquals(attribute: string, values: any[]): this { + return this.push(Query.spatialEquals(attribute, values)); + } + + notSpatialEquals(attribute: string, values: any[]): this { + return this.push(Query.notSpatialEquals(attribute, values)); + } + + page(page: number, perPage: number = 25): this { + return this.push(Query.page(page, perPage)); + } + + private push(query: string | string[]): this { + if (Array.isArray(query)) { + this.queries.push(...query); + } else { + this.queries.push(query); + } + return this; + } +} } + +export function normalizeQueries(queries?: QueryInput | null): string[] { + if (queries == null) { + return []; + } + if (queries instanceof Query.Builder) { + return queries.build(); + } + if (!Array.isArray(queries)) { + if (queries instanceof Query) { + return [queries.toString()]; + } + if (typeof queries === "string") { + return [queries]; + } + return []; + } + + const out: string[] = []; + for (const item of queries) { + out.push(...normalizeQueries(item)); + } + return out; +} + +function isQueryValue(value: unknown): value is Query | Query.Builder { + return value instanceof Query.Builder || value instanceof Query; +} + +export function flattenParam(value: unknown): unknown { + if (isQueryValue(value)) { + return normalizeQueries(value); + } + if (!Array.isArray(value) || !value.some(isQueryValue)) { + return value; + } + + const out: unknown[] = []; + for (const item of value) { + if (isQueryValue(item)) { + out.push(...normalizeQueries(item)); + } else { + out.push(item); + } + } + return out; +} + +export const QueryBuilder = Query.Builder; +export type QueryBuilder = Query.Builder; diff --git a/templates/deno/test/query.test.ts.twig b/templates/deno/test/query.test.ts.twig index f22f314b68..3f221dec7a 100644 --- a/templates/deno/test/query.test.ts.twig +++ b/templates/deno/test/query.test.ts.twig @@ -1,43 +1,45 @@ import {describe, it as test} from "https://deno.land/std@0.149.0/testing/bdd.ts"; import {assertEquals} from "https://deno.land/std@0.204.0/assert/assert_equals.ts"; -import {Query, QueryTypes} from "../src/query.ts"; +import {Query, QueryTypes, flattenParam, normalizeQueries} from "../src/query.ts"; type BasicFilterQueryTest = { description: string; value: QueryTypes; - expectedValues: string; + expectedValues: unknown[]; } +const parsed = (query: string) => JSON.parse(query); + const tests: BasicFilterQueryTest[] = [ { description: 'with a string', value: 's', - expectedValues: '["s"]' + expectedValues: ['s'] }, { description: 'with a integer', value: 1, - expectedValues: '[1]' + expectedValues: [1] }, { description: 'with a double', value: 1.2, - expectedValues: '[1.2]' + expectedValues: [1.2] }, { description: 'with a whole number double', value: 1.0, - expectedValues: '[1]' + expectedValues: [1] }, { description: 'with a bool', value: false, - expectedValues: '[false]' + expectedValues: [false] }, { description: 'with a list', value: ['a', 'b', 'c'], - expectedValues: '["a","b","c"]' + expectedValues: ['a', 'b', 'c'] } ]; @@ -46,8 +48,12 @@ describe('Query', () => { for (const t of tests) { test(t.description, () => assertEquals( - Query.equal("attr", t.value).toString(), - `{"method":"equal","attribute":"attr","values":${t.expectedValues}}`, + parsed(Query.equal("attr", t.value).toString()), + { + method: 'equal', + attribute: 'attr', + values: t.expectedValues, + }, ) ) } @@ -57,8 +63,12 @@ describe('Query', () => { for (const t of tests) { test(t.description, () => assertEquals( - Query.notEqual("attr", t.value).toString(), - `{"method":"notEqual","attribute":"attr","values":${t.expectedValues}}`, + parsed(Query.notEqual("attr", t.value).toString()), + { + method: 'notEqual', + attribute: 'attr', + values: t.expectedValues, + }, ) ) } @@ -68,8 +78,12 @@ describe('Query', () => { for (const t of tests) { test(t.description, () => assertEquals( - Query.lessThan("attr", t.value).toString(), - `{"method":"lessThan","attribute":"attr","values":${t.expectedValues}}`, + parsed(Query.lessThan("attr", t.value).toString()), + { + method: 'lessThan', + attribute: 'attr', + values: t.expectedValues, + }, ) ) } @@ -79,8 +93,12 @@ describe('Query', () => { for (const t of tests) { test(t.description, () => assertEquals( - Query.lessThanEqual("attr", t.value).toString(), - `{"method":"lessThanEqual","attribute":"attr","values":${t.expectedValues}}`, + parsed(Query.lessThanEqual("attr", t.value).toString()), + { + method: 'lessThanEqual', + attribute: 'attr', + values: t.expectedValues, + }, ) ) } @@ -90,8 +108,12 @@ describe('Query', () => { for (const t of tests) { test(t.description, () => assertEquals( - Query.greaterThan("attr", t.value).toString(), - `{"method":"greaterThan","attribute":"attr","values":${t.expectedValues}}`, + parsed(Query.greaterThan("attr", t.value).toString()), + { + method: 'greaterThan', + attribute: 'attr', + values: t.expectedValues, + }, ) ) } @@ -101,131 +123,218 @@ describe('Query', () => { for (const t of tests) { test(t.description, () => assertEquals( - Query.greaterThanEqual("attr", t.value).toString(), - `{"method":"greaterThanEqual","attribute":"attr","values":${t.expectedValues}}`, + parsed(Query.greaterThanEqual("attr", t.value).toString()), + { + method: 'greaterThanEqual', + attribute: 'attr', + values: t.expectedValues, + }, ) ) } }); test('search', () => assertEquals( - Query.search('attr', 'keyword1 keyword2').toString(), - '{"method":"search","attribute":"attr","values":["keyword1 keyword2"]}', + parsed(Query.search('attr', 'keyword1 keyword2').toString()), + { + method: 'search', + attribute: 'attr', + values: ['keyword1 keyword2'], + }, )); test('isNull', () => assertEquals( - Query.isNull('attr').toString(), - `{"method":"isNull","attribute":"attr"}`, + parsed(Query.isNull('attr').toString()), + { + method: 'isNull', + attribute: 'attr', + }, )); test('isNotNull', () => assertEquals( - Query.isNotNull('attr').toString(), - `{"method":"isNotNull","attribute":"attr"}`, + parsed(Query.isNotNull('attr').toString()), + { + method: 'isNotNull', + attribute: 'attr', + }, )); describe('between', () => { test('with integers', () => assertEquals( - Query.between('attr', 1, 2).toString(), - `{"method":"between","attribute":"attr","values":[1,2]}`, + parsed(Query.between('attr', 1, 2).toString()), + { + method: 'between', + attribute: 'attr', + values: [1, 2], + }, )); test('with doubles', () => assertEquals( - Query.between('attr', 1.2, 2.2).toString(), - `{"method":"between","attribute":"attr","values":[1.2,2.2]}`, + parsed(Query.between('attr', 1.2, 2.2).toString()), + { + method: 'between', + attribute: 'attr', + values: [1.2, 2.2], + }, )); test('with strings', () => assertEquals( - Query.between('attr', "a", "z").toString(), - `{"method":"between","attribute":"attr","values":["a","z"]}`, + parsed(Query.between('attr', "a", "z").toString()), + { + method: 'between', + attribute: 'attr', + values: ['a', 'z'], + }, )); }); test('select', () => assertEquals( - Query.select(['attr1', 'attr2']).toString(), - `{"method":"select","values":["attr1","attr2"]}`, + parsed(Query.select(['attr1', 'attr2']).toString()), + { + method: 'select', + values: ['attr1', 'attr2'], + }, )); test('orderAsc', () => assertEquals( - Query.orderAsc('attr').toString(), - `{"method":"orderAsc","attribute":"attr"}`, + parsed(Query.orderAsc('attr').toString()), + { + method: 'orderAsc', + attribute: 'attr', + }, )); test('orderDesc', () => assertEquals( - Query.orderDesc('attr').toString(), - `{"method":"orderDesc","attribute":"attr"}`, + parsed(Query.orderDesc('attr').toString()), + { + method: 'orderDesc', + attribute: 'attr', + }, )); test('orderRandom', () => assertEquals( - Query.orderRandom().toString(), - `{"method":"orderRandom"}`, + parsed(Query.orderRandom().toString()), + { + method: 'orderRandom', + }, )); test('cursorBefore', () => assertEquals( - Query.cursorBefore('attr').toString(), - `{"method":"cursorBefore","values":["attr"]}`, + parsed(Query.cursorBefore('attr').toString()), + { + method: 'cursorBefore', + values: ['attr'], + }, )); test('cursorAfter', () => assertEquals( - Query.cursorAfter('attr').toString(), - `{"method":"cursorAfter","values":["attr"]}`, + parsed(Query.cursorAfter('attr').toString()), + { + method: 'cursorAfter', + values: ['attr'], + }, )); test('limit', () => assertEquals( - Query.limit(1).toString(), - `{"method":"limit","values":[1]}`, + parsed(Query.limit(1).toString()), + { + method: 'limit', + values: [1], + }, )); test('offset', () => assertEquals( - Query.offset(1).toString(), - `{"method":"offset","values":[1]}`, + parsed(Query.offset(1).toString()), + { + method: 'offset', + values: [1], + }, )); test('notContains', () => assertEquals( - Query.notContains('attr', 'value').toString(), - `{"method":"notContains","attribute":"attr","values":["value"]}`, + parsed(Query.notContains('attr', 'value').toString()), + { + method: 'notContains', + attribute: 'attr', + values: ['value'], + }, )); test('notSearch', () => assertEquals( - Query.notSearch('attr', 'keyword1 keyword2').toString(), - '{"method":"notSearch","attribute":"attr","values":["keyword1 keyword2"]}', + parsed(Query.notSearch('attr', 'keyword1 keyword2').toString()), + { + method: 'notSearch', + attribute: 'attr', + values: ['keyword1 keyword2'], + }, )); describe('notBetween', () => { test('with integers', () => assertEquals( - Query.notBetween('attr', 1, 2).toString(), - `{"method":"notBetween","attribute":"attr","values":[1,2]}`, + parsed(Query.notBetween('attr', 1, 2).toString()), + { + method: 'notBetween', + attribute: 'attr', + values: [1, 2], + }, )); test('with doubles', () => assertEquals( - Query.notBetween('attr', 1.2, 2.2).toString(), - `{"method":"notBetween","attribute":"attr","values":[1.2,2.2]}`, + parsed(Query.notBetween('attr', 1.2, 2.2).toString()), + { + method: 'notBetween', + attribute: 'attr', + values: [1.2, 2.2], + }, )); test('with strings', () => assertEquals( - Query.notBetween('attr', "a", "z").toString(), - `{"method":"notBetween","attribute":"attr","values":["a","z"]}`, + parsed(Query.notBetween('attr', "a", "z").toString()), + { + method: 'notBetween', + attribute: 'attr', + values: ['a', 'z'], + }, )); }); test('notStartsWith', () => assertEquals( - Query.notStartsWith('attr', 'prefix').toString(), - `{"method":"notStartsWith","attribute":"attr","values":["prefix"]}`, + parsed(Query.notStartsWith('attr', 'prefix').toString()), + { + method: 'notStartsWith', + attribute: 'attr', + values: ['prefix'], + }, )); test('notEndsWith', () => assertEquals( - Query.notEndsWith('attr', 'suffix').toString(), - `{"method":"notEndsWith","attribute":"attr","values":["suffix"]}`, + parsed(Query.notEndsWith('attr', 'suffix').toString()), + { + method: 'notEndsWith', + attribute: 'attr', + values: ['suffix'], + }, )); test('regex', () => assertEquals( - Query.regex('attr', 'pattern.*').toString(), - `{"method":"regex","attribute":"attr","values":["pattern.*"]}`, + parsed(Query.regex('attr', 'pattern.*').toString()), + { + method: 'regex', + attribute: 'attr', + values: ['pattern.*'], + }, )); test('exists', () => assertEquals( - Query.exists(['attr1', 'attr2']).toString(), - `{"method":"exists","values":["attr1","attr2"]}`, + parsed(Query.exists(['attr1', 'attr2']).toString()), + { + method: 'exists', + values: ['attr1', 'attr2'], + }, )); test('notExists', () => assertEquals( - Query.notExists(['attr1', 'attr2']).toString(), - `{"method":"notExists","values":["attr1","attr2"]}`, + parsed(Query.notExists(['attr1', 'attr2']).toString()), + { + method: 'notExists', + values: ['attr1', 'attr2'], + }, )); test('elemMatch', () => { @@ -246,32 +355,396 @@ describe('Query', () => { }); test('createdBefore', () => assertEquals( - Query.createdBefore('2023-01-01').toString(), - `{"method":"lessThan","attribute":"$createdAt","values":["2023-01-01"]}`, + parsed(Query.createdBefore('2023-01-01').toString()), + { + method: 'lessThan', + attribute: '$createdAt', + values: ['2023-01-01'], + }, )); test('createdAfter', () => assertEquals( - Query.createdAfter('2023-01-01').toString(), - `{"method":"greaterThan","attribute":"$createdAt","values":["2023-01-01"]}`, + parsed(Query.createdAfter('2023-01-01').toString()), + { + method: 'greaterThan', + attribute: '$createdAt', + values: ['2023-01-01'], + }, )); test('createdBetween', () => assertEquals( - Query.createdBetween('2023-01-01', '2023-12-31').toString(), - `{"method":"between","attribute":"$createdAt","values":["2023-01-01","2023-12-31"]}`, + parsed(Query.createdBetween('2023-01-01', '2023-12-31').toString()), + { + method: 'between', + attribute: '$createdAt', + values: ['2023-01-01', '2023-12-31'], + }, )); test('updatedBefore', () => assertEquals( - Query.updatedBefore('2023-01-01').toString(), - `{"method":"lessThan","attribute":"$updatedAt","values":["2023-01-01"]}`, + parsed(Query.updatedBefore('2023-01-01').toString()), + { + method: 'lessThan', + attribute: '$updatedAt', + values: ['2023-01-01'], + }, )); test('updatedAfter', () => assertEquals( - Query.updatedAfter('2023-01-01').toString(), - `{"method":"greaterThan","attribute":"$updatedAt","values":["2023-01-01"]}`, + parsed(Query.updatedAfter('2023-01-01').toString()), + { + method: 'greaterThan', + attribute: '$updatedAt', + values: ['2023-01-01'], + }, )); test('updatedBetween', () => assertEquals( - Query.updatedBetween('2023-01-01', '2023-12-31').toString(), - `{"method":"between","attribute":"$updatedAt","values":["2023-01-01","2023-12-31"]}`, + parsed(Query.updatedBetween('2023-01-01', '2023-12-31').toString()), + { + method: 'between', + attribute: '$updatedAt', + values: ['2023-01-01', '2023-12-31'], + }, + )); + + test('count with alias', () => assertEquals( + parsed(Query.count('*', 'total').toString()), + { + method: 'count', + attribute: '*', + values: ['total'], + }, + )); + + test('count without alias', () => assertEquals( + parsed(Query.count().toString()), + { + method: 'count', + attribute: '*', + }, + )); + + test('countDistinct', () => assertEquals( + parsed(Query.countDistinct('year', 'uniqueYears').toString()), + { + method: 'countDistinct', + attribute: 'year', + values: ['uniqueYears'], + }, + )); + + test('sum', () => assertEquals( + parsed(Query.sum('price', 'total').toString()), + { + method: 'sum', + attribute: 'price', + values: ['total'], + }, + )); + + test('avg', () => assertEquals( + parsed(Query.avg('price', 'avgPrice').toString()), + { + method: 'avg', + attribute: 'price', + values: ['avgPrice'], + }, + )); + + test('min', () => assertEquals( + parsed(Query.min('price', 'lowest').toString()), + { + method: 'min', + attribute: 'price', + values: ['lowest'], + }, + )); + + test('max', () => assertEquals( + parsed(Query.max('price', 'highest').toString()), + { + method: 'max', + attribute: 'price', + values: ['highest'], + }, + )); + + test('stddev', () => assertEquals( + parsed(Query.stddev('price', 'sd').toString()), + { + method: 'stddev', + attribute: 'price', + values: ['sd'], + }, + )); + + test('stddevPop', () => assertEquals( + parsed(Query.stddevPop('price', 'sdp').toString()), + { + method: 'stddevPop', + attribute: 'price', + values: ['sdp'], + }, + )); + + test('stddevSamp', () => assertEquals( + parsed(Query.stddevSamp('price', 'sds').toString()), + { + method: 'stddevSamp', + attribute: 'price', + values: ['sds'], + }, + )); + + test('variance', () => assertEquals( + parsed(Query.variance('price', 'var').toString()), + { + method: 'variance', + attribute: 'price', + values: ['var'], + }, + )); + + test('varPop', () => assertEquals( + parsed(Query.varPop('price', 'vp').toString()), + { + method: 'varPop', + attribute: 'price', + values: ['vp'], + }, + )); + + test('varSamp', () => assertEquals( + parsed(Query.varSamp('price', 'vs').toString()), + { + method: 'varSamp', + attribute: 'price', + values: ['vs'], + }, + )); + + test('bitAnd', () => assertEquals( + parsed(Query.bitAnd('flags', 'band').toString()), + { + method: 'bitAnd', + attribute: 'flags', + values: ['band'], + }, + )); + + test('bitOr', () => assertEquals( + parsed(Query.bitOr('flags', 'bor').toString()), + { + method: 'bitOr', + attribute: 'flags', + values: ['bor'], + }, + )); + + test('bitXor', () => assertEquals( + parsed(Query.bitXor('flags', 'bxor').toString()), + { + method: 'bitXor', + attribute: 'flags', + values: ['bxor'], + }, + )); + + test('groupBy', () => assertEquals( + parsed(Query.groupBy(['status', 'year']).toString()), + { + method: 'groupBy', + values: ['status', 'year'], + }, + )); + + test('having nests parsed queries', () => { + const having = JSON.parse(Query.having([Query.greaterThan('total', 1)])); + assertEquals(having.method, 'having'); + assertEquals(having.values[0].method, 'greaterThan'); + assertEquals(having.values[0].attribute, 'total'); + assertEquals(having.values[0].values, [1]); + }); + + test('distinct', () => assertEquals( + parsed(Query.distinct().toString()), + { + method: 'distinct', + }, + )); + + test('join', () => assertEquals( + parsed(Query.join('orders', '$id', 'customerId').toString()), + { + method: 'join', + attribute: 'orders', + values: ['$id', '=', 'customerId'], + }, )); + + test('leftJoin with alias', () => assertEquals( + parsed(Query.leftJoin('orders', '$id', 'customerId', '=', 'ord').toString()), + { + method: 'leftJoin', + attribute: 'orders', + values: ['$id', '=', 'customerId', 'ord'], + }, + )); + + test('rightJoin', () => assertEquals( + parsed(Query.rightJoin('orders', '$id', 'customerId').toString()), + { + method: 'rightJoin', + attribute: 'orders', + values: ['$id', '=', 'customerId'], + }, + )); + + test('fullOuterJoin', () => assertEquals( + parsed(Query.fullOuterJoin('orders', '$id', 'customerId').toString()), + { + method: 'fullOuterJoin', + attribute: 'orders', + values: ['$id', '=', 'customerId'], + }, + )); + + test('on', () => assertEquals( + parsed(Query.on('$id', 'customerId').toString()), + { + method: 'on', + values: ['$id', '=', 'customerId'], + }, + )); + + test('nested leftJoin', () => { + const query = JSON.parse(Query.leftJoin('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ]).toString()); + assertEquals(query.method, 'leftJoin'); + assertEquals(query.attribute, 'orders'); + assertEquals(query.values[0], 'ord'); + assertEquals(query.values[1].method, 'on'); + assertEquals(query.values[2].method, 'equal'); + }); + + test('crossJoin', () => assertEquals( + parsed(Query.crossJoin('orders', 'ord').toString()), + { + method: 'crossJoin', + attribute: 'orders', + values: ['ord'], + }, + )); + + test('vectorDot', () => assertEquals( + parsed(Query.vectorDot('embedding', [0.1, 0.2]).toString()), + { + method: 'vectorDot', + attribute: 'embedding', + values: [[0.1, 0.2]], + }, + )); + + test('notCovers', () => assertEquals( + parsed(Query.notCovers('location', [1, 2]).toString()), + { + method: 'notCovers', + attribute: 'location', + values: [[1, 2]], + }, + )); + + test('spatialEquals', () => assertEquals( + parsed(Query.spatialEquals('location', [1, 2]).toString()), + { + method: 'spatialEquals', + attribute: 'location', + values: [[1, 2]], + }, + )); + + test('notSpatialEquals', () => assertEquals( + parsed(Query.notSpatialEquals('location', [1, 2]).toString()), + { + method: 'notSpatialEquals', + attribute: 'location', + values: [[1, 2]], + }, + )); + + test('page', () => { + const queries = Query.page(2, 10).map(parsed); + assertEquals(queries, [ + { method: 'limit', values: [10] }, + { method: 'offset', values: [10] }, + ]); + }); + + test('builder build', () => { + const built = Query.builder() + .equal('status', 'published') + .leftJoin('orders', '$id', 'customerId', '=', 'ord') + .count('*', 'total') + .groupBy(['status']) + .limit(25) + .build(); + + assertEquals(built, [ + Query.equal('status', 'published'), + Query.leftJoin('orders', '$id', 'customerId', '=', 'ord'), + Query.count('*', 'total'), + Query.groupBy(['status']), + Query.limit(25), + ]); + }); + + test('normalize builder wrapped in array', () => { + const builder = Query.builder().equal('status', 'active').limit(10); + assertEquals(normalizeQueries([builder]), builder.build()); + assertEquals(normalizeQueries([builder.build()]), builder.build()); + assertEquals(normalizeQueries(builder), builder.build()); + }); + + test('normalize page wrapped in array', () => { + assertEquals(normalizeQueries([Query.equal('status', 'active'), Query.page(2, 10)]), [ + Query.equal('status', 'active'), + Query.limit(10), + Query.offset(10), + ]); + }); + + test('flattenParam spreads wrapped builder', () => { + assertEquals(flattenParam([Query.builder().limit(1)]), [Query.limit(1)]); + }); + + test('flattenParam keeps nested numeric arrays', () => { + const geometry = [[1, 2], [3, 4]]; + assertEquals(flattenParam(geometry), geometry); + }); + + test('flattenParam keeps query string list', () => { + assertEquals(flattenParam([Query.limit(1)]), [Query.limit(1)]); + }); + + test('flattenParam keeps nested string arrays', () => { + assertEquals(flattenParam([['a', 'b']]), [['a', 'b']]); + }); + + test('flattenParam builds a builder value', () => { + assertEquals(flattenParam(Query.builder().equal('status', 'active').limit(1)), [ + Query.equal('status', 'active'), + Query.limit(1), + ]); + }); + + test('flattenParam splices builders among query strings', () => { + assertEquals(flattenParam([Query.equal('status', 'active'), Query.builder().limit(1).offset(2)]), [ + Query.equal('status', 'active'), + Query.limit(1), + Query.offset(2), + ]); + }); }) diff --git a/templates/dotnet/Package/Client.cs.twig b/templates/dotnet/Package/Client.cs.twig index a896d79e40..2701a0343d 100644 --- a/templates/dotnet/Package/Client.cs.twig +++ b/templates/dotnet/Package/Client.cs.twig @@ -154,6 +154,11 @@ namespace {{ spec.info.title | caseUcfirst }} Dictionary headers, Dictionary parameters) { + foreach (var key in parameters.Keys.ToList()) + { + parameters[key] = Query.Flatten(parameters[key]); + } + var methodGet = "GET".Equals(method, StringComparison.OrdinalIgnoreCase); var queryString = methodGet && parameters.Count > 0 ? diff --git a/templates/dotnet/Package/Query.cs.twig b/templates/dotnet/Package/Query.cs.twig index 3b32e28c91..ed8c38e2c1 100644 --- a/templates/dotnet/Package/Query.cs.twig +++ b/templates/dotnet/Package/Query.cs.twig @@ -1,3 +1,4 @@ +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -6,7 +7,7 @@ using System.Text.Json.Serialization; namespace {{ spec.info.title | caseUcfirst }} { - public class Query + public partial class Query { [JsonPropertyName("method")] public string Method { get; set; } = string.Empty; @@ -379,5 +380,273 @@ namespace {{ spec.info.title | caseUcfirst }} { return new Query("notTouches", attribute, new List { values }).ToString(); } + + public static string Count(string attribute = "*", string alias = "") + { + return new Query("count", attribute, alias != "" ? alias : null).ToString(); + } + + public static string CountDistinct(string attribute, string alias = "") + { + return new Query("countDistinct", attribute, alias != "" ? alias : null).ToString(); + } + + public static string Sum(string attribute, string alias = "") + { + return new Query("sum", attribute, alias != "" ? alias : null).ToString(); + } + + public static string Avg(string attribute, string alias = "") + { + return new Query("avg", attribute, alias != "" ? alias : null).ToString(); + } + + public static string Min(string attribute, string alias = "") + { + return new Query("min", attribute, alias != "" ? alias : null).ToString(); + } + + public static string Max(string attribute, string alias = "") + { + return new Query("max", attribute, alias != "" ? alias : null).ToString(); + } + + public static string Stddev(string attribute, string alias = "") + { + return new Query("stddev", attribute, alias != "" ? alias : null).ToString(); + } + + public static string StddevPop(string attribute, string alias = "") + { + return new Query("stddevPop", attribute, alias != "" ? alias : null).ToString(); + } + + public static string StddevSamp(string attribute, string alias = "") + { + return new Query("stddevSamp", attribute, alias != "" ? alias : null).ToString(); + } + + public static string Variance(string attribute, string alias = "") + { + return new Query("variance", attribute, alias != "" ? alias : null).ToString(); + } + + public static string VarPop(string attribute, string alias = "") + { + return new Query("varPop", attribute, alias != "" ? alias : null).ToString(); + } + + public static string VarSamp(string attribute, string alias = "") + { + return new Query("varSamp", attribute, alias != "" ? alias : null).ToString(); + } + + public static string BitAnd(string attribute, string alias = "") + { + return new Query("bitAnd", attribute, alias != "" ? alias : null).ToString(); + } + + public static string BitOr(string attribute, string alias = "") + { + return new Query("bitOr", attribute, alias != "" ? alias : null).ToString(); + } + + public static string BitXor(string attribute, string alias = "") + { + return new Query("bitXor", attribute, alias != "" ? alias : null).ToString(); + } + + public static string GroupBy(List attributes) + { + return new Query("groupBy", null, attributes).ToString(); + } + + public static string Having(List queries) + { + return new Query("having", null, queries.Select(q => JsonSerializer.Deserialize(q, Client.DeserializerOptions)).ToList()).ToString(); + } + + public static string Distinct() + { + return new Query("distinct", null, null).ToString(); + } + + public static string On(string left, string right, string @operator = "=") + { + return new Query("on", null, new List { left, @operator, right }).ToString(); + } + + public static string Join(string table, string left, string right, string @operator = "=", string alias = "") + { + return JoinQuery("join", table, left, right, @operator, alias); + } + + public static string Join(string table, List on) + { + return NestedJoin("join", table, "", on); + } + + public static string Join(string table, string alias, List on) + { + return NestedJoin("join", table, alias, on); + } + + public static string LeftJoin(string table, string left, string right, string @operator = "=", string alias = "") + { + return JoinQuery("leftJoin", table, left, right, @operator, alias); + } + + public static string LeftJoin(string table, List on) + { + return NestedJoin("leftJoin", table, "", on); + } + + public static string LeftJoin(string table, string alias, List on) + { + return NestedJoin("leftJoin", table, alias, on); + } + + public static string RightJoin(string table, string left, string right, string @operator = "=", string alias = "") + { + return JoinQuery("rightJoin", table, left, right, @operator, alias); + } + + public static string RightJoin(string table, List on) + { + return NestedJoin("rightJoin", table, "", on); + } + + public static string RightJoin(string table, string alias, List on) + { + return NestedJoin("rightJoin", table, alias, on); + } + + public static string FullOuterJoin(string table, string left, string right, string @operator = "=", string alias = "") + { + return JoinQuery("fullOuterJoin", table, left, right, @operator, alias); + } + + public static string FullOuterJoin(string table, List on) + { + return NestedJoin("fullOuterJoin", table, "", on); + } + + public static string FullOuterJoin(string table, string alias, List on) + { + return NestedJoin("fullOuterJoin", table, alias, on); + } + + public static string CrossJoin(string table, string alias = "") + { + return new Query("crossJoin", table, alias != "" ? alias : null).ToString(); + } + + public static string Covers(string attribute, object values) + { + return new Query("covers", attribute, new List { values }).ToString(); + } + + public static string NotCovers(string attribute, object values) + { + return new Query("notCovers", attribute, new List { values }).ToString(); + } + + public static string SpatialEquals(string attribute, object values) + { + return new Query("spatialEquals", attribute, new List { values }).ToString(); + } + + public static string NotSpatialEquals(string attribute, object values) + { + return new Query("notSpatialEquals", attribute, new List { values }).ToString(); + } + + public static List Page(int page, int perPage = 25) + { + if (page < 1) + { + throw new ArgumentException("Page must be >= 1, got " + page); + } + if (perPage < 1) + { + throw new ArgumentException("Per page must be >= 1, got " + perPage); + } + + return new List { Limit(perPage), Offset((page - 1) * perPage) }; + } + + public static object? Flatten(object? value) + { + if (value is Builder builder) + { + return builder.Build(); + } + if (value is Query query) + { + return query.ToString(); + } + if (value is not IEnumerable enumerable || value is string) + { + return value; + } + + var items = enumerable.Cast().ToList(); + var output = new List(); + var changed = false; + foreach (var item in items) + { + var flat = Flatten(item); + if (item is Builder || item is Query) + { + if (flat is IEnumerable nested && flat is not string) + { + output.AddRange(nested.Cast()); + } + else + { + output.Add(flat); + } + changed = true; + continue; + } + if (!Equals(flat, item)) + { + changed = true; + } + output.Add(flat); + } + + return changed ? output : value; + } + + private static string JoinQuery(string method, string table, string left, string right, string @operator, string alias) + { + var values = new List { left, @operator, right }; + if (alias != "") + { + values.Add(alias); + } + + return new Query(method, table, values).ToString(); + } + + private static string NestedJoin(string method, string table, string alias, List on) + { + var values = new List(); + if (alias != "") + { + values.Add(alias); + } + foreach (var query in on) + { + var parsed = JsonSerializer.Deserialize(query, Client.DeserializerOptions); + if (parsed != null) + { + values.Add(parsed); + } + } + + return new Query(method, table, values).ToString(); + } } } diff --git a/templates/dotnet/Package/Query/Builder.cs.twig b/templates/dotnet/Package/Query/Builder.cs.twig new file mode 100644 index 0000000000..00fb5f0857 --- /dev/null +++ b/templates/dotnet/Package/Query/Builder.cs.twig @@ -0,0 +1,507 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace {{ spec.info.title | caseUcfirst }} +{ + public partial class Query + { + public class Builder + { + private readonly List queries = new List(); + + public List Build() + { + return new List(queries); + } + + public Builder Equal(string attribute, object value) + { + return Push(Query.Equal(attribute, value)); + } + + public Builder NotEqual(string attribute, object value) + { + return Push(Query.NotEqual(attribute, value)); + } + + public Builder LessThan(string attribute, object value) + { + return Push(Query.LessThan(attribute, value)); + } + + public Builder LessThanEqual(string attribute, object value) + { + return Push(Query.LessThanEqual(attribute, value)); + } + + public Builder GreaterThan(string attribute, object value) + { + return Push(Query.GreaterThan(attribute, value)); + } + + public Builder GreaterThanEqual(string attribute, object value) + { + return Push(Query.GreaterThanEqual(attribute, value)); + } + + public Builder Search(string attribute, string value) + { + return Push(Query.Search(attribute, value)); + } + + public Builder IsNull(string attribute) + { + return Push(Query.IsNull(attribute)); + } + + public Builder IsNotNull(string attribute) + { + return Push(Query.IsNotNull(attribute)); + } + + public Builder Between(string attribute, string start, string end) + { + return Push(Query.Between(attribute, start, end)); + } + + public Builder Between(string attribute, int start, int end) + { + return Push(Query.Between(attribute, start, end)); + } + + public Builder Between(string attribute, double start, double end) + { + return Push(Query.Between(attribute, start, end)); + } + + public Builder StartsWith(string attribute, string value) + { + return Push(Query.StartsWith(attribute, value)); + } + + public Builder EndsWith(string attribute, string value) + { + return Push(Query.EndsWith(attribute, value)); + } + + public Builder Select(List attributes) + { + return Push(Query.Select(attributes)); + } + + public Builder CursorAfter(string documentId) + { + return Push(Query.CursorAfter(documentId)); + } + + public Builder CursorBefore(string documentId) + { + return Push(Query.CursorBefore(documentId)); + } + + public Builder OrderAsc(string attribute) + { + return Push(Query.OrderAsc(attribute)); + } + + public Builder OrderDesc(string attribute) + { + return Push(Query.OrderDesc(attribute)); + } + + public Builder OrderRandom() + { + return Push(Query.OrderRandom()); + } + + public Builder Limit(int limit) + { + return Push(Query.Limit(limit)); + } + + public Builder Offset(int offset) + { + return Push(Query.Offset(offset)); + } + + public Builder Contains(string attribute, object value) + { + return Push(Query.Contains(attribute, value)); + } + + public Builder ContainsAny(string attribute, IList value) + { + return Push(Query.ContainsAny(attribute, value)); + } + + public Builder ContainsAll(string attribute, IList value) + { + return Push(Query.ContainsAll(attribute, value)); + } + + public Builder NotContains(string attribute, object value) + { + return Push(Query.NotContains(attribute, value)); + } + + public Builder NotSearch(string attribute, string value) + { + return Push(Query.NotSearch(attribute, value)); + } + + public Builder NotBetween(string attribute, string start, string end) + { + return Push(Query.NotBetween(attribute, start, end)); + } + + public Builder NotBetween(string attribute, int start, int end) + { + return Push(Query.NotBetween(attribute, start, end)); + } + + public Builder NotBetween(string attribute, double start, double end) + { + return Push(Query.NotBetween(attribute, start, end)); + } + + public Builder NotStartsWith(string attribute, string value) + { + return Push(Query.NotStartsWith(attribute, value)); + } + + public Builder NotEndsWith(string attribute, string value) + { + return Push(Query.NotEndsWith(attribute, value)); + } + + public Builder Regex(string attribute, string pattern) + { + return Push(Query.Regex(attribute, pattern)); + } + + public Builder Exists(List attributes) + { + return Push(Query.Exists(attributes)); + } + + public Builder NotExists(List attributes) + { + return Push(Query.NotExists(attributes)); + } + + public Builder CreatedBefore(string value) + { + return Push(Query.CreatedBefore(value)); + } + + public Builder CreatedAfter(string value) + { + return Push(Query.CreatedAfter(value)); + } + + public Builder CreatedBetween(string start, string end) + { + return Push(Query.CreatedBetween(start, end)); + } + + public Builder UpdatedBefore(string value) + { + return Push(Query.UpdatedBefore(value)); + } + + public Builder UpdatedAfter(string value) + { + return Push(Query.UpdatedAfter(value)); + } + + public Builder UpdatedBetween(string start, string end) + { + return Push(Query.UpdatedBetween(start, end)); + } + + public Builder Or(List queries) + { + return Push(Query.Or(queries)); + } + + public Builder And(List queries) + { + return Push(Query.And(queries)); + } + + public Builder ElemMatch(string attribute, List queries) + { + return Push(Query.ElemMatch(attribute, queries)); + } + + public Builder DistanceEqual(string attribute, object values, double distance, bool meters = true) + { + return Push(Query.DistanceEqual(attribute, values, distance, meters)); + } + + public Builder DistanceNotEqual(string attribute, object values, double distance, bool meters = true) + { + return Push(Query.DistanceNotEqual(attribute, values, distance, meters)); + } + + public Builder DistanceGreaterThan(string attribute, object values, double distance, bool meters = true) + { + return Push(Query.DistanceGreaterThan(attribute, values, distance, meters)); + } + + public Builder DistanceLessThan(string attribute, object values, double distance, bool meters = true) + { + return Push(Query.DistanceLessThan(attribute, values, distance, meters)); + } + + public Builder Intersects(string attribute, object values) + { + return Push(Query.Intersects(attribute, values)); + } + + public Builder NotIntersects(string attribute, object values) + { + return Push(Query.NotIntersects(attribute, values)); + } + + public Builder Crosses(string attribute, object values) + { + return Push(Query.Crosses(attribute, values)); + } + + public Builder NotCrosses(string attribute, object values) + { + return Push(Query.NotCrosses(attribute, values)); + } + + public Builder Overlaps(string attribute, object values) + { + return Push(Query.Overlaps(attribute, values)); + } + + public Builder NotOverlaps(string attribute, object values) + { + return Push(Query.NotOverlaps(attribute, values)); + } + + public Builder Touches(string attribute, object values) + { + return Push(Query.Touches(attribute, values)); + } + + public Builder NotTouches(string attribute, object values) + { + return Push(Query.NotTouches(attribute, values)); + } + + public Builder Count(string attribute = "*", string alias = "") + { + return Push(Query.Count(attribute, alias)); + } + + public Builder CountDistinct(string attribute, string alias = "") + { + return Push(Query.CountDistinct(attribute, alias)); + } + + public Builder Sum(string attribute, string alias = "") + { + return Push(Query.Sum(attribute, alias)); + } + + public Builder Avg(string attribute, string alias = "") + { + return Push(Query.Avg(attribute, alias)); + } + + public Builder Min(string attribute, string alias = "") + { + return Push(Query.Min(attribute, alias)); + } + + public Builder Max(string attribute, string alias = "") + { + return Push(Query.Max(attribute, alias)); + } + + public Builder Stddev(string attribute, string alias = "") + { + return Push(Query.Stddev(attribute, alias)); + } + + public Builder StddevPop(string attribute, string alias = "") + { + return Push(Query.StddevPop(attribute, alias)); + } + + public Builder StddevSamp(string attribute, string alias = "") + { + return Push(Query.StddevSamp(attribute, alias)); + } + + public Builder Variance(string attribute, string alias = "") + { + return Push(Query.Variance(attribute, alias)); + } + + public Builder VarPop(string attribute, string alias = "") + { + return Push(Query.VarPop(attribute, alias)); + } + + public Builder VarSamp(string attribute, string alias = "") + { + return Push(Query.VarSamp(attribute, alias)); + } + + public Builder BitAnd(string attribute, string alias = "") + { + return Push(Query.BitAnd(attribute, alias)); + } + + public Builder BitOr(string attribute, string alias = "") + { + return Push(Query.BitOr(attribute, alias)); + } + + public Builder BitXor(string attribute, string alias = "") + { + return Push(Query.BitXor(attribute, alias)); + } + + public Builder GroupBy(List attributes) + { + return Push(Query.GroupBy(attributes)); + } + + public Builder Having(List queries) + { + return Push(Query.Having(queries)); + } + + public Builder Distinct() + { + return Push(Query.Distinct()); + } + + public Builder Join(string table, string left, string right, string @operator = "=", string alias = "") + { + return Push(Query.Join(table, left, right, @operator, alias)); + } + + public Builder Join(string table, List on) + { + return Push(Query.Join(table, on)); + } + + public Builder Join(string table, string alias, List on) + { + return Push(Query.Join(table, alias, on)); + } + + public Builder LeftJoin(string table, string left, string right, string @operator = "=", string alias = "") + { + return Push(Query.LeftJoin(table, left, right, @operator, alias)); + } + + public Builder LeftJoin(string table, List on) + { + return Push(Query.LeftJoin(table, on)); + } + + public Builder LeftJoin(string table, string alias, List on) + { + return Push(Query.LeftJoin(table, alias, on)); + } + + public Builder RightJoin(string table, string left, string right, string @operator = "=", string alias = "") + { + return Push(Query.RightJoin(table, left, right, @operator, alias)); + } + + public Builder RightJoin(string table, List on) + { + return Push(Query.RightJoin(table, on)); + } + + public Builder RightJoin(string table, string alias, List on) + { + return Push(Query.RightJoin(table, alias, on)); + } + + public Builder FullOuterJoin(string table, string left, string right, string @operator = "=", string alias = "") + { + return Push(Query.FullOuterJoin(table, left, right, @operator, alias)); + } + + public Builder FullOuterJoin(string table, List on) + { + return Push(Query.FullOuterJoin(table, on)); + } + + public Builder FullOuterJoin(string table, string alias, List on) + { + return Push(Query.FullOuterJoin(table, alias, on)); + } + + public Builder CrossJoin(string table, string alias = "") + { + return Push(Query.CrossJoin(table, alias)); + } + + public Builder VectorDot(string attribute, List vector) + { + return Push(Query.VectorDot(attribute, vector)); + } + + public Builder VectorCosine(string attribute, List vector) + { + return Push(Query.VectorCosine(attribute, vector)); + } + + public Builder VectorEuclidean(string attribute, List vector) + { + return Push(Query.VectorEuclidean(attribute, vector)); + } + + public Builder Covers(string attribute, object values) + { + return Push(Query.Covers(attribute, values)); + } + + public Builder NotCovers(string attribute, object values) + { + return Push(Query.NotCovers(attribute, values)); + } + + public Builder SpatialEquals(string attribute, object values) + { + return Push(Query.SpatialEquals(attribute, values)); + } + + public Builder NotSpatialEquals(string attribute, object values) + { + return Push(Query.NotSpatialEquals(attribute, values)); + } + + public Builder Page(int page, int perPage = 25) + { + return Push(Query.Page(page, perPage)); + } + + private Builder Push(string query) + { + queries.Add(query); + return this; + } + + private Builder Push(IEnumerable query) + { + queries.AddRange(query); + return this; + } + } + } +} diff --git a/templates/flutter/lib/package.dart.twig b/templates/flutter/lib/package.dart.twig index 56ddf62127..3bfb6b3f01 100644 --- a/templates/flutter/lib/package.dart.twig +++ b/templates/flutter/lib/package.dart.twig @@ -36,6 +36,7 @@ export 'src/tracking_observer.dart'; {% endif %} part 'query.dart'; +part 'query/builder.dart'; part 'permission.dart'; part 'role.dart'; part 'id.dart'; diff --git a/templates/flutter/lib/src/client.dart.twig b/templates/flutter/lib/src/client.dart.twig index befb62b95b..8d92cab85c 100644 --- a/templates/flutter/lib/src/client.dart.twig +++ b/templates/flutter/lib/src/client.dart.twig @@ -86,7 +86,10 @@ abstract class Client { Future {{ clientMethodName | caseCamel }}(); {% endfor %} -{% endfor %} /// Send the API request. +{% endfor %} /// Flatten builders and nested query-string lists for request params. + dynamic flatten(dynamic value); + + /// Send the API request. Future call( HttpMethod method, { String path = '', diff --git a/templates/flutter/lib/src/client_mixin.dart.twig b/templates/flutter/lib/src/client_mixin.dart.twig index b5420647cb..cbf68936db 100644 --- a/templates/flutter/lib/src/client_mixin.dart.twig +++ b/templates/flutter/lib/src/client_mixin.dart.twig @@ -1,4 +1,5 @@ import 'package:http/http.dart' as http; +import '../{{ language.params.packageName }}.dart' show QueryBuilder; import 'exception.dart'; import 'response.dart'; import 'dart:convert'; @@ -6,12 +7,18 @@ import 'dart:developer'; import 'enums.dart'; mixin ClientMixin { + dynamic flatten(dynamic value) => _flattenValue(value); + http.BaseRequest prepareRequest( HttpMethod method, { required Uri uri, required Map headers, required Map params, }) { + params = { + for (final entry in params.entries) entry.key: _flattenValue(entry.value), + }; + http.BaseRequest request = http.Request(method.name(), uri); if (headers['content-type'] == 'multipart/form-data') { request = http.MultipartRequest(method.name(), uri); @@ -157,3 +164,16 @@ mixin ClientMixin { } } } + +dynamic _flattenValue(dynamic value) { + if (value is QueryBuilder) { + return value.build(); + } + if (value is! List || !value.any((item) => item is QueryBuilder)) { + return value; + } + return [ + for (final item in value) + if (item is QueryBuilder) ...item.build() else item, + ]; +} diff --git a/templates/go/client.go.twig b/templates/go/client.go.twig index 9f33d63f0e..922d6d9050 100644 --- a/templates/go/client.go.twig +++ b/templates/go/client.go.twig @@ -20,6 +20,7 @@ import ( "time" "{{ sdk | goPackagePath }}/file" + "{{ sdk | goPackagePath }}/query" ) const ( @@ -479,6 +480,12 @@ func (client *Client) FileUpload(url string, headers map[string]interface{}, par // Call an API using Client func (client *Client) Call(method string, path string, headers map[string]interface{}, params map[string]interface{}) (*ClientResponse, error) { + if params != nil { + for key, value := range params { + params[key] = flattenValue(value) + } + } + if client.Client == nil { // Create HTTP client httpClient, err := GetDefaultClient(client.Timeout) @@ -797,3 +804,59 @@ func flatten(params interface{}, prefix string, result *map[string]string) error } return nil } + +func Flatten(value interface{}) interface{} { + return flattenValue(value) +} + +func flattenValue(value interface{}) interface{} { + if value == nil { + return nil + } + switch v := value.(type) { + case *query.Builder: + if v == nil { + return value + } + return v.Build() + case query.Builder: + return v.Build() + } + + reflected := reflect.ValueOf(value) + if reflected.Kind() != reflect.Slice && reflected.Kind() != reflect.Array { + return value + } + + out := make([]interface{}, 0, reflected.Len()) + changed := false + for i := 0; i < reflected.Len(); i++ { + item := reflected.Index(i).Interface() + flat := flattenValue(item) + if isBuilder(item) { + for _, entry := range flat.([]string) { + out = append(out, entry) + } + changed = true + continue + } + if !reflect.DeepEqual(flat, item) { + changed = true + } + out = append(out, flat) + } + if !changed { + return value + } + return out +} + +func isBuilder(value interface{}) bool { + switch v := value.(type) { + case *query.Builder: + return v != nil + case query.Builder: + return true + } + return false +} diff --git a/templates/go/client_test.go.twig b/templates/go/client_test.go.twig index 9d13b437bc..db5807e2ac 100644 --- a/templates/go/client_test.go.twig +++ b/templates/go/client_test.go.twig @@ -3,11 +3,16 @@ package client import ( "crypto/tls" + "encoding/json" + "io/ioutil" "net/http" "net/http/httptest" "net/url" + "reflect" "sync" "testing" + + "{{ sdk | goPackagePath }}/query" ) func TestSetProject(t *testing.T) { @@ -272,4 +277,104 @@ func TestResponseBodyRejectsAnotherType(t *testing.T) { t.Error("Expected an error for a nil response") } } + +func TestFlattenValueNestedFloatsDoesNotPanic(t *testing.T) { + geometry := [][]float64{ + {1, 2}, + {3, 4}, + } + got := flattenValue(geometry) + if !reflect.DeepEqual(got, geometry) { + t.Errorf("Expected nested floats to pass through, got %#v", got) + } +} + +func TestFlattenValueSpreadsWrappedBuilder(t *testing.T) { + builder := query.NewBuilder().Limit(1) + got := flattenValue([]interface{}{builder}) + want := []interface{}{query.Limit(1)} + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %#v, got %#v", want, got) + } +} +func TestFlattenValueKeepsNestedStringArrays(t *testing.T) { + for _, test := range []struct { + name string + value interface{} + }{ + {name: "typed", value: [][]string{[]string{"a", "b"}}}, + {name: "untyped", value: []interface{}{[]interface{}{"a", "b"}}}, + {name: "mixed", value: []interface{}{"x", []string{"a", "b"}}}, + } { + t.Run(test.name, func(t *testing.T) { + got := flattenValue(test.value) + if !reflect.DeepEqual(got, test.value) { + t.Errorf("Expected %#v to pass through, got %#v", test.value, got) + } + }) + } +} + +func TestFlattenValueBuildsBuilder(t *testing.T) { + builder := query.NewBuilder().Equal("status", "active").Limit(1) + got := flattenValue(builder) + want := []string{query.Equal("status", "active"), query.Limit(1)} + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %#v, got %#v", want, got) + } +} + +func TestFlattenValueSpreadsBuildersBesideStrings(t *testing.T) { + builder := query.NewBuilder().Limit(1) + got := flattenValue([]interface{}{query.Offset(2), builder, *builder}) + want := []interface{}{query.Offset(2), query.Limit(1), query.Limit(1)} + if !reflect.DeepEqual(got, want) { + t.Errorf("Expected %#v, got %#v", want, got) + } +} + +func TestCallSendsFlattenedParams(t *testing.T) { + var body map[string]interface{} + var rawQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.Query() + if r.Method != http.MethodGet { + data, _ := ioutil.ReadAll(r.Body) + json.Unmarshal(data, &body) + } + w.Header().Set("content-type", "application/json") + w.Write([]byte("{}")) + })) + defer server.Close() + + c := New() + c.Endpoint = server.URL + builder := query.NewBuilder().Limit(1) + + _, err := c.Call("POST", "/", map[string]interface{}{"content-type": "application/json"}, map[string]interface{}{ + "values": []interface{}{[]interface{}{"a", "b"}}, + "queries": []interface{}{query.Offset(2), builder}, + }) + if err != nil { + t.Fatalf("Expected the call to succeed, got %v", err) + } + wantValues := []interface{}{[]interface{}{"a", "b"}} + if !reflect.DeepEqual(body["values"], wantValues) { + t.Errorf("Expected values %#v in the JSON body, got %#v", wantValues, body["values"]) + } + wantQueries := []interface{}{query.Offset(2), query.Limit(1)} + if !reflect.DeepEqual(body["queries"], wantQueries) { + t.Errorf("Expected queries %#v in the JSON body, got %#v", wantQueries, body["queries"]) + } + + _, err = c.Call("GET", "/", map[string]interface{}{}, map[string]interface{}{ + "queries": builder, + }) + if err != nil { + t.Fatalf("Expected the call to succeed, got %v", err) + } + if got := rawQuery.Get("queries[0]"); got != query.Limit(1) { + t.Errorf("Expected queries[0] to be %q in the query string, got %q", query.Limit(1), got) + } +} {% endautoescape %} diff --git a/templates/go/query.go.twig b/templates/go/query.go.twig index b5361f5e3e..ea094cb9fd 100644 --- a/templates/go/query.go.twig +++ b/templates/go/query.go.twig @@ -2,6 +2,8 @@ package query import ( "encoding/json" + "fmt" + "reflect" ) func toArray(val interface{}) []interface{} { @@ -520,3 +522,256 @@ func ElemMatch(attribute string, queries []string) string { Values: &parsedQueries, }) } + +func aliasedQuery(method, attribute, alias string) string { + options := queryOptions{ + Method: method, + Attribute: &attribute, + } + if alias != "" { + values := toArray(alias) + options.Values = &values + } + return parseQuery(options) +} + +func joinQuery(method, table, left, right, operator, alias string) string { + if operator == "" { + operator = "=" + } + values := []interface{}{left, operator, right} + if alias != "" { + values = append(values, alias) + } + return parseQuery(queryOptions{ + Method: method, + Attribute: &table, + Values: &values, + }) +} + +func nestedJoin(method, table, alias string, on []string) string { + var values []interface{} + if alias != "" { + values = append(values, alias) + } + for _, query := range on { + var parsed interface{} + if err := json.Unmarshal([]byte(query), &parsed); err != nil { + continue + } + values = append(values, parsed) + } + return parseQuery(queryOptions{ + Method: method, + Attribute: &table, + Values: &values, + }) +} + +func Count(attribute, alias string) string { + return aliasedQuery("count", attribute, alias) +} + +func CountDistinct(attribute, alias string) string { + return aliasedQuery("countDistinct", attribute, alias) +} + +func Sum(attribute, alias string) string { + return aliasedQuery("sum", attribute, alias) +} + +func Avg(attribute, alias string) string { + return aliasedQuery("avg", attribute, alias) +} + +func Min(attribute, alias string) string { + return aliasedQuery("min", attribute, alias) +} + +func Max(attribute, alias string) string { + return aliasedQuery("max", attribute, alias) +} + +func Stddev(attribute, alias string) string { + return aliasedQuery("stddev", attribute, alias) +} + +func StddevPop(attribute, alias string) string { + return aliasedQuery("stddevPop", attribute, alias) +} + +func StddevSamp(attribute, alias string) string { + return aliasedQuery("stddevSamp", attribute, alias) +} + +func Variance(attribute, alias string) string { + return aliasedQuery("variance", attribute, alias) +} + +func VarPop(attribute, alias string) string { + return aliasedQuery("varPop", attribute, alias) +} + +func VarSamp(attribute, alias string) string { + return aliasedQuery("varSamp", attribute, alias) +} + +func BitAnd(attribute, alias string) string { + return aliasedQuery("bitAnd", attribute, alias) +} + +func BitOr(attribute, alias string) string { + return aliasedQuery("bitOr", attribute, alias) +} + +func BitXor(attribute, alias string) string { + return aliasedQuery("bitXor", attribute, alias) +} + +func GroupBy(attributes []interface{}) string { + return parseQuery(queryOptions{ + Method: "groupBy", + Values: &attributes, + }) +} + +func Having(queries []string) string { + var parsedQueries []interface{} + for _, query := range queries { + var q interface{} + if err := json.Unmarshal([]byte(query), &q); err != nil { + continue + } + parsedQueries = append(parsedQueries, q) + } + return parseQuery(queryOptions{ + Method: "having", + Values: &parsedQueries, + }) +} + +func Distinct() string { + return parseQuery(queryOptions{ + Method: "distinct", + }) +} + +func On(left, right, operator string) string { + if operator == "" { + operator = "=" + } + values := []interface{}{left, operator, right} + return parseQuery(queryOptions{ + Method: "on", + Values: &values, + }) +} + +func Join(table, left, right, operator, alias string) string { + return joinQuery("join", table, left, right, operator, alias) +} + +func LeftJoin(table, left, right, operator, alias string) string { + return joinQuery("leftJoin", table, left, right, operator, alias) +} + +func RightJoin(table, left, right, operator, alias string) string { + return joinQuery("rightJoin", table, left, right, operator, alias) +} + +func FullOuterJoin(table, left, right, operator, alias string) string { + return joinQuery("fullOuterJoin", table, left, right, operator, alias) +} + +func JoinOn(table, alias string, on []string) string { + return nestedJoin("join", table, alias, on) +} + +func LeftJoinOn(table, alias string, on []string) string { + return nestedJoin("leftJoin", table, alias, on) +} + +func RightJoinOn(table, alias string, on []string) string { + return nestedJoin("rightJoin", table, alias, on) +} + +func FullOuterJoinOn(table, alias string, on []string) string { + return nestedJoin("fullOuterJoin", table, alias, on) +} + +func CrossJoin(table, alias string) string { + return aliasedQuery("crossJoin", table, alias) +} + +func Covers(attribute string, values []interface{}) string { + return parseQuery(queryOptions{ + Method: "covers", + Attribute: &attribute, + Values: &[]interface{}{values}, + }) +} + +func NotCovers(attribute string, values []interface{}) string { + return parseQuery(queryOptions{ + Method: "notCovers", + Attribute: &attribute, + Values: &[]interface{}{values}, + }) +} + +func SpatialEquals(attribute string, values []interface{}) string { + return parseQuery(queryOptions{ + Method: "spatialEquals", + Attribute: &attribute, + Values: &[]interface{}{values}, + }) +} + +func NotSpatialEquals(attribute string, values []interface{}) string { + return parseQuery(queryOptions{ + Method: "notSpatialEquals", + Attribute: &attribute, + Values: &[]interface{}{values}, + }) +} + +func Page(page, perPage int) []string { + if page < 1 { + panic(fmt.Sprintf("Page must be >= 1, got %d", page)) + } + if perPage < 1 { + panic(fmt.Sprintf("Per page must be >= 1, got %d", perPage)) + } + return []string{Limit(perPage), Offset((page - 1) * perPage)} +} + +func normalize(queries interface{}) []string { + if queries == nil { + return []string{} + } + + switch v := queries.(type) { + case *Builder: + if v == nil { + return []string{} + } + return v.Build() + case Builder: + return v.Build() + case string: + return []string{v} + } + + value := reflect.ValueOf(queries) + switch value.Kind() { + case reflect.Slice, reflect.Array: + out := make([]string, 0, value.Len()) + for i := 0; i < value.Len(); i++ { + out = append(out, normalize(value.Index(i).Interface())...) + } + return out + default: + return []string{} + } +} diff --git a/templates/go/query/builder.go.twig b/templates/go/query/builder.go.twig new file mode 100644 index 0000000000..ae7fcc7a4e --- /dev/null +++ b/templates/go/query/builder.go.twig @@ -0,0 +1,368 @@ +package query + +type Builder struct { + queries []string +} + +func NewBuilder() *Builder { + return &Builder{} +} + +func (b *Builder) Build() []string { + result := make([]string, len(b.queries)) + copy(result, b.queries) + return result +} + +func (b *Builder) push(queries ...string) *Builder { + b.queries = append(b.queries, queries...) + return b +} + +func (b *Builder) Equal(attribute string, value interface{}) *Builder { + return b.push(Equal(attribute, value)) +} + +func (b *Builder) NotEqual(attribute string, value interface{}) *Builder { + return b.push(NotEqual(attribute, value)) +} + +func (b *Builder) LessThan(attribute string, value interface{}) *Builder { + return b.push(LessThan(attribute, value)) +} + +func (b *Builder) LessThanEqual(attribute string, value interface{}) *Builder { + return b.push(LessThanEqual(attribute, value)) +} + +func (b *Builder) GreaterThan(attribute string, value interface{}) *Builder { + return b.push(GreaterThan(attribute, value)) +} + +func (b *Builder) GreaterThanEqual(attribute string, value interface{}) *Builder { + return b.push(GreaterThanEqual(attribute, value)) +} + +func (b *Builder) Search(attribute string, value interface{}) *Builder { + return b.push(Search(attribute, value)) +} + +func (b *Builder) IsNull(attribute string) *Builder { + return b.push(IsNull(attribute)) +} + +func (b *Builder) IsNotNull(attribute string) *Builder { + return b.push(IsNotNull(attribute)) +} + +func (b *Builder) Between(attribute string, start, end interface{}) *Builder { + return b.push(Between(attribute, start, end)) +} + +func (b *Builder) StartsWith(attribute string, value interface{}) *Builder { + return b.push(StartsWith(attribute, value)) +} + +func (b *Builder) EndsWith(attribute string, value interface{}) *Builder { + return b.push(EndsWith(attribute, value)) +} + +func (b *Builder) Select(attributes interface{}) *Builder { + return b.push(Select(attributes)) +} + +func (b *Builder) CursorAfter(documentId string) *Builder { + return b.push(CursorAfter(documentId)) +} + +func (b *Builder) CursorBefore(documentId interface{}) *Builder { + return b.push(CursorBefore(documentId)) +} + +func (b *Builder) OrderAsc(attribute string) *Builder { + return b.push(OrderAsc(attribute)) +} + +func (b *Builder) OrderDesc(attribute string) *Builder { + return b.push(OrderDesc(attribute)) +} + +func (b *Builder) OrderRandom() *Builder { + return b.push(OrderRandom()) +} + +func (b *Builder) Limit(limit int) *Builder { + return b.push(Limit(limit)) +} + +func (b *Builder) Offset(offset int) *Builder { + return b.push(Offset(offset)) +} + +func (b *Builder) Contains(attribute string, value interface{}) *Builder { + return b.push(Contains(attribute, value)) +} + +func (b *Builder) ContainsAny(attribute string, value []interface{}) *Builder { + return b.push(ContainsAny(attribute, value)) +} + +func (b *Builder) ContainsAll(attribute string, value []interface{}) *Builder { + return b.push(ContainsAll(attribute, value)) +} + +func (b *Builder) NotContains(attribute string, value interface{}) *Builder { + return b.push(NotContains(attribute, value)) +} + +func (b *Builder) NotSearch(attribute string, value interface{}) *Builder { + return b.push(NotSearch(attribute, value)) +} + +func (b *Builder) NotBetween(attribute string, start, end interface{}) *Builder { + return b.push(NotBetween(attribute, start, end)) +} + +func (b *Builder) NotStartsWith(attribute string, value interface{}) *Builder { + return b.push(NotStartsWith(attribute, value)) +} + +func (b *Builder) NotEndsWith(attribute string, value interface{}) *Builder { + return b.push(NotEndsWith(attribute, value)) +} + +func (b *Builder) Regex(attribute string, pattern string) *Builder { + return b.push(Regex(attribute, pattern)) +} + +func (b *Builder) Exists(attributes []interface{}) *Builder { + return b.push(Exists(attributes)) +} + +func (b *Builder) NotExists(attributes []interface{}) *Builder { + return b.push(NotExists(attributes)) +} + +func (b *Builder) CreatedBefore(value interface{}) *Builder { + return b.push(CreatedBefore(value)) +} + +func (b *Builder) CreatedAfter(value interface{}) *Builder { + return b.push(CreatedAfter(value)) +} + +func (b *Builder) CreatedBetween(start, end interface{}) *Builder { + return b.push(CreatedBetween(start, end)) +} + +func (b *Builder) UpdatedBefore(value interface{}) *Builder { + return b.push(UpdatedBefore(value)) +} + +func (b *Builder) UpdatedAfter(value interface{}) *Builder { + return b.push(UpdatedAfter(value)) +} + +func (b *Builder) UpdatedBetween(start, end interface{}) *Builder { + return b.push(UpdatedBetween(start, end)) +} + +func (b *Builder) Or(queries []string) *Builder { + return b.push(Or(queries)) +} + +func (b *Builder) And(queries []string) *Builder { + return b.push(And(queries)) +} + +func (b *Builder) ElemMatch(attribute string, queries []string) *Builder { + return b.push(ElemMatch(attribute, queries)) +} + +func (b *Builder) DistanceEqual(attribute string, values []interface{}, distance float64, meters bool) *Builder { + return b.push(DistanceEqual(attribute, values, distance, meters)) +} + +func (b *Builder) DistanceNotEqual(attribute string, values []interface{}, distance float64, meters bool) *Builder { + return b.push(DistanceNotEqual(attribute, values, distance, meters)) +} + +func (b *Builder) DistanceGreaterThan(attribute string, values []interface{}, distance float64, meters bool) *Builder { + return b.push(DistanceGreaterThan(attribute, values, distance, meters)) +} + +func (b *Builder) DistanceLessThan(attribute string, values []interface{}, distance float64, meters bool) *Builder { + return b.push(DistanceLessThan(attribute, values, distance, meters)) +} + +func (b *Builder) Intersects(attribute string, values []interface{}) *Builder { + return b.push(Intersects(attribute, values)) +} + +func (b *Builder) NotIntersects(attribute string, values []interface{}) *Builder { + return b.push(NotIntersects(attribute, values)) +} + +func (b *Builder) Crosses(attribute string, values []interface{}) *Builder { + return b.push(Crosses(attribute, values)) +} + +func (b *Builder) NotCrosses(attribute string, values []interface{}) *Builder { + return b.push(NotCrosses(attribute, values)) +} + +func (b *Builder) Overlaps(attribute string, values []interface{}) *Builder { + return b.push(Overlaps(attribute, values)) +} + +func (b *Builder) NotOverlaps(attribute string, values []interface{}) *Builder { + return b.push(NotOverlaps(attribute, values)) +} + +func (b *Builder) Touches(attribute string, values []interface{}) *Builder { + return b.push(Touches(attribute, values)) +} + +func (b *Builder) NotTouches(attribute string, values []interface{}) *Builder { + return b.push(NotTouches(attribute, values)) +} + +func (b *Builder) Count(attribute, alias string) *Builder { + return b.push(Count(attribute, alias)) +} + +func (b *Builder) CountDistinct(attribute, alias string) *Builder { + return b.push(CountDistinct(attribute, alias)) +} + +func (b *Builder) Sum(attribute, alias string) *Builder { + return b.push(Sum(attribute, alias)) +} + +func (b *Builder) Avg(attribute, alias string) *Builder { + return b.push(Avg(attribute, alias)) +} + +func (b *Builder) Min(attribute, alias string) *Builder { + return b.push(Min(attribute, alias)) +} + +func (b *Builder) Max(attribute, alias string) *Builder { + return b.push(Max(attribute, alias)) +} + +func (b *Builder) Stddev(attribute, alias string) *Builder { + return b.push(Stddev(attribute, alias)) +} + +func (b *Builder) StddevPop(attribute, alias string) *Builder { + return b.push(StddevPop(attribute, alias)) +} + +func (b *Builder) StddevSamp(attribute, alias string) *Builder { + return b.push(StddevSamp(attribute, alias)) +} + +func (b *Builder) Variance(attribute, alias string) *Builder { + return b.push(Variance(attribute, alias)) +} + +func (b *Builder) VarPop(attribute, alias string) *Builder { + return b.push(VarPop(attribute, alias)) +} + +func (b *Builder) VarSamp(attribute, alias string) *Builder { + return b.push(VarSamp(attribute, alias)) +} + +func (b *Builder) BitAnd(attribute, alias string) *Builder { + return b.push(BitAnd(attribute, alias)) +} + +func (b *Builder) BitOr(attribute, alias string) *Builder { + return b.push(BitOr(attribute, alias)) +} + +func (b *Builder) BitXor(attribute, alias string) *Builder { + return b.push(BitXor(attribute, alias)) +} + +func (b *Builder) GroupBy(attributes []interface{}) *Builder { + return b.push(GroupBy(attributes)) +} + +func (b *Builder) Having(queries []string) *Builder { + return b.push(Having(queries)) +} + +func (b *Builder) Distinct() *Builder { + return b.push(Distinct()) +} + +func (b *Builder) Join(table, left, right, operator, alias string) *Builder { + return b.push(Join(table, left, right, operator, alias)) +} + +func (b *Builder) LeftJoin(table, left, right, operator, alias string) *Builder { + return b.push(LeftJoin(table, left, right, operator, alias)) +} + +func (b *Builder) RightJoin(table, left, right, operator, alias string) *Builder { + return b.push(RightJoin(table, left, right, operator, alias)) +} + +func (b *Builder) FullOuterJoin(table, left, right, operator, alias string) *Builder { + return b.push(FullOuterJoin(table, left, right, operator, alias)) +} + +func (b *Builder) JoinOn(table, alias string, on []string) *Builder { + return b.push(JoinOn(table, alias, on)) +} + +func (b *Builder) LeftJoinOn(table, alias string, on []string) *Builder { + return b.push(LeftJoinOn(table, alias, on)) +} + +func (b *Builder) RightJoinOn(table, alias string, on []string) *Builder { + return b.push(RightJoinOn(table, alias, on)) +} + +func (b *Builder) FullOuterJoinOn(table, alias string, on []string) *Builder { + return b.push(FullOuterJoinOn(table, alias, on)) +} + +func (b *Builder) CrossJoin(table, alias string) *Builder { + return b.push(CrossJoin(table, alias)) +} + +func (b *Builder) VectorDot(attribute string, vector []float64) *Builder { + return b.push(VectorDot(attribute, vector)) +} + +func (b *Builder) VectorCosine(attribute string, vector []float64) *Builder { + return b.push(VectorCosine(attribute, vector)) +} + +func (b *Builder) VectorEuclidean(attribute string, vector []float64) *Builder { + return b.push(VectorEuclidean(attribute, vector)) +} + +func (b *Builder) Covers(attribute string, values []interface{}) *Builder { + return b.push(Covers(attribute, values)) +} + +func (b *Builder) NotCovers(attribute string, values []interface{}) *Builder { + return b.push(NotCovers(attribute, values)) +} + +func (b *Builder) SpatialEquals(attribute string, values []interface{}) *Builder { + return b.push(SpatialEquals(attribute, values)) +} + +func (b *Builder) NotSpatialEquals(attribute string, values []interface{}) *Builder { + return b.push(NotSpatialEquals(attribute, values)) +} + +func (b *Builder) Page(page, perPage int) *Builder { + return b.push(Page(page, perPage)...) +} diff --git a/templates/go/query_test.go.twig b/templates/go/query_test.go.twig index 804e46492b..48af42d692 100644 --- a/templates/go/query_test.go.twig +++ b/templates/go/query_test.go.twig @@ -125,4 +125,383 @@ func TestOr(t *testing.T) { t.Errorf("Expected 2 subqueries, got %d", len(values)) } } + +func TestCountWithAlias(t *testing.T) { + q := Count("*", "total") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "count" { + t.Errorf("Expected count, got %v", data["method"]) + } + if data["attribute"] != "*" { + t.Errorf("Expected *, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "total" { + t.Errorf("Expected total, got %v", values[0]) + } +} + +func TestCountWithoutAlias(t *testing.T) { + q := Count("*", "") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "count" { + t.Errorf("Expected count, got %v", data["method"]) + } + if data["attribute"] != "*" { + t.Errorf("Expected *, got %v", data["attribute"]) + } + if _, ok := data["values"]; ok { + t.Errorf("Expected no values, got %v", data["values"]) + } +} + +func TestCountDistinct(t *testing.T) { + q := CountDistinct("year", "uniqueYears") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "countDistinct" { + t.Errorf("Expected countDistinct, got %v", data["method"]) + } + if data["attribute"] != "year" { + t.Errorf("Expected year, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "uniqueYears" { + t.Errorf("Expected uniqueYears, got %v", values[0]) + } +} + +func TestNewQueryMethods(t *testing.T) { + cases := []struct { + name string + query string + method string + }{ + {"sum", Sum("price", "total"), "sum"}, + {"avg", Avg("price", "avgPrice"), "avg"}, + {"min", Min("price", "lowest"), "min"}, + {"max", Max("price", "highest"), "max"}, + {"stddev", Stddev("price", "sd"), "stddev"}, + {"stddevPop", StddevPop("price", "sdp"), "stddevPop"}, + {"stddevSamp", StddevSamp("price", "sds"), "stddevSamp"}, + {"variance", Variance("price", "var"), "variance"}, + {"varPop", VarPop("price", "vp"), "varPop"}, + {"varSamp", VarSamp("price", "vs"), "varSamp"}, + {"bitAnd", BitAnd("flags", "band"), "bitAnd"}, + {"bitOr", BitOr("flags", "bor"), "bitOr"}, + {"bitXor", BitXor("flags", "bxor"), "bitXor"}, + {"rightJoin", RightJoin("orders", "$id", "customerId", "=", ""), "rightJoin"}, + {"fullOuterJoin", FullOuterJoin("orders", "$id", "customerId", "=", ""), "fullOuterJoin"}, + {"notCovers", NotCovers("location", []interface{}{1, 2}), "notCovers"}, + {"spatialEquals", SpatialEquals("location", []interface{}{1, 2}), "spatialEquals"}, + {"notSpatialEquals", NotSpatialEquals("location", []interface{}{1, 2}), "notSpatialEquals"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var data map[string]interface{} + if err := json.Unmarshal([]byte(c.query), &data); err != nil { + t.Fatal(err) + } + if data["method"] != c.method { + t.Errorf("Expected %s, got %v", c.method, data["method"]) + } + }) + } +} + +func TestGroupBy(t *testing.T) { + q := GroupBy([]interface{}{"status", "year"}) + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "groupBy" { + t.Errorf("Expected groupBy, got %v", data["method"]) + } + if _, ok := data["attribute"]; ok { + t.Errorf("Expected no attribute, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "status" || values[1] != "year" { + t.Errorf("Expected [status, year], got %v", values) + } +} + +func TestHaving(t *testing.T) { + q := Having([]string{GreaterThan("total", 1)}) + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "having" { + t.Errorf("Expected having, got %v", data["method"]) + } + values := data["values"].([]interface{}) + inner := values[0].(map[string]interface{}) + if inner["method"] != "greaterThan" { + t.Errorf("Expected greaterThan, got %v", inner["method"]) + } + if inner["attribute"] != "total" { + t.Errorf("Expected total, got %v", inner["attribute"]) + } + if inner["values"].([]interface{})[0].(float64) != 1 { + t.Errorf("Expected [1], got %v", inner["values"]) + } +} + +func TestDistinct(t *testing.T) { + q := Distinct() + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "distinct" { + t.Errorf("Expected distinct, got %v", data["method"]) + } + if _, ok := data["attribute"]; ok { + t.Errorf("Expected no attribute, got %v", data["attribute"]) + } + if _, ok := data["values"]; ok { + t.Errorf("Expected no values, got %v", data["values"]) + } +} + +func TestJoin(t *testing.T) { + q := Join("orders", "$id", "customerId", "", "") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "join" { + t.Errorf("Expected join, got %v", data["method"]) + } + if data["attribute"] != "orders" { + t.Errorf("Expected orders, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "$id" || values[1] != "=" || values[2] != "customerId" { + t.Errorf("Expected [$id, =, customerId], got %v", values) + } + if len(values) != 3 { + t.Errorf("Expected 3 values, got %d", len(values)) + } +} + +func TestJoinWithAlias(t *testing.T) { + q := LeftJoin("orders", "$id", "customerId", "=", "ord") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "leftJoin" { + t.Errorf("Expected leftJoin, got %v", data["method"]) + } + if data["attribute"] != "orders" { + t.Errorf("Expected orders, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "$id" || values[1] != "=" || values[2] != "customerId" || values[3] != "ord" { + t.Errorf("Expected [$id, =, customerId, ord], got %v", values) + } +} + +func TestOn(t *testing.T) { + q := On("$id", "customerId", "") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + if data["method"] != "on" { + t.Errorf("Expected on, got %v", data["method"]) + } + values := data["values"].([]interface{}) + if values[0] != "$id" || values[1] != "=" || values[2] != "customerId" { + t.Errorf("Expected [$id, =, customerId], got %v", values) + } +} + +func TestNestedLeftJoin(t *testing.T) { + q := LeftJoinOn("orders", "ord", []string{ + On("$id", "customerId", ""), + Equal("ord.status", "paid"), + }) + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + if data["method"] != "leftJoin" { + t.Errorf("Expected leftJoin, got %v", data["method"]) + } + if data["attribute"] != "orders" { + t.Errorf("Expected orders, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "ord" { + t.Errorf("Expected alias ord, got %v", values[0]) + } + on := values[1].(map[string]interface{}) + if on["method"] != "on" { + t.Errorf("Expected on, got %v", on["method"]) + } +} + +func TestCrossJoin(t *testing.T) { + q := CrossJoin("orders", "ord") + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "crossJoin" { + t.Errorf("Expected crossJoin, got %v", data["method"]) + } + if data["attribute"] != "orders" { + t.Errorf("Expected orders, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + if values[0] != "ord" { + t.Errorf("Expected ord, got %v", values[0]) + } +} + +func TestVectorDot(t *testing.T) { + q := VectorDot("embedding", []float64{0.1, 0.2}) + var data map[string]interface{} + if err := json.Unmarshal([]byte(q), &data); err != nil { + t.Fatal(err) + } + + if data["method"] != "vectorDot" { + t.Errorf("Expected vectorDot, got %v", data["method"]) + } + if data["attribute"] != "embedding" { + t.Errorf("Expected embedding, got %v", data["attribute"]) + } + values := data["values"].([]interface{}) + vector := values[0].([]interface{}) + if vector[0].(float64) != 0.1 || vector[1].(float64) != 0.2 { + t.Errorf("Expected [[0.1, 0.2]], got %v", values) + } +} + +func TestPage(t *testing.T) { + queries := Page(2, 10) + if len(queries) != 2 { + t.Fatalf("Expected 2 queries, got %d", len(queries)) + } + + var limit map[string]interface{} + if err := json.Unmarshal([]byte(queries[0]), &limit); err != nil { + t.Fatal(err) + } + if limit["method"] != "limit" { + t.Errorf("Expected limit, got %v", limit["method"]) + } + if limit["values"].([]interface{})[0].(float64) != 10 { + t.Errorf("Expected [10], got %v", limit["values"]) + } + + var offset map[string]interface{} + if err := json.Unmarshal([]byte(queries[1]), &offset); err != nil { + t.Fatal(err) + } + if offset["method"] != "offset" { + t.Errorf("Expected offset, got %v", offset["method"]) + } + if offset["values"].([]interface{})[0].(float64) != 10 { + t.Errorf("Expected [10], got %v", offset["values"]) + } +} + +func TestPageRejectsInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("Expected panic for page < 1") + } + }() + Page(0, 10) +} + +func TestBuilderBuild(t *testing.T) { + expected := []string{ + Equal("status", "published"), + LeftJoin("orders", "$id", "customerId", "=", "ord"), + Count("*", "total"), + GroupBy([]interface{}{"status"}), + Limit(25), + } + + built := NewBuilder(). + Equal("status", "published"). + LeftJoin("orders", "$id", "customerId", "=", "ord"). + Count("*", "total"). + GroupBy([]interface{}{"status"}). + Limit(25). + Build() + + if len(built) != len(expected) { + t.Fatalf("Expected %d queries, got %d", len(expected), len(built)) + } + for i := range expected { + if built[i] != expected[i] { + t.Errorf("index %d: expected %s, got %s", i, expected[i], built[i]) + } + } +} + +func assertQueriesEqual(t *testing.T, got, expected []string) { + t.Helper() + if len(got) != len(expected) { + t.Fatalf("expected %d queries, got %d: %v", len(expected), len(got), got) + } + for i := range expected { + if got[i] != expected[i] { + t.Errorf("index %d: expected %s, got %s", i, expected[i], got[i]) + } + } +} + +func TestNormalizeBuilderDirect(t *testing.T) { + builder := NewBuilder().Equal("status", "active").Limit(10) + assertQueriesEqual(t, normalize(builder), builder.Build()) +} + +func TestNormalizeBuilderWrappedInArray(t *testing.T) { + builder := NewBuilder().Equal("status", "active").Limit(10) + assertQueriesEqual(t, normalize([]interface{}{builder}), builder.Build()) +} + +func TestNormalizeBuildResultWrappedInArray(t *testing.T) { + built := NewBuilder().Equal("status", "active").Limit(10).Build() + assertQueriesEqual(t, normalize([]interface{}{built}), built) +} + +func TestNormalizeMixedAndPage(t *testing.T) { + normalized := normalize([]interface{}{ + Equal("status", "active"), + Page(2, 10), + }) + assertQueriesEqual(t, normalized, []string{ + Equal("status", "active"), + Limit(10), + Offset(10), + }) +} {% endautoescape %} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig index 290afaa7ca..13a2b61d09 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/Client.kt.twig @@ -239,7 +239,8 @@ class Client @JvmOverloads constructor( headers: Map = mapOf(), params: Map = mapOf(), ): Request { - val filteredParams = params.filterValues { it != null } + val flattenedParams = params.mapValues { flatten(it.value) } + val filteredParams = flattenedParams.filterValues { it != null } val requestHeaders = this.headers.toHeaders().newBuilder() .addAll(headers.toHeaders()) @@ -302,7 +303,7 @@ class Client @JvmOverloads constructor( } builder.build() } else { - params + flattenedParams .toJsonRequestBody() .toRequestBody("application/json".toMediaType()) } @@ -714,4 +715,44 @@ class Client @JvmOverloads constructor( } }) } + + fun flatten(value: Any?): Any? { + if (value is Query.Builder) { + return value.build() + } + if (value is Query) { + return value.toString() + } + + val items = when (value) { + is Array<*> -> value.asList() + is Collection<*> -> value + else -> return value + } + + val out = ArrayList(items.size) + var changed = false + for (item in items) { + val flat = flatten(item) + if (item is Query.Builder || item is Query) { + if (flat is Collection<*>) { + out.addAll(flat) + } else { + out.add(flat) + } + changed = true + continue + } + if ((item is Array<*> || item is Collection<*>) && flat is Collection<*> && flat.all { it is String }) { + out.addAll(flat) + changed = true + continue + } + if (flat !== item) { + changed = true + } + out.add(flat) + } + return if (changed) out else value + } } diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig index 961874d154..6b122448be 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/Query.kt.twig @@ -220,6 +220,377 @@ class Query( fun notTouches(attribute: String, values: List) = Query("notTouches", attribute, listOf(values)).toJson() + /** + * Count rows of [attribute], optionally aliased. + * + * @param attribute The attribute to count. Defaults to `*`. + * @param alias Optional result alias. + * @returns The query string. + */ + fun count(attribute: String = "*", alias: String = "") = Query("count", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Count distinct values of [attribute], optionally aliased. + * + * @param attribute The attribute to count. + * @param alias Optional result alias. + * @returns The query string. + */ + fun countDistinct(attribute: String, alias: String = "") = Query("countDistinct", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Sum values of [attribute], optionally aliased. + * + * @param attribute The attribute to sum. + * @param alias Optional result alias. + * @returns The query string. + */ + fun sum(attribute: String, alias: String = "") = Query("sum", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Average values of [attribute], optionally aliased. + * + * @param attribute The attribute to average. + * @param alias Optional result alias. + * @returns The query string. + */ + fun avg(attribute: String, alias: String = "") = Query("avg", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Minimum value of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun min(attribute: String, alias: String = "") = Query("min", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Maximum value of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun max(attribute: String, alias: String = "") = Query("max", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Standard deviation of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun stddev(attribute: String, alias: String = "") = Query("stddev", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Population standard deviation of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun stddevPop(attribute: String, alias: String = "") = Query("stddevPop", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Sample standard deviation of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun stddevSamp(attribute: String, alias: String = "") = Query("stddevSamp", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Variance of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun variance(attribute: String, alias: String = "") = Query("variance", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Population variance of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun varPop(attribute: String, alias: String = "") = Query("varPop", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Sample variance of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun varSamp(attribute: String, alias: String = "") = Query("varSamp", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Bitwise AND of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun bitAnd(attribute: String, alias: String = "") = Query("bitAnd", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Bitwise OR of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun bitOr(attribute: String, alias: String = "") = Query("bitOr", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Bitwise XOR of [attribute], optionally aliased. + * + * @param attribute The attribute to inspect. + * @param alias Optional result alias. + * @returns The query string. + */ + fun bitXor(attribute: String, alias: String = "") = Query("bitXor", attribute, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Group results by [attributes]. + * + * @param attributes Attributes to group by. + * @returns The query string. + */ + fun groupBy(attributes: List) = Query("groupBy", null, attributes).toJson() + + /** + * Filter grouped results. [queries] are nested like `and`/`or`. + * + * @param queries Nested query strings. + * @returns The query string. + */ + fun having(queries: List) = Query("having", null, queries.map { it.fromJson() }).toJson() + + /** + * Select distinct rows. + * + * @returns The query string. + */ + fun distinct() = Query("distinct").toJson() + + /** + * Column-to-column join ON condition. + * + * @param left Left-hand column. + * @param right Right-hand column. + * @param operator Comparison operator. Defaults to `=`. + * @returns The query string. + */ + fun on(left: String, right: String, operator: String = "=") = Query("on", values = listOf(left, operator, right)).toJson() + + /** + * Inner join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun join(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("join", table, left, right, operator, alias) + + /** + * Inner join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun join(table: String, on: List) = nestedJoin("join", table, "", on) + + /** + * Inner join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun join(table: String, alias: String, on: List) = nestedJoin("join", table, alias, on) + + /** + * Left join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun leftJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("leftJoin", table, left, right, operator, alias) + + /** + * Left join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun leftJoin(table: String, on: List) = nestedJoin("leftJoin", table, "", on) + + /** + * Left join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun leftJoin(table: String, alias: String, on: List) = nestedJoin("leftJoin", table, alias, on) + + /** + * Right join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun rightJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("rightJoin", table, left, right, operator, alias) + + /** + * Right join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun rightJoin(table: String, on: List) = nestedJoin("rightJoin", table, "", on) + + /** + * Right join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun rightJoin(table: String, alias: String, on: List) = nestedJoin("rightJoin", table, alias, on) + + /** + * Full outer join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param left Left-hand join column. + * @param right Right-hand join column. + * @param operator Comparison operator. Defaults to `=`. + * @param alias Optional table alias. + * @returns The query string. + */ + fun fullOuterJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = joinQuery("fullOuterJoin", table, left, right, operator, alias) + + /** + * Full outer join with nested ON queries and no alias. + * + * @param table Joined table ID. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun fullOuterJoin(table: String, on: List) = nestedJoin("fullOuterJoin", table, "", on) + + /** + * Full outer join with nested ON queries and an alias. + * + * @param table Joined table ID. + * @param alias Table alias. + * @param on Nested ON queries, including [on]. + * @returns The query string. + */ + fun fullOuterJoin(table: String, alias: String, on: List) = nestedJoin("fullOuterJoin", table, alias, on) + + /** + * Cross join. [table] is the joined collection/table ID. + * + * @param table Joined table ID. + * @param alias Optional table alias. + * @returns The query string. + */ + fun crossJoin(table: String, alias: String = "") = Query("crossJoin", table, if (alias.isNotEmpty()) listOf(alias) else null).toJson() + + /** + * Filter resources where [attribute] covers the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun covers(attribute: String, values: List) = Query("covers", attribute, listOf(values)).toJson() + + /** + * Filter resources where [attribute] does not cover the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun notCovers(attribute: String, values: List) = Query("notCovers", attribute, listOf(values)).toJson() + + /** + * Filter resources where [attribute] is spatially equal to the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun spatialEquals(attribute: String, values: List) = Query("spatialEquals", attribute, listOf(values)).toJson() + + /** + * Filter resources where [attribute] is not spatially equal to the given geometry. + * + * @param attribute The attribute to filter on. + * @param values The coordinate values. + * @returns The query string. + */ + fun notSpatialEquals(attribute: String, values: List) = Query("notSpatialEquals", attribute, listOf(values)).toJson() + + /** + * Return limit and offset queries for 1-based page pagination. + * + * @param page 1-based page number. + * @param perPage Rows per page. Defaults to 25. + * @returns Limit and offset query strings. + */ + fun page(page: Int, perPage: Int = 25): List { + require(page >= 1) { "Page must be >= 1, got $page" } + require(perPage >= 1) { "Per page must be >= 1, got $perPage" } + return listOf(limit(perPage), offset((page - 1) * perPage)) + } + + /** + * Start a fluent query builder. + * + * @returns A new query builder. + */ + fun builder() = Builder() + + private fun joinQuery(method: String, table: String, left: String, right: String, operator: String, alias: String): String { + val values = if (alias.isNotEmpty()) listOf(left, operator, right, alias) else listOf(left, operator, right) + return Query(method, table, values).toJson() + } + + private fun nestedJoin(method: String, table: String, alias: String, on: List): String { + val values = mutableListOf() + if (alias.isNotEmpty()) { + values.add(alias) + } + on.forEach { values.add(it.fromJson()) } + return Query(method, table, values).toJson() + } + private fun parseValue(value: Any): List { return when (value) { is List<*> -> value as List @@ -227,4 +598,196 @@ class Query( } } } + + class Builder { + private val queries = mutableListOf() + + fun build(): List = queries.toList() + + fun equal(attribute: String, value: Any) = push(Query.equal(attribute, value)) + + fun notEqual(attribute: String, value: Any) = push(Query.notEqual(attribute, value)) + + fun lessThan(attribute: String, value: Any) = push(Query.lessThan(attribute, value)) + + fun lessThanEqual(attribute: String, value: Any) = push(Query.lessThanEqual(attribute, value)) + + fun greaterThan(attribute: String, value: Any) = push(Query.greaterThan(attribute, value)) + + fun greaterThanEqual(attribute: String, value: Any) = push(Query.greaterThanEqual(attribute, value)) + + fun search(attribute: String, value: String) = push(Query.search(attribute, value)) + + fun isNull(attribute: String) = push(Query.isNull(attribute)) + + fun isNotNull(attribute: String) = push(Query.isNotNull(attribute)) + + fun between(attribute: String, start: Any, end: Any) = push(Query.between(attribute, start, end)) + + fun startsWith(attribute: String, value: String) = push(Query.startsWith(attribute, value)) + + fun endsWith(attribute: String, value: String) = push(Query.endsWith(attribute, value)) + + fun select(attributes: List) = push(Query.select(attributes)) + + fun cursorAfter(documentId: String) = push(Query.cursorAfter(documentId)) + + fun cursorBefore(documentId: String) = push(Query.cursorBefore(documentId)) + + fun orderAsc(attribute: String) = push(Query.orderAsc(attribute)) + + fun orderDesc(attribute: String) = push(Query.orderDesc(attribute)) + + fun orderRandom() = push(Query.orderRandom()) + + fun limit(limit: Int) = push(Query.limit(limit)) + + fun offset(offset: Int) = push(Query.offset(offset)) + + fun contains(attribute: String, value: Any) = push(Query.contains(attribute, value)) + + fun containsAny(attribute: String, value: List) = push(Query.containsAny(attribute, value)) + + fun containsAll(attribute: String, value: List) = push(Query.containsAll(attribute, value)) + + fun notContains(attribute: String, value: Any) = push(Query.notContains(attribute, value)) + + fun notSearch(attribute: String, value: String) = push(Query.notSearch(attribute, value)) + + fun notBetween(attribute: String, start: Any, end: Any) = push(Query.notBetween(attribute, start, end)) + + fun notStartsWith(attribute: String, value: String) = push(Query.notStartsWith(attribute, value)) + + fun notEndsWith(attribute: String, value: String) = push(Query.notEndsWith(attribute, value)) + + fun regex(attribute: String, pattern: String) = push(Query.regex(attribute, pattern)) + + fun exists(attributes: List) = push(Query.exists(attributes)) + + fun notExists(attributes: List) = push(Query.notExists(attributes)) + + fun createdBefore(value: String) = push(Query.createdBefore(value)) + + fun createdAfter(value: String) = push(Query.createdAfter(value)) + + fun createdBetween(start: String, end: String) = push(Query.createdBetween(start, end)) + + fun updatedBefore(value: String) = push(Query.updatedBefore(value)) + + fun updatedAfter(value: String) = push(Query.updatedAfter(value)) + + fun updatedBetween(start: String, end: String) = push(Query.updatedBetween(start, end)) + + fun or(queries: List) = push(Query.or(queries)) + + fun and(queries: List) = push(Query.and(queries)) + + fun elemMatch(attribute: String, queries: List) = push(Query.elemMatch(attribute, queries)) + + fun distanceEqual(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceEqual(attribute, values, distance, meters)) + + fun distanceNotEqual(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceNotEqual(attribute, values, distance, meters)) + + fun distanceGreaterThan(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceGreaterThan(attribute, values, distance, meters)) + + fun distanceLessThan(attribute: String, values: List, distance: Number, meters: Boolean = true) = push(Query.distanceLessThan(attribute, values, distance, meters)) + + fun intersects(attribute: String, values: List) = push(Query.intersects(attribute, values)) + + fun notIntersects(attribute: String, values: List) = push(Query.notIntersects(attribute, values)) + + fun crosses(attribute: String, values: List) = push(Query.crosses(attribute, values)) + + fun notCrosses(attribute: String, values: List) = push(Query.notCrosses(attribute, values)) + + fun overlaps(attribute: String, values: List) = push(Query.overlaps(attribute, values)) + + fun notOverlaps(attribute: String, values: List) = push(Query.notOverlaps(attribute, values)) + + fun touches(attribute: String, values: List) = push(Query.touches(attribute, values)) + + fun notTouches(attribute: String, values: List) = push(Query.notTouches(attribute, values)) + + fun count(attribute: String = "*", alias: String = "") = push(Query.count(attribute, alias)) + + fun countDistinct(attribute: String, alias: String = "") = push(Query.countDistinct(attribute, alias)) + + fun sum(attribute: String, alias: String = "") = push(Query.sum(attribute, alias)) + + fun avg(attribute: String, alias: String = "") = push(Query.avg(attribute, alias)) + + fun min(attribute: String, alias: String = "") = push(Query.min(attribute, alias)) + + fun max(attribute: String, alias: String = "") = push(Query.max(attribute, alias)) + + fun stddev(attribute: String, alias: String = "") = push(Query.stddev(attribute, alias)) + + fun stddevPop(attribute: String, alias: String = "") = push(Query.stddevPop(attribute, alias)) + + fun stddevSamp(attribute: String, alias: String = "") = push(Query.stddevSamp(attribute, alias)) + + fun variance(attribute: String, alias: String = "") = push(Query.variance(attribute, alias)) + + fun varPop(attribute: String, alias: String = "") = push(Query.varPop(attribute, alias)) + + fun varSamp(attribute: String, alias: String = "") = push(Query.varSamp(attribute, alias)) + + fun bitAnd(attribute: String, alias: String = "") = push(Query.bitAnd(attribute, alias)) + + fun bitOr(attribute: String, alias: String = "") = push(Query.bitOr(attribute, alias)) + + fun bitXor(attribute: String, alias: String = "") = push(Query.bitXor(attribute, alias)) + + fun groupBy(attributes: List) = push(Query.groupBy(attributes)) + + fun having(queries: List) = push(Query.having(queries)) + + fun distinct() = push(Query.distinct()) + + fun join(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.join(table, left, right, operator, alias)) + + fun join(table: String, on: List) = push(Query.join(table, on)) + + fun join(table: String, alias: String, on: List) = push(Query.join(table, alias, on)) + + fun leftJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.leftJoin(table, left, right, operator, alias)) + + fun leftJoin(table: String, on: List) = push(Query.leftJoin(table, on)) + + fun leftJoin(table: String, alias: String, on: List) = push(Query.leftJoin(table, alias, on)) + + fun rightJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.rightJoin(table, left, right, operator, alias)) + + fun rightJoin(table: String, on: List) = push(Query.rightJoin(table, on)) + + fun rightJoin(table: String, alias: String, on: List) = push(Query.rightJoin(table, alias, on)) + + fun fullOuterJoin(table: String, left: String, right: String, operator: String = "=", alias: String = "") = push(Query.fullOuterJoin(table, left, right, operator, alias)) + + fun fullOuterJoin(table: String, on: List) = push(Query.fullOuterJoin(table, on)) + + fun fullOuterJoin(table: String, alias: String, on: List) = push(Query.fullOuterJoin(table, alias, on)) + + fun crossJoin(table: String, alias: String = "") = push(Query.crossJoin(table, alias)) + + fun vectorDot(attribute: String, vector: List) = push(Query.vectorDot(attribute, vector)) + + fun vectorCosine(attribute: String, vector: List) = push(Query.vectorCosine(attribute, vector)) + + fun vectorEuclidean(attribute: String, vector: List) = push(Query.vectorEuclidean(attribute, vector)) + + fun covers(attribute: String, values: List) = push(Query.covers(attribute, values)) + + fun notCovers(attribute: String, values: List) = push(Query.notCovers(attribute, values)) + + fun spatialEquals(attribute: String, values: List) = push(Query.spatialEquals(attribute, values)) + + fun notSpatialEquals(attribute: String, values: List) = push(Query.notSpatialEquals(attribute, values)) + + fun page(page: Int, perPage: Int = 25) = push(Query.page(page, perPage)) + + private fun push(query: String) = apply { queries.add(query) } + + private fun push(query: List) = apply { queries.addAll(query) } + } } diff --git a/templates/kotlin/src/test/kotlin/io/appwrite/JsonRequestBodyTest.kt.twig b/templates/kotlin/src/test/kotlin/io/appwrite/JsonRequestBodyTest.kt.twig index 27d10f5d63..82d3a891f1 100644 --- a/templates/kotlin/src/test/kotlin/io/appwrite/JsonRequestBodyTest.kt.twig +++ b/templates/kotlin/src/test/kotlin/io/appwrite/JsonRequestBodyTest.kt.twig @@ -55,6 +55,25 @@ class JsonRequestBodyTest { assertFalse(body.contains("permissions")) } + @Test + fun jsonBodySendsBuiltQueries() = runBlocking { + val request = Client().prepareRequest( + method = "POST", + path = "/v1/tablesdb/db/tables/tbl/rows", + headers = mapOf("content-type" to "application/json"), + params = mapOf( + "queries" to Query.builder().equal("title", "Spiderman").limit(1), + "permissions" to null, + ), + ) + + val body = requestBodyUtf8(request.body!!) + assertFalse(body.contains("\"queries\":{")) + assertTrue(body.contains("\"queries\":[\"{\\\"method\\\":\\\"equal\\\"")) + assertTrue(body.contains("\\\"method\\\":\\\"limit\\\"")) + assertFalse(body.contains("permissions")) + } + @Test fun getOmitsNullQueryParams() = runBlocking { val request = Client().prepareRequest( diff --git a/templates/kotlin/src/test/kotlin/io/appwrite/QueryTest.kt.twig b/templates/kotlin/src/test/kotlin/io/appwrite/QueryTest.kt.twig new file mode 100644 index 0000000000..72588a736c --- /dev/null +++ b/templates/kotlin/src/test/kotlin/io/appwrite/QueryTest.kt.twig @@ -0,0 +1,154 @@ +package {{ sdk.namespace | caseDot }} + +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Test + +class QueryTest { + private fun method(json: String): String = + JsonParser.parseString(json).asJsonObject.get("method").asString + + @Test + fun countDistinct() { + assertEquals("countDistinct", method(Query.countDistinct("year", "uniqueYears"))) + } + + @Test + fun sum() { + assertEquals("sum", method(Query.sum("price", "total"))) + } + + @Test + fun avg() { + assertEquals("avg", method(Query.avg("price", "avgPrice"))) + } + + @Test + fun min() { + assertEquals("min", method(Query.min("price", "lowest"))) + } + + @Test + fun max() { + assertEquals("max", method(Query.max("price", "highest"))) + } + + @Test + fun stddev() { + assertEquals("stddev", method(Query.stddev("price", "sd"))) + } + + @Test + fun stddevPop() { + assertEquals("stddevPop", method(Query.stddevPop("price", "sdp"))) + } + + @Test + fun stddevSamp() { + assertEquals("stddevSamp", method(Query.stddevSamp("price", "sds"))) + } + + @Test + fun variance() { + assertEquals("variance", method(Query.variance("price", "var"))) + } + + @Test + fun varPop() { + assertEquals("varPop", method(Query.varPop("price", "vp"))) + } + + @Test + fun varSamp() { + assertEquals("varSamp", method(Query.varSamp("price", "vs"))) + } + + @Test + fun bitAnd() { + assertEquals("bitAnd", method(Query.bitAnd("flags", "band"))) + } + + @Test + fun bitOr() { + assertEquals("bitOr", method(Query.bitOr("flags", "bor"))) + } + + @Test + fun bitXor() { + assertEquals("bitXor", method(Query.bitXor("flags", "bxor"))) + } + + @Test + fun having() { + val json = JsonParser.parseString(Query.having(listOf(Query.greaterThan("total", 1)))).asJsonObject + assertEquals("having", json.get("method").asString) + assertEquals("greaterThan", json.get("values").asJsonArray[0].asJsonObject.get("method").asString) + } + + @Test + fun leftJoin() { + assertEquals("leftJoin", method(Query.leftJoin("orders", "\$id", "customerId", "=", "ord"))) + } + + @Test + fun rightJoin() { + assertEquals("rightJoin", method(Query.rightJoin("orders", "\$id", "customerId"))) + } + + @Test + fun fullOuterJoin() { + assertEquals("fullOuterJoin", method(Query.fullOuterJoin("orders", "\$id", "customerId"))) + } + + @Test + fun crossJoin() { + assertEquals("crossJoin", method(Query.crossJoin("orders", "ord"))) + } + + @Test + fun on() { + assertEquals("on", method(Query.on("\$id", "customerId"))) + } + + @Test + fun nestedLeftJoin() { + val json = JsonParser.parseString( + Query.leftJoin( + "orders", + "ord", + listOf( + Query.on("\$id", "customerId"), + Query.equal("ord.status", "paid"), + ) + ) + ).asJsonObject + assertEquals("leftJoin", json.get("method").asString) + val values = json.get("values").asJsonArray + assertEquals("ord", values[0].asString) + assertEquals("on", values[1].asJsonObject.get("method").asString) + assertEquals("equal", values[2].asJsonObject.get("method").asString) + } + + @Test + fun notCovers() { + assertEquals("notCovers", method(Query.notCovers("location", listOf(1, 2)))) + } + + @Test + fun spatialEquals() { + assertEquals("spatialEquals", method(Query.spatialEquals("location", listOf(1, 2)))) + } + + @Test + fun notSpatialEquals() { + assertEquals("notSpatialEquals", method(Query.notSpatialEquals("location", listOf(1, 2)))) + } + + @Test + fun page() { + val queries = Query.page(2, 10) + assertEquals(2, queries.size) + assertEquals("limit", method(queries[0])) + assertEquals("offset", method(queries[1])) + } +} diff --git a/templates/node/src/client.ts.twig b/templates/node/src/client.ts.twig index 8ed155e5ec..6f2259dcd7 100644 --- a/templates/node/src/client.ts.twig +++ b/templates/node/src/client.ts.twig @@ -1,6 +1,7 @@ import { fetch, FormData, File, Agent, type RequestInit } from 'undici'; import { Models } from './models'; import { InputFile } from './inputFile'; +import { flattenParam } from './query'; import JSONbigModule from 'json-bigint'; const JSONbigParser = JSONbigModule({ storeAsString: false }); const JSONbigSerializer = JSONbigModule({ useNativeBigInt: true }); @@ -295,6 +296,10 @@ class Client { ): { uri: string; options: RequestInit } { method = method.toUpperCase(); + for (const key of Object.keys(params)) { + params[key] = flattenParam(params[key]); + } + headers = Object.assign({}, this.headers, headers); const options: RequestInit = { @@ -796,6 +801,6 @@ class Client { } export { Client, {{spec.info.title | caseUcfirst}}Exception }; -export { Query } from './query'; +export { Query, QueryBuilder, flattenParam } from './query'; export type { Models, Payload, UploadProgress }; -export type { QueryTypes, QueryTypesList } from './query'; +export type { QueryTypes, QueryTypesList, QueryInput } from './query'; diff --git a/templates/node/src/index.ts.twig b/templates/node/src/index.ts.twig index 63df5f3652..c30dc16606 100644 --- a/templates/node/src/index.ts.twig +++ b/templates/node/src/index.ts.twig @@ -1,9 +1,15 @@ -export { Client, Query, {{spec.info.title | caseUcfirst}}Exception } from './client'; +export { + Client, + Query, + QueryBuilder, + flattenParam, + {{spec.info.title | caseUcfirst}}Exception, +} from './client'; {% for service in services %} export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseKebab}}'; {% endfor %} export type { Models, Payload, UploadProgress } from './client'; -export type { QueryTypes, QueryTypesList } from './query'; +export type { QueryTypes, QueryTypesList, QueryInput } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; diff --git a/templates/node/test/client.test.js.twig b/templates/node/test/client.test.js.twig index a8bec62234..bfbbda8f63 100644 --- a/templates/node/test/client.test.js.twig +++ b/templates/node/test/client.test.js.twig @@ -1,5 +1,6 @@ const { Client } = require('../dist/client'); const { InputFile } = require('../dist/inputFile'); +const { Query } = require('../dist/query'); const { fetch: mockedFetch, Response, Dispatcher } = require('undici'); jest.mock('undici', () => ({ @@ -45,6 +46,58 @@ describe('Client', () => { }); }); +describe('Client.prepareRequest', () => { + const url = () => new URL('https://cloud.appwrite.io/v1/rows'); + const params = () => ({ + data: [['a', 'b']], + queries: Query.builder().equal('status', 'active').limit(1), + filters: [Query.equal('status', 'active'), Query.builder().limit(1)], + }); + const queries = [Query.equal('status', 'active'), Query.limit(1)]; + + test('sends nested string arrays and flattened builders as JSON', () => { + const { options } = new Client().prepareRequest( + 'POST', + url(), + { 'content-type': 'application/json' }, + params(), + ); + + expect(JSON.parse(options.body)).toEqual({ + data: [['a', 'b']], + queries, + filters: queries, + }); + }); + + test('sends nested string arrays and flattened builders as query string', () => { + const { uri } = new Client().prepareRequest('GET', url(), {}, params()); + const search = new URL(uri).searchParams; + + expect(search.get('data[0][0]')).toBe('a'); + expect(search.get('data[0][1]')).toBe('b'); + expect(search.has('data[0]')).toBe(false); + expect([search.get('queries[0]'), search.get('queries[1]')]).toEqual( + queries, + ); + expect([search.get('filters[0]'), search.get('filters[1]')]).toEqual( + queries, + ); + }); + + test('sends flattened builders as multipart fields', () => { + const { options } = new Client().prepareRequest( + 'POST', + url(), + { 'content-type': 'multipart/form-data' }, + params(), + ); + + expect(options.body.getAll('queries[]')).toEqual(queries); + expect(options.body.getAll('filters[]')).toEqual(queries); + }); +}); + describe('Client.chunkedUpload', () => { const url = new URL( 'https://cloud.appwrite.io/v1/storage/buckets/bucket/files', diff --git a/templates/node/test/query.test.js.twig b/templates/node/test/query.test.js.twig index 52e9490cd5..53c3bc898b 100644 --- a/templates/node/test/query.test.js.twig +++ b/templates/node/test/query.test.js.twig @@ -1,35 +1,37 @@ -const { Query } = require('../dist/query'); +const { Query, normalizeQueries, flattenParam } = require('../dist/query'); + +const expectQuery = (query) => expect(JSON.parse(query)); const tests = [ { description: 'with a string', value: 's', - expectedValues: '["s"]', + expectedValues: ['s'], }, { description: 'with a integer', value: 1, - expectedValues: '[1]', + expectedValues: [1], }, { description: 'with a double', value: 1.2, - expectedValues: '[1.2]', + expectedValues: [1.2], }, { description: 'with a whole number double', value: 1.0, - expectedValues: '[1]', + expectedValues: [1], }, { description: 'with a bool', value: false, - expectedValues: '[false]', + expectedValues: [false], }, { description: 'with a list', value: ['a', 'b', 'c'], - expectedValues: '["a","b","c"]', + expectedValues: ['a', 'b', 'c'], }, ]; @@ -37,9 +39,11 @@ describe('Query', () => { describe('basic filter equal', () => { for (const t of tests) { test(t.description, () => - expect(Query.equal('attr', t.value)).toEqual( - `{"method":"equal","attribute":"attr","values":${t.expectedValues}}`, - ), + expectQuery(Query.equal('attr', t.value)).toEqual({ + method: 'equal', + attribute: 'attr', + values: t.expectedValues, + }), ); } }); @@ -47,9 +51,11 @@ describe('Query', () => { describe('basic filter notEqual', () => { for (const t of tests) { test(t.description, () => - expect(Query.notEqual('attr', t.value)).toEqual( - `{"method":"notEqual","attribute":"attr","values":${t.expectedValues}}`, - ), + expectQuery(Query.notEqual('attr', t.value)).toEqual({ + method: 'notEqual', + attribute: 'attr', + values: t.expectedValues, + }), ); } }); @@ -57,9 +63,11 @@ describe('Query', () => { describe('basic filter lessThan', () => { for (const t of tests) { test(t.description, () => - expect(Query.lessThan('attr', t.value)).toEqual( - `{"method":"lessThan","attribute":"attr","values":${t.expectedValues}}`, - ), + expectQuery(Query.lessThan('attr', t.value)).toEqual({ + method: 'lessThan', + attribute: 'attr', + values: t.expectedValues, + }), ); } }); @@ -67,9 +75,11 @@ describe('Query', () => { describe('basic filter lessThanEqual', () => { for (const t of tests) { test(t.description, () => - expect(Query.lessThanEqual('attr', t.value)).toEqual( - `{"method":"lessThanEqual","attribute":"attr","values":${t.expectedValues}}`, - ), + expectQuery(Query.lessThanEqual('attr', t.value)).toEqual({ + method: 'lessThanEqual', + attribute: 'attr', + values: t.expectedValues, + }), ); } }); @@ -77,9 +87,11 @@ describe('Query', () => { describe('basic filter greaterThan', () => { for (const t of tests) { test(t.description, () => - expect(Query.greaterThan('attr', t.value)).toEqual( - `{"method":"greaterThan","attribute":"attr","values":${t.expectedValues}}`, - ), + expectQuery(Query.greaterThan('attr', t.value)).toEqual({ + method: 'greaterThan', + attribute: 'attr', + values: t.expectedValues, + }), ); } }); @@ -87,71 +99,396 @@ describe('Query', () => { describe('basic filter greaterThanEqual', () => { for (const t of tests) { test(t.description, () => - expect(Query.greaterThanEqual('attr', t.value)).toEqual( - `{"method":"greaterThanEqual","attribute":"attr","values":${t.expectedValues}}`, - ), + expectQuery(Query.greaterThanEqual('attr', t.value)).toEqual({ + method: 'greaterThanEqual', + attribute: 'attr', + values: t.expectedValues, + }), ); } }); test('search', () => - expect(Query.search('attr', 'keyword1 keyword2')).toEqual( - `{"method":"search","attribute":"attr","values":["keyword1 keyword2"]}`, - )); + expectQuery(Query.search('attr', 'keyword1 keyword2')).toEqual({ + method: 'search', + attribute: 'attr', + values: ['keyword1 keyword2'], + })); test('isNull', () => - expect(Query.isNull('attr')).toEqual( - `{"method":"isNull","attribute":"attr"}`, - )); + expectQuery(Query.isNull('attr')).toEqual({ + method: 'isNull', + attribute: 'attr', + })); test('isNotNull', () => - expect(Query.isNotNull('attr')).toEqual( - `{"method":"isNotNull","attribute":"attr"}`, - )); + expectQuery(Query.isNotNull('attr')).toEqual({ + method: 'isNotNull', + attribute: 'attr', + })); describe('between', () => { test('with integers', () => - expect(Query.between('attr', 1, 2)).toEqual( - `{"method":"between","attribute":"attr","values":[1,2]}`, - )); + expectQuery(Query.between('attr', 1, 2)).toEqual({ + method: 'between', + attribute: 'attr', + values: [1, 2], + })); test('with doubles', () => - expect(Query.between('attr', 1.2, 2.2)).toEqual( - `{"method":"between","attribute":"attr","values":[1.2,2.2]}`, - )); + expectQuery(Query.between('attr', 1.2, 2.2)).toEqual({ + method: 'between', + attribute: 'attr', + values: [1.2, 2.2], + })); test('with strings', () => - expect(Query.between('attr', 'a', 'z')).toEqual( - `{"method":"between","attribute":"attr","values":["a","z"]}`, - )); + expectQuery(Query.between('attr', 'a', 'z')).toEqual({ + method: 'between', + attribute: 'attr', + values: ['a', 'z'], + })); }); test('select', () => - expect(Query.select(['attr1', 'attr2'])).toEqual( - `{"method":"select","values":["attr1","attr2"]}`, - )); + expectQuery(Query.select(['attr1', 'attr2'])).toEqual({ + method: 'select', + values: ['attr1', 'attr2'], + })); test('orderAsc', () => - expect(Query.orderAsc('attr')).toEqual( - `{"method":"orderAsc","attribute":"attr"}`, - )); + expectQuery(Query.orderAsc('attr')).toEqual({ + method: 'orderAsc', + attribute: 'attr', + })); test('orderDesc', () => - expect(Query.orderDesc('attr')).toEqual( - `{"method":"orderDesc","attribute":"attr"}`, - )); + expectQuery(Query.orderDesc('attr')).toEqual({ + method: 'orderDesc', + attribute: 'attr', + })); test('cursorBefore', () => - expect(Query.cursorBefore('attr')).toEqual( - '{"method":"cursorBefore","values":["attr"]}', - )); + expectQuery(Query.cursorBefore('attr')).toEqual({ + method: 'cursorBefore', + values: ['attr'], + })); test('cursorAfter', () => - expect(Query.cursorAfter('attr')).toEqual( - '{"method":"cursorAfter","values":["attr"]}', - )); + expectQuery(Query.cursorAfter('attr')).toEqual({ + method: 'cursorAfter', + values: ['attr'], + })); test('limit', () => - expect(Query.limit(1)).toEqual('{"method":"limit","values":[1]}')); + expectQuery(Query.limit(1)).toEqual({ method: 'limit', values: [1] })); test('offset', () => - expect(Query.offset(1)).toEqual('{"method":"offset","values":[1]}')); + expectQuery(Query.offset(1)).toEqual({ + method: 'offset', + values: [1], + })); + + test('count with alias', () => + expectQuery(Query.count('*', 'total')).toEqual({ + method: 'count', + attribute: '*', + values: ['total'], + })); + + test('count without alias', () => + expectQuery(Query.count()).toEqual({ + method: 'count', + attribute: '*', + })); + + test('countDistinct', () => + expectQuery(Query.countDistinct('year', 'uniqueYears')).toEqual({ + method: 'countDistinct', + attribute: 'year', + values: ['uniqueYears'], + })); + + test('sum', () => + expectQuery(Query.sum('price', 'total')).toEqual({ + method: 'sum', + attribute: 'price', + values: ['total'], + })); + + test('avg', () => + expectQuery(Query.avg('price', 'avgPrice')).toEqual({ + method: 'avg', + attribute: 'price', + values: ['avgPrice'], + })); + + test('min', () => + expectQuery(Query.min('price', 'lowest')).toEqual({ + method: 'min', + attribute: 'price', + values: ['lowest'], + })); + + test('max', () => + expectQuery(Query.max('price', 'highest')).toEqual({ + method: 'max', + attribute: 'price', + values: ['highest'], + })); + + test('stddev', () => + expectQuery(Query.stddev('price', 'sd')).toEqual({ + method: 'stddev', + attribute: 'price', + values: ['sd'], + })); + + test('stddevPop', () => + expectQuery(Query.stddevPop('price', 'sdp')).toEqual({ + method: 'stddevPop', + attribute: 'price', + values: ['sdp'], + })); + + test('stddevSamp', () => + expectQuery(Query.stddevSamp('price', 'sds')).toEqual({ + method: 'stddevSamp', + attribute: 'price', + values: ['sds'], + })); + + test('variance', () => + expectQuery(Query.variance('price', 'var')).toEqual({ + method: 'variance', + attribute: 'price', + values: ['var'], + })); + + test('varPop', () => + expectQuery(Query.varPop('price', 'vp')).toEqual({ + method: 'varPop', + attribute: 'price', + values: ['vp'], + })); + + test('varSamp', () => + expectQuery(Query.varSamp('price', 'vs')).toEqual({ + method: 'varSamp', + attribute: 'price', + values: ['vs'], + })); + + test('bitAnd', () => + expectQuery(Query.bitAnd('flags', 'band')).toEqual({ + method: 'bitAnd', + attribute: 'flags', + values: ['band'], + })); + + test('bitOr', () => + expectQuery(Query.bitOr('flags', 'bor')).toEqual({ + method: 'bitOr', + attribute: 'flags', + values: ['bor'], + })); + + test('bitXor', () => + expectQuery(Query.bitXor('flags', 'bxor')).toEqual({ + method: 'bitXor', + attribute: 'flags', + values: ['bxor'], + })); + + test('groupBy', () => + expectQuery(Query.groupBy(['status', 'year'])).toEqual({ + method: 'groupBy', + values: ['status', 'year'], + })); + + test('having nests parsed queries', () => { + const having = JSON.parse( + Query.having([Query.greaterThan('total', 1)]), + ); + expect(having.method).toEqual('having'); + expect(having.values[0].method).toEqual('greaterThan'); + expect(having.values[0].attribute).toEqual('total'); + expect(having.values[0].values).toEqual([1]); + }); + + test('distinct', () => + expectQuery(Query.distinct()).toEqual({ method: 'distinct' })); + + test('join', () => + expectQuery(Query.join('orders', '$id', 'customerId')).toEqual({ + method: 'join', + attribute: 'orders', + values: ['$id', '=', 'customerId'], + })); + + test('leftJoin with alias', () => + expectQuery( + Query.leftJoin('orders', '$id', 'customerId', '=', 'ord'), + ).toEqual({ + method: 'leftJoin', + attribute: 'orders', + values: ['$id', '=', 'customerId', 'ord'], + })); + + test('rightJoin', () => + expectQuery(Query.rightJoin('orders', '$id', 'customerId')).toEqual({ + method: 'rightJoin', + attribute: 'orders', + values: ['$id', '=', 'customerId'], + })); + + test('fullOuterJoin', () => + expectQuery(Query.fullOuterJoin('orders', '$id', 'customerId')).toEqual( + { + method: 'fullOuterJoin', + attribute: 'orders', + values: ['$id', '=', 'customerId'], + }, + )); + + test('on', () => + expectQuery(Query.on('$id', 'customerId')).toEqual({ + method: 'on', + values: ['$id', '=', 'customerId'], + })); + + test('nested leftJoin', () => { + const query = JSON.parse( + Query.leftJoin('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ]), + ); + expect(query.method).toEqual('leftJoin'); + expect(query.attribute).toEqual('orders'); + expect(query.values[0]).toEqual('ord'); + expect(query.values[1].method).toEqual('on'); + expect(query.values[2].method).toEqual('equal'); + }); + + test('crossJoin', () => + expectQuery(Query.crossJoin('orders', 'ord')).toEqual({ + method: 'crossJoin', + attribute: 'orders', + values: ['ord'], + })); + + test('vectorDot', () => + expectQuery(Query.vectorDot('embedding', [0.1, 0.2])).toEqual({ + method: 'vectorDot', + attribute: 'embedding', + values: [[0.1, 0.2]], + })); + + test('notCovers', () => + expectQuery(Query.notCovers('location', [1, 2])).toEqual({ + method: 'notCovers', + attribute: 'location', + values: [[1, 2]], + })); + + test('spatialEquals', () => + expectQuery(Query.spatialEquals('location', [1, 2])).toEqual({ + method: 'spatialEquals', + attribute: 'location', + values: [[1, 2]], + })); + + test('notSpatialEquals', () => + expectQuery(Query.notSpatialEquals('location', [1, 2])).toEqual({ + method: 'notSpatialEquals', + attribute: 'location', + values: [[1, 2]], + })); + + test('page', () => { + const queries = Query.page(2, 10).map((query) => JSON.parse(query)); + expect(queries).toEqual([ + { method: 'limit', values: [10] }, + { method: 'offset', values: [10] }, + ]); + }); + + test('builder build', () => { + const built = Query.builder() + .equal('status', 'published') + .leftJoin('orders', '$id', 'customerId', '=', 'ord') + .count('*', 'total') + .groupBy(['status']) + .limit(25) + .build(); + + expect(built).toEqual([ + Query.equal('status', 'published'), + Query.leftJoin('orders', '$id', 'customerId', '=', 'ord'), + Query.count('*', 'total'), + Query.groupBy(['status']), + Query.limit(25), + ]); + }); + + test('normalize builder wrapped in array', () => { + const builder = Query.builder().equal('status', 'active').limit(10); + expect(normalizeQueries([builder])).toEqual(builder.build()); + expect(normalizeQueries([builder.build()])).toEqual(builder.build()); + expect(normalizeQueries(builder)).toEqual(builder.build()); + }); + + test('normalize page wrapped in array', () => { + expect( + normalizeQueries([ + Query.equal('status', 'active'), + Query.page(2, 10), + ]), + ).toEqual([ + Query.equal('status', 'active'), + Query.limit(10), + Query.offset(10), + ]); + }); + + test('flattenParam spreads wrapped builder', () => { + expect(flattenParam([Query.builder().limit(1)])).toEqual([ + Query.limit(1), + ]); + }); + + test('flattenParam keeps nested numeric arrays', () => { + const geometry = [ + [1, 2], + [3, 4], + ]; + expect(flattenParam(geometry)).toEqual(geometry); + }); + + test('flattenParam keeps query string list', () => { + expect(flattenParam([Query.limit(1)])).toEqual([Query.limit(1)]); + }); + + test('flattenParam keeps nested string arrays', () => { + const value = [['a', 'b']]; + expect(flattenParam(value)).toEqual([['a', 'b']]); + }); + + test('flattenParam builds a builder value', () => { + expect( + flattenParam(Query.builder().equal('status', 'active').limit(1)), + ).toEqual([Query.equal('status', 'active'), Query.limit(1)]); + }); + + test('flattenParam splices builders among query strings', () => { + expect( + flattenParam([ + Query.equal('status', 'active'), + Query.builder().limit(1).offset(2), + ]), + ).toEqual([ + Query.equal('status', 'active'), + Query.limit(1), + Query.offset(2), + ]); + }); }); diff --git a/templates/php/src/Client.php.twig b/templates/php/src/Client.php.twig index 8a4cc98870..8e08356de4 100644 --- a/templates/php/src/Client.php.twig +++ b/templates/php/src/Client.php.twig @@ -4,6 +4,7 @@ declare(strict_types=1); namespace {{ namespace | caseNamespace }}; +use {{ namespace | caseNamespace }}\Query\Builder as QueryBuilder; {% if globalHeaders | hasBearerAuth %} use Ahc\Jwt\JWT; {% endif %} @@ -284,6 +285,7 @@ class Client } {% endif %} $headers = array_merge($this->headers, $headers); + $params = \array_map(self::flatten(...), $params); $contentType = strtolower((string) ($headers['content-type'] ?? '')); if ($contentType === '') { unset($headers['content-type']); @@ -356,7 +358,7 @@ class Client * Flatten params array to PHP multiple format * */ - protected function flatten(array $data, string $prefix = ''): array + protected function flattenForm(array $data, string $prefix = ''): array { $output = []; @@ -364,7 +366,7 @@ class Client $finalKey = $prefix ? "{$prefix}[{$key}]" : $key; if (is_array($value)) { - $output = array_merge($output, $this->flatten($value, $finalKey)); + $output = array_merge($output, $this->flattenForm($value, $finalKey)); } else { $output[$finalKey] = $value; } @@ -390,6 +392,40 @@ class Client return $data; } + public static function flatten(mixed $value): mixed + { + if ($value instanceof QueryBuilder) { + return $value->build(); + } + if ($value instanceof Query) { + return $value->__toString(); + } + if (!\is_array($value) || !\array_is_list($value)) { + return $value; + } + + $out = []; + $changed = false; + foreach ($value as $item) { + $flat = self::flatten($item); + if ($item instanceof QueryBuilder || $item instanceof Query) { + if (\is_array($flat)) { + array_push($out, ...$flat); + } else { + $out[] = $flat; + } + $changed = true; + continue; + } + if ($flat !== $item) { + $changed = true; + } + $out[] = $flat; + } + + return $changed ? $out : $value; + } + /** * Convert flattened params into Utopia multipart parts. * @@ -400,7 +436,7 @@ class Client { $parts = []; - foreach ($this->flatten($data) as $name => $value) { + foreach ($this->flattenForm($data) as $name => $value) { $name = (string) $name; if ($value instanceof Part) { diff --git a/templates/php/src/Models/Model.php.twig b/templates/php/src/Models/Model.php.twig index 04b7b62be7..3c9002d0ab 100644 --- a/templates/php/src/Models/Model.php.twig +++ b/templates/php/src/Models/Model.php.twig @@ -119,7 +119,7 @@ readonly class {{ definitionName | caseUcfirst | overrideIdentifier }} static fn (mixed $item): mixed => static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $item), $data['{{ (property | schemaName) }}'] ) - : $data['{{ (property | schemaName) }}']{% elseif (property | enumValues) is not empty %}static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}']){% else %}$data['{{ (property | schemaName) }}']{% endif %}{{ (not loop.last or optionalProperties is not empty or definition.additionalProperties) ? ',' : '' }} + : [static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}'])]{% elseif (property | enumValues) is not empty %}static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}']){% else %}$data['{{ (property | schemaName) }}']{% endif %}{{ (not loop.last or optionalProperties is not empty or definition.additionalProperties) ? ',' : '' }} {% endfor %} {% for property in optionalProperties %} {% if (property | schemaType) == 'array' and (property | schemaModel) %} @@ -140,7 +140,7 @@ readonly class {{ definitionName | caseUcfirst | overrideIdentifier }} static fn (mixed $item): mixed => static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $item), $data['{{ (property | schemaName) }}'] ) - : $data['{{ (property | schemaName) }}'] + : [static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}'])] ) {% elseif (property | schemaModel) %} {{ (property | schemaName) | caseCamel }}: array_key_exists('{{ (property | schemaName) }}', $data) ? static::hydrateTypedValue({{ (property | schemaModel) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}'], true) : null{{ (not loop.last or definition.additionalProperties) ? ',' : '' }} diff --git a/templates/php/src/Models/RequestModel.php.twig b/templates/php/src/Models/RequestModel.php.twig index 9808cece3c..863189ec86 100644 --- a/templates/php/src/Models/RequestModel.php.twig +++ b/templates/php/src/Models/RequestModel.php.twig @@ -90,7 +90,7 @@ readonly class {{ requestModelName | caseUcfirst | overrideIdentifier }} static fn (mixed $item): mixed => static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $item), $data['{{ (property | schemaName) }}'] ) - : $data['{{ (property | schemaName) }}']{% elseif (property | enumValues) is not empty %}static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}']){% else %}$data['{{ (property | schemaName) }}']{% endif %}{{ (not loop.last or optionalProperties is not empty) ? ',' : '' }} + : [static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}'])]{% elseif (property | enumValues) is not empty %}static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}']){% else %}$data['{{ (property | schemaName) }}']{% endif %}{{ (not loop.last or optionalProperties is not empty) ? ',' : '' }} {% endfor %} {% for property in optionalProperties %} {{ (property | schemaName) | caseCamel }}: array_key_exists('{{ (property | schemaName) }}', $data) @@ -112,7 +112,7 @@ readonly class {{ requestModelName | caseUcfirst | overrideIdentifier }} static fn (mixed $item): mixed => static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $item), $data['{{ (property | schemaName) }}'] ) - : $data['{{ (property | schemaName) }}'] + : [static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}'])] ) {% elseif (property | enumValues) is not empty %} ? static::hydrateTypedValue({{ ((property | enumName)) | caseUcfirst | overrideIdentifier }}::class, $data['{{ (property | schemaName) }}'], true) diff --git a/templates/php/src/Query.php.twig b/templates/php/src/Query.php.twig index 51b15c048c..6ecd9d618f 100644 --- a/templates/php/src/Query.php.twig +++ b/templates/php/src/Query.php.twig @@ -4,13 +4,14 @@ declare(strict_types=1); namespace {{ namespace | caseNamespace }}; +use {{ namespace | caseNamespace }}\Query\Builder as QueryBuilder; + class Query implements \JsonSerializable, \Stringable { protected ?array $values; public function __construct(protected string $method, protected ?string $attribute = null, $values = null) { - if (is_null($values) || is_array($values)) { $this->values = $values; } else { @@ -34,7 +35,6 @@ class Query implements \JsonSerializable, \Stringable /** * Equal - * */ public static function equal(string $attribute, mixed $value): string { @@ -43,7 +43,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Equal - * */ public static function notEqual(string $attribute, mixed $value): string { @@ -52,7 +51,6 @@ class Query implements \JsonSerializable, \Stringable /** * Less Than - * */ public static function lessThan(string $attribute, mixed $value): string { @@ -61,7 +59,6 @@ class Query implements \JsonSerializable, \Stringable /** * Less Than or Equal - * */ public static function lessThanEqual(string $attribute, mixed $value): string { @@ -70,7 +67,6 @@ class Query implements \JsonSerializable, \Stringable /** * Greater Than - * */ public static function greaterThan(string $attribute, mixed $value): string { @@ -79,7 +75,6 @@ class Query implements \JsonSerializable, \Stringable /** * Greater Than or Equal - * */ public static function greaterThanEqual(string $attribute, mixed $value): string { @@ -88,7 +83,6 @@ class Query implements \JsonSerializable, \Stringable /** * Search - * */ public static function search(string $attribute, string $value): string { @@ -97,7 +91,6 @@ class Query implements \JsonSerializable, \Stringable /** * Is Null - * */ public static function isNull(string $attribute): string { @@ -106,7 +99,6 @@ class Query implements \JsonSerializable, \Stringable /** * Is Not Null - * */ public static function isNotNull(string $attribute): string { @@ -115,7 +107,6 @@ class Query implements \JsonSerializable, \Stringable /** * Between - * */ public static function between(string $attribute, mixed $start, mixed $end): string { @@ -124,7 +115,6 @@ class Query implements \JsonSerializable, \Stringable /** * Starts With - * */ public static function startsWith(string $attribute, string $value): string { @@ -133,7 +123,6 @@ class Query implements \JsonSerializable, \Stringable /** * Ends With - * */ public static function endsWith(string $attribute, string $value): string { @@ -142,7 +131,6 @@ class Query implements \JsonSerializable, \Stringable /** * Select - * */ public static function select(array $attributes): string { @@ -151,7 +139,6 @@ class Query implements \JsonSerializable, \Stringable /** * Cursor After - * */ public static function cursorAfter(string $documentId): string { @@ -160,7 +147,6 @@ class Query implements \JsonSerializable, \Stringable /** * Cursor Before - * */ public static function cursorBefore(string $documentId): string { @@ -169,7 +155,6 @@ class Query implements \JsonSerializable, \Stringable /** * Order Asc - * */ public static function orderAsc(string $attribute): string { @@ -178,7 +163,6 @@ class Query implements \JsonSerializable, \Stringable /** * Order Desc - * */ public static function orderDesc(string $attribute): string { @@ -187,7 +171,6 @@ class Query implements \JsonSerializable, \Stringable /** * Order Random - * */ public static function orderRandom(): string { @@ -196,7 +179,6 @@ class Query implements \JsonSerializable, \Stringable /** * Limit - * */ public static function limit(int $limit): string { @@ -205,7 +187,6 @@ class Query implements \JsonSerializable, \Stringable /** * Offset - * */ public static function offset(int $offset): string { @@ -253,7 +234,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Contains - * */ public static function notContains(string $attribute, $value): string { @@ -262,7 +242,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Search - * */ public static function notSearch(string $attribute, string $value): string { @@ -271,7 +250,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Between - * */ public static function notBetween(string $attribute, mixed $start, mixed $end): string { @@ -280,7 +258,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Starts With - * */ public static function notStartsWith(string $attribute, string $value): string { @@ -289,7 +266,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Ends With - * */ public static function notEndsWith(string $attribute, string $value): string { @@ -298,7 +274,6 @@ class Query implements \JsonSerializable, \Stringable /** * Filter resources where attribute matches a regular expression pattern. - * */ public static function regex(string $attribute, string $pattern): string { @@ -307,7 +282,6 @@ class Query implements \JsonSerializable, \Stringable /** * Filter resources where the specified attributes exist. - * */ public static function exists(array $attributes): string { @@ -316,7 +290,6 @@ class Query implements \JsonSerializable, \Stringable /** * Filter resources where the specified attributes do not exist. - * */ public static function notExists(array $attributes): string { @@ -325,7 +298,6 @@ class Query implements \JsonSerializable, \Stringable /** * Created Before - * */ public static function createdBefore(string $value): string { @@ -334,7 +306,6 @@ class Query implements \JsonSerializable, \Stringable /** * Created After - * */ public static function createdAfter(string $value): string { @@ -343,7 +314,6 @@ class Query implements \JsonSerializable, \Stringable /** * Created Between - * */ public static function createdBetween(string $start, string $end): string { @@ -352,7 +322,6 @@ class Query implements \JsonSerializable, \Stringable /** * Updated Before - * */ public static function updatedBefore(string $value): string { @@ -361,7 +330,6 @@ class Query implements \JsonSerializable, \Stringable /** * Updated After - * */ public static function updatedAfter(string $value): string { @@ -370,7 +338,6 @@ class Query implements \JsonSerializable, \Stringable /** * Updated Between - * */ public static function updatedBetween(string $start, string $end): string { @@ -379,7 +346,6 @@ class Query implements \JsonSerializable, \Stringable /** * Or - * */ public static function or(array $queries): string { @@ -391,7 +357,6 @@ class Query implements \JsonSerializable, \Stringable /** * And - * */ public static function and(array $queries): string { @@ -403,7 +368,6 @@ class Query implements \JsonSerializable, \Stringable /** * Filter array elements where at least one element matches all the specified queries. - * */ public static function elemMatch(string $attribute, array $queries): string { @@ -416,7 +380,6 @@ class Query implements \JsonSerializable, \Stringable /** * Distance Equal - * */ public static function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = true): string { @@ -425,7 +388,6 @@ class Query implements \JsonSerializable, \Stringable /** * Distance Not Equal - * */ public static function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = true): string { @@ -434,7 +396,6 @@ class Query implements \JsonSerializable, \Stringable /** * Distance Greater Than - * */ public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = true): string { @@ -443,7 +404,6 @@ class Query implements \JsonSerializable, \Stringable /** * Distance Less Than - * */ public static function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = true): string { @@ -452,7 +412,6 @@ class Query implements \JsonSerializable, \Stringable /** * Vector Dot - * */ public static function vectorDot(string $attribute, array $vector): string { @@ -461,7 +420,6 @@ class Query implements \JsonSerializable, \Stringable /** * Vector Cosine - * */ public static function vectorCosine(string $attribute, array $vector): string { @@ -470,7 +428,6 @@ class Query implements \JsonSerializable, \Stringable /** * Vector Euclidean - * */ public static function vectorEuclidean(string $attribute, array $vector): string { @@ -479,7 +436,6 @@ class Query implements \JsonSerializable, \Stringable /** * Intersects - * */ public static function intersects(string $attribute, array $values): string { @@ -488,7 +444,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Intersects - * */ public static function notIntersects(string $attribute, array $values): string { @@ -497,7 +452,6 @@ class Query implements \JsonSerializable, \Stringable /** * Crosses - * */ public static function crosses(string $attribute, array $values): string { @@ -506,7 +460,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Crosses - * */ public static function notCrosses(string $attribute, array $values): string { @@ -515,7 +468,6 @@ class Query implements \JsonSerializable, \Stringable /** * Overlaps - * */ public static function overlaps(string $attribute, array $values): string { @@ -524,7 +476,6 @@ class Query implements \JsonSerializable, \Stringable /** * Not Overlaps - * */ public static function notOverlaps(string $attribute, array $values): string { @@ -533,7 +484,6 @@ class Query implements \JsonSerializable, \Stringable /** * Touches - * */ public static function touches(string $attribute, array $values): string { @@ -542,10 +492,308 @@ class Query implements \JsonSerializable, \Stringable /** * Not Touches - * */ public static function notTouches(string $attribute, array $values): string { return new Query('notTouches', $attribute, [$values])->__toString(); } + + /** + * Count rows of $attribute, optionally aliased. + */ + public static function count(string $attribute = '*', string $alias = ''): string + { + return new Query('count', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Count distinct values of $attribute, optionally aliased. + */ + public static function countDistinct(string $attribute, string $alias = ''): string + { + return new Query('countDistinct', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Sum values of $attribute, optionally aliased. + */ + public static function sum(string $attribute, string $alias = ''): string + { + return new Query('sum', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Average values of $attribute, optionally aliased. + */ + public static function avg(string $attribute, string $alias = ''): string + { + return new Query('avg', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Minimum value of $attribute, optionally aliased. + */ + public static function min(string $attribute, string $alias = ''): string + { + return new Query('min', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Maximum value of $attribute, optionally aliased. + */ + public static function max(string $attribute, string $alias = ''): string + { + return new Query('max', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Standard deviation of $attribute, optionally aliased. + */ + public static function stddev(string $attribute, string $alias = ''): string + { + return new Query('stddev', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Population standard deviation of $attribute, optionally aliased. + */ + public static function stddevPop(string $attribute, string $alias = ''): string + { + return new Query('stddevPop', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Sample standard deviation of $attribute, optionally aliased. + */ + public static function stddevSamp(string $attribute, string $alias = ''): string + { + return new Query('stddevSamp', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Variance of $attribute, optionally aliased. + */ + public static function variance(string $attribute, string $alias = ''): string + { + return new Query('variance', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Population variance of $attribute, optionally aliased. + */ + public static function varPop(string $attribute, string $alias = ''): string + { + return new Query('varPop', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Sample variance of $attribute, optionally aliased. + */ + public static function varSamp(string $attribute, string $alias = ''): string + { + return new Query('varSamp', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Bitwise AND of $attribute, optionally aliased. + */ + public static function bitAnd(string $attribute, string $alias = ''): string + { + return new Query('bitAnd', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Bitwise OR of $attribute, optionally aliased. + */ + public static function bitOr(string $attribute, string $alias = ''): string + { + return new Query('bitOr', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Bitwise XOR of $attribute, optionally aliased. + */ + public static function bitXor(string $attribute, string $alias = ''): string + { + return new Query('bitXor', $attribute, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Group results by attributes. + */ + public static function groupBy(array $attributes): string + { + return new Query('groupBy', null, $attributes)->__toString(); + } + + /** + * Filter grouped results. $queries are nested like and/or. + * + * @param array $queries + */ + public static function having(array $queries): string + { + foreach ($queries as &$query) { + if ($query instanceof self) { + $query = $query->__toString(); + } + $query = \json_decode($query, true); + } + unset($query); + + return new Query('having', null, $queries)->__toString(); + } + + /** + * Select distinct rows. + */ + public static function distinct(): string + { + return new Query('distinct')->__toString(); + } + + /** + * Column-to-column join ON condition. + */ + public static function on(string $left, string $right, string $operator = '='): string + { + return new Query('on', null, [$left, $operator, $right])->__toString(); + } + + /** + * Inner join. $table is the joined collection/table ID. + */ + public static function join(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): string + { + return self::createJoin('join', $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } + + /** + * Left join. $table is the joined collection/table ID. + */ + public static function leftJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): string + { + return self::createJoin('leftJoin', $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } + + /** + * Right join. $table is the joined collection/table ID. + */ + public static function rightJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): string + { + return self::createJoin('rightJoin', $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } + + /** + * Full outer join. $table is the joined collection/table ID. + */ + public static function fullOuterJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): string + { + return self::createJoin('fullOuterJoin', $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } + + /** + * Cross join. $table is the joined collection/table ID. + */ + public static function crossJoin(string $table, string $alias = ''): string + { + return new Query('crossJoin', $table, $alias !== '' ? $alias : null)->__toString(); + } + + /** + * Filter resources where $attribute covers the given geometry. + */ + public static function covers(string $attribute, array $values): string + { + return new Query('covers', $attribute, [$values])->__toString(); + } + + /** + * Filter resources where $attribute does not cover the given geometry. + */ + public static function notCovers(string $attribute, array $values): string + { + return new Query('notCovers', $attribute, [$values])->__toString(); + } + + /** + * Filter resources where $attribute is spatially equal to the given geometry. + */ + public static function spatialEquals(string $attribute, array $values): string + { + return new Query('spatialEquals', $attribute, [$values])->__toString(); + } + + /** + * Filter resources where $attribute is not spatially equal to the given geometry. + */ + public static function notSpatialEquals(string $attribute, array $values): string + { + return new Query('notSpatialEquals', $attribute, [$values])->__toString(); + } + + /** + * Return limit and offset queries for 1-based page pagination. + */ + public static function page(int $page, int $perPage = 25): array + { + if ($page < 1) { + throw new \InvalidArgumentException('Page must be >= 1, got ' . $page); + } + if ($perPage < 1) { + throw new \InvalidArgumentException('Per page must be >= 1, got ' . $perPage); + } + + return [ + self::limit($perPage), + self::offset(($page - 1) * $perPage), + ]; + } + + /** + * Start a fluent query builder. + */ + public static function builder(): QueryBuilder + { + return new QueryBuilder(); + } + + /** + */ + private static function createJoin(string $method, string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn, string $operator, string $alias): string + { + if (\is_array($leftOrAliasOrOn)) { + return self::nestedJoin($method, $table, '', $leftOrAliasOrOn); + } + if (\is_array($rightOrOn)) { + return self::nestedJoin($method, $table, $leftOrAliasOrOn, $rightOrOn); + } + + return self::joinQuery($method, $table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias); + } + + /** + */ + private static function nestedJoin(string $method, string $table, string $alias, array $on): string + { + $values = []; + if ($alias !== '') { + $values[] = $alias; + } + foreach ($on as $query) { + $values[] = \json_decode($query, true); + } + + return new Query($method, $table, $values)->__toString(); + } + + private static function joinQuery(string $method, string $table, string $left, string $right, string $operator, string $alias): string + { + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new Query($method, $table, $values)->__toString(); + } } diff --git a/templates/php/src/Query/Builder.php.twig b/templates/php/src/Query/Builder.php.twig new file mode 100644 index 0000000000..8e0f50d7cf --- /dev/null +++ b/templates/php/src/Query/Builder.php.twig @@ -0,0 +1,553 @@ + + */ + private array $queries = []; + + /** + * @return list + */ + public function build(): array + { + return $this->queries; + } + + public function equal(string $attribute, mixed $value): self + { + return $this->push(Query::equal($attribute, $value)); + } + + public function notEqual(string $attribute, mixed $value): self + { + return $this->push(Query::notEqual($attribute, $value)); + } + + public function lessThan(string $attribute, mixed $value): self + { + return $this->push(Query::lessThan($attribute, $value)); + } + + public function lessThanEqual(string $attribute, mixed $value): self + { + return $this->push(Query::lessThanEqual($attribute, $value)); + } + + public function greaterThan(string $attribute, mixed $value): self + { + return $this->push(Query::greaterThan($attribute, $value)); + } + + public function greaterThanEqual(string $attribute, mixed $value): self + { + return $this->push(Query::greaterThanEqual($attribute, $value)); + } + + public function search(string $attribute, string $value): self + { + return $this->push(Query::search($attribute, $value)); + } + + public function isNull(string $attribute): self + { + return $this->push(Query::isNull($attribute)); + } + + public function isNotNull(string $attribute): self + { + return $this->push(Query::isNotNull($attribute)); + } + + public function between(string $attribute, mixed $start, mixed $end): self + { + return $this->push(Query::between($attribute, $start, $end)); + } + + public function startsWith(string $attribute, string $value): self + { + return $this->push(Query::startsWith($attribute, $value)); + } + + public function endsWith(string $attribute, string $value): self + { + return $this->push(Query::endsWith($attribute, $value)); + } + + /** + * @param array $attributes + */ + public function select(array $attributes): self + { + return $this->push(Query::select($attributes)); + } + + public function cursorAfter(string $documentId): self + { + return $this->push(Query::cursorAfter($documentId)); + } + + public function cursorBefore(string $documentId): self + { + return $this->push(Query::cursorBefore($documentId)); + } + + public function orderAsc(string $attribute): self + { + return $this->push(Query::orderAsc($attribute)); + } + + public function orderDesc(string $attribute): self + { + return $this->push(Query::orderDesc($attribute)); + } + + public function orderRandom(): self + { + return $this->push(Query::orderRandom()); + } + + public function limit(int $limit): self + { + return $this->push(Query::limit($limit)); + } + + public function offset(int $offset): self + { + return $this->push(Query::offset($offset)); + } + + public function contains(string $attribute, mixed $value): self + { + return $this->push(Query::contains($attribute, $value)); + } + + /** + * @param array $value + */ + public function containsAny(string $attribute, array $value): self + { + return $this->push(Query::containsAny($attribute, $value)); + } + + /** + * @param array $value + */ + public function containsAll(string $attribute, array $value): self + { + return $this->push(Query::containsAll($attribute, $value)); + } + + public function notContains(string $attribute, mixed $value): self + { + return $this->push(Query::notContains($attribute, $value)); + } + + public function notSearch(string $attribute, string $value): self + { + return $this->push(Query::notSearch($attribute, $value)); + } + + public function notBetween(string $attribute, mixed $start, mixed $end): self + { + return $this->push(Query::notBetween($attribute, $start, $end)); + } + + public function notStartsWith(string $attribute, string $value): self + { + return $this->push(Query::notStartsWith($attribute, $value)); + } + + public function notEndsWith(string $attribute, string $value): self + { + return $this->push(Query::notEndsWith($attribute, $value)); + } + + public function regex(string $attribute, string $pattern): self + { + return $this->push(Query::regex($attribute, $pattern)); + } + + /** + * @param array $attributes + */ + public function exists(array $attributes): self + { + return $this->push(Query::exists($attributes)); + } + + /** + * @param array $attributes + */ + public function notExists(array $attributes): self + { + return $this->push(Query::notExists($attributes)); + } + + public function createdBefore(string $value): self + { + return $this->push(Query::createdBefore($value)); + } + + public function createdAfter(string $value): self + { + return $this->push(Query::createdAfter($value)); + } + + public function createdBetween(string $start, string $end): self + { + return $this->push(Query::createdBetween($start, $end)); + } + + public function updatedBefore(string $value): self + { + return $this->push(Query::updatedBefore($value)); + } + + public function updatedAfter(string $value): self + { + return $this->push(Query::updatedAfter($value)); + } + + public function updatedBetween(string $start, string $end): self + { + return $this->push(Query::updatedBetween($start, $end)); + } + + /** + * @param array $queries + */ + public function or(array $queries): self + { + return $this->push(Query::or($queries)); + } + + /** + * @param array $queries + */ + public function and(array $queries): self + { + return $this->push(Query::and($queries)); + } + + /** + * @param array $queries + */ + public function elemMatch(string $attribute, array $queries): self + { + return $this->push(Query::elemMatch($attribute, $queries)); + } + + /** + * @param array $values + */ + public function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = true): self + { + return $this->push(Query::distanceEqual($attribute, $values, $distance, $meters)); + } + + /** + * @param array $values + */ + public function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = true): self + { + return $this->push(Query::distanceNotEqual($attribute, $values, $distance, $meters)); + } + + /** + * @param array $values + */ + public function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = true): self + { + return $this->push(Query::distanceGreaterThan($attribute, $values, $distance, $meters)); + } + + /** + * @param array $values + */ + public function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = true): self + { + return $this->push(Query::distanceLessThan($attribute, $values, $distance, $meters)); + } + + /** + * @param array $values + */ + public function intersects(string $attribute, array $values): self + { + return $this->push(Query::intersects($attribute, $values)); + } + + /** + * @param array $values + */ + public function notIntersects(string $attribute, array $values): self + { + return $this->push(Query::notIntersects($attribute, $values)); + } + + /** + * @param array $values + */ + public function crosses(string $attribute, array $values): self + { + return $this->push(Query::crosses($attribute, $values)); + } + + /** + * @param array $values + */ + public function notCrosses(string $attribute, array $values): self + { + return $this->push(Query::notCrosses($attribute, $values)); + } + + /** + * @param array $values + */ + public function overlaps(string $attribute, array $values): self + { + return $this->push(Query::overlaps($attribute, $values)); + } + + /** + * @param array $values + */ + public function notOverlaps(string $attribute, array $values): self + { + return $this->push(Query::notOverlaps($attribute, $values)); + } + + /** + * @param array $values + */ + public function touches(string $attribute, array $values): self + { + return $this->push(Query::touches($attribute, $values)); + } + + /** + * @param array $values + */ + public function notTouches(string $attribute, array $values): self + { + return $this->push(Query::notTouches($attribute, $values)); + } + + public function count(string $attribute = '*', string $alias = ''): self + { + return $this->push(Query::count($attribute, $alias)); + } + + public function countDistinct(string $attribute, string $alias = ''): self + { + return $this->push(Query::countDistinct($attribute, $alias)); + } + + public function sum(string $attribute, string $alias = ''): self + { + return $this->push(Query::sum($attribute, $alias)); + } + + public function avg(string $attribute, string $alias = ''): self + { + return $this->push(Query::avg($attribute, $alias)); + } + + public function min(string $attribute, string $alias = ''): self + { + return $this->push(Query::min($attribute, $alias)); + } + + public function max(string $attribute, string $alias = ''): self + { + return $this->push(Query::max($attribute, $alias)); + } + + public function stddev(string $attribute, string $alias = ''): self + { + return $this->push(Query::stddev($attribute, $alias)); + } + + public function stddevPop(string $attribute, string $alias = ''): self + { + return $this->push(Query::stddevPop($attribute, $alias)); + } + + public function stddevSamp(string $attribute, string $alias = ''): self + { + return $this->push(Query::stddevSamp($attribute, $alias)); + } + + public function variance(string $attribute, string $alias = ''): self + { + return $this->push(Query::variance($attribute, $alias)); + } + + public function varPop(string $attribute, string $alias = ''): self + { + return $this->push(Query::varPop($attribute, $alias)); + } + + public function varSamp(string $attribute, string $alias = ''): self + { + return $this->push(Query::varSamp($attribute, $alias)); + } + + public function bitAnd(string $attribute, string $alias = ''): self + { + return $this->push(Query::bitAnd($attribute, $alias)); + } + + public function bitOr(string $attribute, string $alias = ''): self + { + return $this->push(Query::bitOr($attribute, $alias)); + } + + public function bitXor(string $attribute, string $alias = ''): self + { + return $this->push(Query::bitXor($attribute, $alias)); + } + + /** + * @param array $attributes + */ + public function groupBy(array $attributes): self + { + return $this->push(Query::groupBy($attributes)); + } + + /** + * @param array $queries + */ + public function having(array $queries): self + { + return $this->push(Query::having($queries)); + } + + public function distinct(): self + { + return $this->push(Query::distinct()); + } + + /** + * @param string|array $leftOrAliasOrOn + * @param string|array $rightOrOn + */ + public function join(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): self + { + return $this->push(Query::join($table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias)); + } + + /** + * @param string|array $leftOrAliasOrOn + * @param string|array $rightOrOn + */ + public function leftJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): self + { + return $this->push(Query::leftJoin($table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias)); + } + + /** + * @param string|array $leftOrAliasOrOn + * @param string|array $rightOrOn + */ + public function rightJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): self + { + return $this->push(Query::rightJoin($table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias)); + } + + /** + * @param string|array $leftOrAliasOrOn + * @param string|array $rightOrOn + */ + public function fullOuterJoin(string $table, string|array $leftOrAliasOrOn, string|array $rightOrOn = '', string $operator = '=', string $alias = ''): self + { + return $this->push(Query::fullOuterJoin($table, $leftOrAliasOrOn, $rightOrOn, $operator, $alias)); + } + + public function crossJoin(string $table, string $alias = ''): self + { + return $this->push(Query::crossJoin($table, $alias)); + } + + /** + * @param array $vector + */ + public function vectorDot(string $attribute, array $vector): self + { + return $this->push(Query::vectorDot($attribute, $vector)); + } + + /** + * @param array $vector + */ + public function vectorCosine(string $attribute, array $vector): self + { + return $this->push(Query::vectorCosine($attribute, $vector)); + } + + /** + * @param array $vector + */ + public function vectorEuclidean(string $attribute, array $vector): self + { + return $this->push(Query::vectorEuclidean($attribute, $vector)); + } + + /** + * @param array $values + */ + public function covers(string $attribute, array $values): self + { + return $this->push(Query::covers($attribute, $values)); + } + + /** + * @param array $values + */ + public function notCovers(string $attribute, array $values): self + { + return $this->push(Query::notCovers($attribute, $values)); + } + + /** + * @param array $values + */ + public function spatialEquals(string $attribute, array $values): self + { + return $this->push(Query::spatialEquals($attribute, $values)); + } + + /** + * @param array $values + */ + public function notSpatialEquals(string $attribute, array $values): self + { + return $this->push(Query::notSpatialEquals($attribute, $values)); + } + + public function page(int $page, int $perPage = 25): self + { + return $this->push(Query::page($page, $perPage)); + } + + /** + * @param string|list $query + */ + private function push(string|array $query): self + { + if (\is_array($query)) { + array_push($this->queries, ...$query); + } else { + $this->queries[] = $query; + } + + return $this; + } +} diff --git a/templates/php/tests/ClientTest.php.twig b/templates/php/tests/ClientTest.php.twig index 0b7d3484f6..fedfa81a5f 100644 --- a/templates/php/tests/ClientTest.php.twig +++ b/templates/php/tests/ClientTest.php.twig @@ -65,6 +65,49 @@ final class ClientTest extends TestCase $result = $client->addHeader('X-Custom', 'value'); $this->assertSame($client, $result); } + + public function testFlattenSpreadsWrappedBuilder(): void + { + $this->assertSame( + [Query::limit(1)], + array_values((array) Client::flatten([Query::builder()->limit(1)])) + ); + } + + public function testFlattenKeepsNestedNumericArrays(): void + { + $geometry = [[1, 2], [3, 4]]; + $this->assertSame($geometry, Client::flatten($geometry)); + } + + public function testFlattenKeepsQueryStringList(): void + { + $this->assertSame( + [Query::limit(1)], + array_values((array) Client::flatten([Query::limit(1)])) + ); + } + + public function testFlattenKeepsNestedStringArrays(): void + { + $this->assertSame([['a', 'b']], Client::flatten([['a', 'b']])); + } + + public function testFlattenBuilderParamBecomesQueryStrings(): void + { + $this->assertSame( + [Query::equal('status', ['active']), Query::limit(1)], + Client::flatten(Query::builder()->equal('status', ['active'])->limit(1)) + ); + } + + public function testFlattenSplicesBuildersBesideNestedStringArrays(): void + { + $this->assertSame( + [Query::limit(1), Query::offset(2), ['a', 'b']], + Client::flatten([Query::builder()->limit(1)->offset(2), ['a', 'b']]) + ); + } {% for headerKey, header in globalHeaders %} public function testSet{{headerKey | caseUcfirst}}ReturnsClient(): void @@ -132,6 +175,39 @@ final class ClientTest extends TestCase $this->assertSame('injected', $received[0]->getHeaderLine('x-test')); } + public function testInjectedTransportReceivesFlattenedParams(): void + { + $received = []; + $transport = $this->createMock(ClientInterface::class); + $transport->expects($this->exactly(2))->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use (&$received): ResponseInterface { + $received[] = $request; + + return new Response(body: new Stream('{}')) + ->withHeader('content-type', 'application/json'); + }); + + $client = new Client($transport)->setEndpoint('https://example.com/v1'); + $params = [ + 'queries' => Query::builder()->limit(1)->offset(2), + 'tags' => [['a', 'b']], + ]; + + $client->call(Client::METHOD_POST, '/items', ['content-type' => 'application/json'], $params); + $client->call(Client::METHOD_GET, '/items', [], $params); + + $this->assertCount(2, $received); + $this->assertSame( + ['queries' => [Query::limit(1), Query::offset(2)], 'tags' => [['a', 'b']]], + json_decode((string) $received[0]->getBody(), true) + ); + parse_str($received[1]->getUri()->getQuery(), $query); + $this->assertSame( + ['queries' => [Query::limit(1), Query::offset(2)], 'tags' => [['a', 'b']]], + $query + ); + } + public function testInjectedTransportSurvivesFailureAndConfigurationChanges(): void { $received = []; diff --git a/templates/php/tests/QueryTest.php.twig b/templates/php/tests/QueryTest.php.twig index f3e3590433..894b8bdf35 100644 --- a/templates/php/tests/QueryTest.php.twig +++ b/templates/php/tests/QueryTest.php.twig @@ -8,11 +8,8 @@ use PHPUnit\Framework\TestCase; final class BasicFilterQueryTest { - public function __construct( - public string $description, - public mixed $value, - public string $expectedValues, - ) { + public function __construct(public string $description, public mixed $value, public string $expectedValues) + { } } @@ -361,4 +358,263 @@ final class QueryTest extends TestCase $this->assertSame(['2023-01-01', '2023-12-31'], $query['values']); $this->assertSame('between', $query['method']); } + + public function testCountWithAlias(): void + { + $query = json_decode(Query::count('*', 'total'), true); + $this->assertSame('count', $query['method']); + $this->assertSame('*', $query['attribute']); + $this->assertSame(['total'], $query['values']); + } + + public function testCountWithoutAlias(): void + { + $query = json_decode(Query::count(), true); + $this->assertSame('count', $query['method']); + $this->assertSame('*', $query['attribute']); + $this->assertArrayNotHasKey('values', $query); + } + + public function testCountDistinct(): void + { + $query = json_decode(Query::countDistinct('year', 'uniqueYears'), true); + $this->assertSame('countDistinct', $query['method']); + $this->assertSame('year', $query['attribute']); + $this->assertSame(['uniqueYears'], $query['values']); + } + + public function testSumAvgMinMax(): void + { + $this->assertSame('sum', json_decode(Query::sum('price', 'total'), true)['method']); + $this->assertSame('avg', json_decode(Query::avg('price', 'avgPrice'), true)['method']); + $this->assertSame('min', json_decode(Query::min('price', 'lowest'), true)['method']); + $this->assertSame('max', json_decode(Query::max('price', 'highest'), true)['method']); + } + + public function testStddev(): void + { + $this->assertSame('stddev', json_decode(Query::stddev('price', 'sd'), true)['method']); + } + + public function testStddevPop(): void + { + $this->assertSame('stddevPop', json_decode(Query::stddevPop('price', 'sdp'), true)['method']); + } + + public function testStddevSamp(): void + { + $this->assertSame('stddevSamp', json_decode(Query::stddevSamp('price', 'sds'), true)['method']); + } + + public function testVariance(): void + { + $this->assertSame('variance', json_decode(Query::variance('price', 'var'), true)['method']); + } + + public function testVarPop(): void + { + $this->assertSame('varPop', json_decode(Query::varPop('price', 'vp'), true)['method']); + } + + public function testVarSamp(): void + { + $this->assertSame('varSamp', json_decode(Query::varSamp('price', 'vs'), true)['method']); + } + + public function testBitAnd(): void + { + $this->assertSame('bitAnd', json_decode(Query::bitAnd('flags', 'band'), true)['method']); + } + + public function testBitOr(): void + { + $this->assertSame('bitOr', json_decode(Query::bitOr('flags', 'bor'), true)['method']); + } + + public function testBitXor(): void + { + $this->assertSame('bitXor', json_decode(Query::bitXor('flags', 'bxor'), true)['method']); + } + + public function testGroupBy(): void + { + $query = json_decode(Query::groupBy(['status', 'year']), true); + $this->assertSame('groupBy', $query['method']); + $this->assertSame(['status', 'year'], $query['values']); + $this->assertArrayNotHasKey('attribute', $query); + } + + public function testHaving(): void + { + $query = json_decode(Query::having([Query::greaterThan('total', 1)]), true); + $this->assertSame('having', $query['method']); + $this->assertSame('greaterThan', $query['values'][0]['method']); + $this->assertSame('total', $query['values'][0]['attribute']); + $this->assertSame([1], $query['values'][0]['values']); + } + + public function testDistinct(): void + { + $query = json_decode(Query::distinct(), true); + $this->assertSame('distinct', $query['method']); + $this->assertArrayNotHasKey('attribute', $query); + $this->assertArrayNotHasKey('values', $query); + } + + public function testJoin(): void + { + $query = json_decode(Query::join('orders', '$id', 'customerId'), true); + $this->assertSame('join', $query['method']); + $this->assertSame('orders', $query['attribute']); + $this->assertSame(['$id', '=', 'customerId'], $query['values']); + } + + public function testJoinWithAlias(): void + { + $query = json_decode(Query::leftJoin('orders', '$id', 'customerId', '=', 'ord'), true); + $this->assertSame('leftJoin', $query['method']); + $this->assertSame('orders', $query['attribute']); + $this->assertSame(['$id', '=', 'customerId', 'ord'], $query['values']); + } + + public function testOn(): void + { + $query = json_decode(Query::on('$id', 'customerId'), true); + $this->assertSame('on', $query['method']); + $this->assertSame(['$id', '=', 'customerId'], $query['values']); + } + + public function testNestedLeftJoin(): void + { + $query = json_decode(Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', 'paid'), + ]), true); + $this->assertSame('leftJoin', $query['method']); + $this->assertSame('orders', $query['attribute']); + $this->assertSame('ord', $query['values'][0]); + $this->assertSame('on', $query['values'][1]['method']); + $this->assertSame(['$id', '=', 'customerId'], $query['values'][1]['values']); + $this->assertSame('equal', $query['values'][2]['method']); + $this->assertSame('ord.status', $query['values'][2]['attribute']); + } + + public function testRightJoin(): void + { + $query = json_decode(Query::rightJoin('orders', '$id', 'customerId'), true); + $this->assertSame('rightJoin', $query['method']); + $this->assertSame(['$id', '=', 'customerId'], $query['values']); + } + + public function testFullOuterJoin(): void + { + $query = json_decode(Query::fullOuterJoin('orders', '$id', 'customerId'), true); + $this->assertSame('fullOuterJoin', $query['method']); + $this->assertSame(['$id', '=', 'customerId'], $query['values']); + } + + public function testCrossJoin(): void + { + $query = json_decode(Query::crossJoin('orders', 'ord'), true); + $this->assertSame('crossJoin', $query['method']); + $this->assertSame('orders', $query['attribute']); + $this->assertSame(['ord'], $query['values']); + } + + public function testNotCovers(): void + { + $query = json_decode(Query::notCovers('location', [1, 2]), true); + $this->assertSame('notCovers', $query['method']); + $this->assertSame([[1, 2]], $query['values']); + } + + public function testSpatialEquals(): void + { + $query = json_decode(Query::spatialEquals('location', [1, 2]), true); + $this->assertSame('spatialEquals', $query['method']); + $this->assertSame([[1, 2]], $query['values']); + } + + public function testNotSpatialEquals(): void + { + $query = json_decode(Query::notSpatialEquals('location', [1, 2]), true); + $this->assertSame('notSpatialEquals', $query['method']); + $this->assertSame([[1, 2]], $query['values']); + } + + public function testVectorDot(): void + { + $query = json_decode(Query::vectorDot('embedding', [0.1, 0.2]), true); + $this->assertSame('vectorDot', $query['method']); + $this->assertSame('embedding', $query['attribute']); + $this->assertSame([[0.1, 0.2]], $query['values']); + } + + public function testCovers(): void + { + $query = json_decode(Query::covers('location', [0, 0]), true); + $this->assertSame('covers', $query['method']); + $this->assertSame([[0, 0]], $query['values']); + } + + public function testPage(): void + { + $queries = Query::page(2, 10); + $this->assertCount(2, $queries); + $limit = json_decode($queries[0], true); + $offset = json_decode($queries[1], true); + $this->assertSame('limit', $limit['method']); + $this->assertSame([10], $limit['values']); + $this->assertSame('offset', $offset['method']); + $this->assertSame([10], $offset['values']); + } + + public function testPageRejectsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + Query::page(0, 10); + } + + public function testBuilderBuild(): void + { + $expected = [ + Query::equal('status', 'published'), + Query::leftJoin('orders', '$id', 'customerId', '=', 'ord'), + Query::count('*', 'total'), + Query::groupBy(['status']), + Query::limit(25), + ]; + + $built = Query::builder() + ->equal('status', 'published') + ->leftJoin('orders', '$id', 'customerId', '=', 'ord') + ->count('*', 'total') + ->groupBy(['status']) + ->limit(25) + ->build(); + + $this->assertSame($expected, $built); + } + + public function testFlattenSpreadsWrappedBuilder(): void + { + $this->assertSame( + [Query::limit(1)], + array_values((array) Client::flatten([Query::builder()->limit(1)])) + ); + } + + public function testFlattenKeepsNestedNumericArrays(): void + { + $geometry = [[1, 2], [3, 4]]; + $this->assertSame($geometry, Client::flatten($geometry)); + } + + public function testFlattenKeepsQueryStringList(): void + { + $this->assertSame( + [Query::limit(1)], + array_values((array) Client::flatten([Query::limit(1)])) + ); + } } diff --git a/templates/python/package/client.py.twig b/templates/python/package/client.py.twig index 02b9d6a3e7..a64c959f6e 100644 --- a/templates/python/package/client.py.twig +++ b/templates/python/package/client.py.twig @@ -9,6 +9,7 @@ from threading import Lock from .input_file import InputFile from .exception import {{spec.info.title | caseUcfirst}}Exception from .encoders.value_class_encoder import ValueClassEncoder +from .query import Query class Client: @@ -70,6 +71,8 @@ class Client: if params is None: params = {} + params = {key: Query._flatten(value) for key, value in params.items()} + data = {} files = {} stringify = False @@ -297,13 +300,10 @@ class Client: def flatten(self, data, prefix='', stringify=False): output = {} - i = 0 + entries = data.items() if isinstance(data, dict) else enumerate(data) - for key in data: - value = data[key] if isinstance(data, dict) else key - finalKey = prefix + '[' + key + ']' if prefix else key - finalKey = prefix + '[' + str(i) + ']' if isinstance(data, list) else finalKey - i += 1 + for key, value in entries: + finalKey = f'{prefix}[{key}]' if prefix or isinstance(data, list) else key if isinstance(value, list) or isinstance(value, dict): output = {**output, **self.flatten(value, finalKey, stringify)} diff --git a/templates/python/package/query.py.twig b/templates/python/package/query.py.twig index 154a19e6d9..261845d484 100644 --- a/templates/python/package/query.py.twig +++ b/templates/python/package/query.py.twig @@ -288,3 +288,445 @@ class Query: @staticmethod def not_touches(attribute, values): return str(Query("notTouches", attribute, [values])) + + @staticmethod + def count(attribute='*', alias=''): + return str(Query("count", attribute, alias if alias != '' else None)) + + @staticmethod + def count_distinct(attribute, alias=''): + return str(Query("countDistinct", attribute, alias if alias != '' else None)) + + @staticmethod + def sum(attribute, alias=''): + return str(Query("sum", attribute, alias if alias != '' else None)) + + @staticmethod + def avg(attribute, alias=''): + return str(Query("avg", attribute, alias if alias != '' else None)) + + @staticmethod + def min(attribute, alias=''): + return str(Query("min", attribute, alias if alias != '' else None)) + + @staticmethod + def max(attribute, alias=''): + return str(Query("max", attribute, alias if alias != '' else None)) + + @staticmethod + def stddev(attribute, alias=''): + return str(Query("stddev", attribute, alias if alias != '' else None)) + + @staticmethod + def stddev_pop(attribute, alias=''): + return str(Query("stddevPop", attribute, alias if alias != '' else None)) + + @staticmethod + def stddev_samp(attribute, alias=''): + return str(Query("stddevSamp", attribute, alias if alias != '' else None)) + + @staticmethod + def variance(attribute, alias=''): + return str(Query("variance", attribute, alias if alias != '' else None)) + + @staticmethod + def var_pop(attribute, alias=''): + return str(Query("varPop", attribute, alias if alias != '' else None)) + + @staticmethod + def var_samp(attribute, alias=''): + return str(Query("varSamp", attribute, alias if alias != '' else None)) + + @staticmethod + def bit_and(attribute, alias=''): + return str(Query("bitAnd", attribute, alias if alias != '' else None)) + + @staticmethod + def bit_or(attribute, alias=''): + return str(Query("bitOr", attribute, alias if alias != '' else None)) + + @staticmethod + def bit_xor(attribute, alias=''): + return str(Query("bitXor", attribute, alias if alias != '' else None)) + + @staticmethod + def group_by(attributes): + return str(Query("groupBy", None, attributes)) + + @staticmethod + def having(queries): + return str(Query("having", None, [json.loads(query) for query in queries])) + + @staticmethod + def distinct(): + return str(Query("distinct", None, None)) + + @staticmethod + def on(left, right, operator='='): + return str(Query("on", None, [left, operator, right])) + + @staticmethod + def join(table, left, right='', operator='=', alias=''): + return Query._create_join("join", table, left, right, operator, alias) + + @staticmethod + def left_join(table, left, right='', operator='=', alias=''): + return Query._create_join("leftJoin", table, left, right, operator, alias) + + @staticmethod + def right_join(table, left, right='', operator='=', alias=''): + return Query._create_join("rightJoin", table, left, right, operator, alias) + + @staticmethod + def full_outer_join(table, left, right='', operator='=', alias=''): + return Query._create_join("fullOuterJoin", table, left, right, operator, alias) + + @staticmethod + def cross_join(table, alias=''): + return str(Query("crossJoin", table, alias if alias != '' else None)) + + @staticmethod + def covers(attribute, values): + return str(Query("covers", attribute, [values])) + + @staticmethod + def not_covers(attribute, values): + return str(Query("notCovers", attribute, [values])) + + @staticmethod + def spatial_equals(attribute, values): + return str(Query("spatialEquals", attribute, [values])) + + @staticmethod + def not_spatial_equals(attribute, values): + return str(Query("notSpatialEquals", attribute, [values])) + + @staticmethod + def page(page, per_page=25): + if page < 1: + raise ValueError(f'Page must be >= 1, got {page}') + if per_page < 1: + raise ValueError(f'Per page must be >= 1, got {per_page}') + + return [ + Query.limit(per_page), + Query.offset((page - 1) * per_page), + ] + + @staticmethod + def builder(): + return Query.Builder() + + @staticmethod + def _flatten(value): + if isinstance(value, Query.Builder): + return value.build() + if isinstance(value, Query): + return str(value) + if not isinstance(value, list): + return value + + out = [] + changed = False + for item in value: + flat = Query._flatten(item) + if isinstance(item, (Query.Builder, Query)): + if isinstance(flat, list): + out.extend(flat) + else: + out.append(flat) + changed = True + continue + if flat is not item: + changed = True + out.append(flat) + + return out if changed else value + + @staticmethod + def _create_join(method, table, left, right, operator, alias): + if isinstance(left, list): + return Query._nested_join(method, table, '', left) + if isinstance(right, list): + return Query._nested_join(method, table, left, right) + return Query._join(method, table, left, right, operator, alias) + + @staticmethod + def _nested_join(method, table, alias, on): + values = [] + if alias != '': + values.append(alias) + for query in on: + values.append(json.loads(query)) + return str(Query(method, table, values)) + + @staticmethod + def _join(method, table, left, right, operator, alias): + values = [left, operator, right] + if alias != '': + values.append(alias) + return str(Query(method, table, values)) + + class Builder: + def __init__(self): + self._queries = [] + + def build(self): + return list(self._queries) + + def equal(self, attribute, value): + return self._push(Query.equal(attribute, value)) + + def not_equal(self, attribute, value): + return self._push(Query.not_equal(attribute, value)) + + def less_than(self, attribute, value): + return self._push(Query.less_than(attribute, value)) + + def less_than_equal(self, attribute, value): + return self._push(Query.less_than_equal(attribute, value)) + + def greater_than(self, attribute, value): + return self._push(Query.greater_than(attribute, value)) + + def greater_than_equal(self, attribute, value): + return self._push(Query.greater_than_equal(attribute, value)) + + def search(self, attribute, value): + return self._push(Query.search(attribute, value)) + + def is_null(self, attribute): + return self._push(Query.is_null(attribute)) + + def is_not_null(self, attribute): + return self._push(Query.is_not_null(attribute)) + + def between(self, attribute, start, end): + return self._push(Query.between(attribute, start, end)) + + def starts_with(self, attribute, value): + return self._push(Query.starts_with(attribute, value)) + + def ends_with(self, attribute, value): + return self._push(Query.ends_with(attribute, value)) + + def select(self, attributes): + return self._push(Query.select(attributes)) + + def cursor_after(self, id): + return self._push(Query.cursor_after(id)) + + def cursor_before(self, id): + return self._push(Query.cursor_before(id)) + + def order_asc(self, attribute): + return self._push(Query.order_asc(attribute)) + + def order_desc(self, attribute): + return self._push(Query.order_desc(attribute)) + + def order_random(self): + return self._push(Query.order_random()) + + def limit(self, limit): + return self._push(Query.limit(limit)) + + def offset(self, offset): + return self._push(Query.offset(offset)) + + def contains(self, attribute, value): + return self._push(Query.contains(attribute, value)) + + def contains_any(self, attribute, value): + return self._push(Query.contains_any(attribute, value)) + + def contains_all(self, attribute, value): + return self._push(Query.contains_all(attribute, value)) + + def not_contains(self, attribute, value): + return self._push(Query.not_contains(attribute, value)) + + def not_search(self, attribute, value): + return self._push(Query.not_search(attribute, value)) + + def not_between(self, attribute, start, end): + return self._push(Query.not_between(attribute, start, end)) + + def not_starts_with(self, attribute, value): + return self._push(Query.not_starts_with(attribute, value)) + + def not_ends_with(self, attribute, value): + return self._push(Query.not_ends_with(attribute, value)) + + def regex(self, attribute, pattern): + return self._push(Query.regex(attribute, pattern)) + + def exists(self, attributes): + return self._push(Query.exists(attributes)) + + def not_exists(self, attributes): + return self._push(Query.not_exists(attributes)) + + def created_before(self, value): + return self._push(Query.created_before(value)) + + def created_after(self, value): + return self._push(Query.created_after(value)) + + def created_between(self, start, end): + return self._push(Query.created_between(start, end)) + + def updated_before(self, value): + return self._push(Query.updated_before(value)) + + def updated_after(self, value): + return self._push(Query.updated_after(value)) + + def updated_between(self, start, end): + return self._push(Query.updated_between(start, end)) + + def or_queries(self, queries): + return self._push(Query.or_queries(queries)) + + def and_queries(self, queries): + return self._push(Query.and_queries(queries)) + + def elem_match(self, attribute, queries): + return self._push(Query.elem_match(attribute, queries)) + + def distance_equal(self, attribute, values, distance, meters=True): + return self._push(Query.distance_equal(attribute, values, distance, meters)) + + def distance_not_equal(self, attribute, values, distance, meters=True): + return self._push(Query.distance_not_equal(attribute, values, distance, meters)) + + def distance_greater_than(self, attribute, values, distance, meters=True): + return self._push(Query.distance_greater_than(attribute, values, distance, meters)) + + def distance_less_than(self, attribute, values, distance, meters=True): + return self._push(Query.distance_less_than(attribute, values, distance, meters)) + + def intersects(self, attribute, values): + return self._push(Query.intersects(attribute, values)) + + def not_intersects(self, attribute, values): + return self._push(Query.not_intersects(attribute, values)) + + def crosses(self, attribute, values): + return self._push(Query.crosses(attribute, values)) + + def not_crosses(self, attribute, values): + return self._push(Query.not_crosses(attribute, values)) + + def overlaps(self, attribute, values): + return self._push(Query.overlaps(attribute, values)) + + def not_overlaps(self, attribute, values): + return self._push(Query.not_overlaps(attribute, values)) + + def touches(self, attribute, values): + return self._push(Query.touches(attribute, values)) + + def not_touches(self, attribute, values): + return self._push(Query.not_touches(attribute, values)) + + def count(self, attribute='*', alias=''): + return self._push(Query.count(attribute, alias)) + + def count_distinct(self, attribute, alias=''): + return self._push(Query.count_distinct(attribute, alias)) + + def sum(self, attribute, alias=''): + return self._push(Query.sum(attribute, alias)) + + def avg(self, attribute, alias=''): + return self._push(Query.avg(attribute, alias)) + + def min(self, attribute, alias=''): + return self._push(Query.min(attribute, alias)) + + def max(self, attribute, alias=''): + return self._push(Query.max(attribute, alias)) + + def stddev(self, attribute, alias=''): + return self._push(Query.stddev(attribute, alias)) + + def stddev_pop(self, attribute, alias=''): + return self._push(Query.stddev_pop(attribute, alias)) + + def stddev_samp(self, attribute, alias=''): + return self._push(Query.stddev_samp(attribute, alias)) + + def variance(self, attribute, alias=''): + return self._push(Query.variance(attribute, alias)) + + def var_pop(self, attribute, alias=''): + return self._push(Query.var_pop(attribute, alias)) + + def var_samp(self, attribute, alias=''): + return self._push(Query.var_samp(attribute, alias)) + + def bit_and(self, attribute, alias=''): + return self._push(Query.bit_and(attribute, alias)) + + def bit_or(self, attribute, alias=''): + return self._push(Query.bit_or(attribute, alias)) + + def bit_xor(self, attribute, alias=''): + return self._push(Query.bit_xor(attribute, alias)) + + def group_by(self, attributes): + return self._push(Query.group_by(attributes)) + + def having(self, queries): + return self._push(Query.having(queries)) + + def distinct(self): + return self._push(Query.distinct()) + + def join(self, table, left, right='', operator='=', alias=''): + return self._push(Query.join(table, left, right, operator, alias)) + + def left_join(self, table, left, right='', operator='=', alias=''): + return self._push(Query.left_join(table, left, right, operator, alias)) + + def right_join(self, table, left, right='', operator='=', alias=''): + return self._push(Query.right_join(table, left, right, operator, alias)) + + def full_outer_join(self, table, left, right='', operator='=', alias=''): + return self._push(Query.full_outer_join(table, left, right, operator, alias)) + + def cross_join(self, table, alias=''): + return self._push(Query.cross_join(table, alias)) + + def vector_dot(self, attribute, vector): + return self._push(Query.vector_dot(attribute, vector)) + + def vector_cosine(self, attribute, vector): + return self._push(Query.vector_cosine(attribute, vector)) + + def vector_euclidean(self, attribute, vector): + return self._push(Query.vector_euclidean(attribute, vector)) + + def covers(self, attribute, values): + return self._push(Query.covers(attribute, values)) + + def not_covers(self, attribute, values): + return self._push(Query.not_covers(attribute, values)) + + def spatial_equals(self, attribute, values): + return self._push(Query.spatial_equals(attribute, values)) + + def not_spatial_equals(self, attribute, values): + return self._push(Query.not_spatial_equals(attribute, values)) + + def page(self, page, per_page=25): + return self._push(Query.page(page, per_page)) + + def _push(self, query): + if isinstance(query, list): + self._queries.extend(query) + else: + self._queries.append(query) + + return self diff --git a/templates/python/test/test_query.py.twig b/templates/python/test/test_query.py.twig index 11bd491492..a7965f75a5 100644 --- a/templates/python/test/test_query.py.twig +++ b/templates/python/test/test_query.py.twig @@ -1,22 +1,32 @@ +import json import unittest +from urllib.parse import parse_qsl +from urllib.parse import urlsplit +import requests_mock + +from {{ namespace | caseSnake }}.client import Client from {{ namespace | caseSnake }}.query import Query +def parsed(query): + return json.loads(query) + + class BasicFilterQueryTest: - def __init__(self, description: str, value, expected_values: str): + def __init__(self, description: str, value, expected_values): self.description = description self.value = value self.expected_values = expected_values tests = [ - BasicFilterQueryTest('with a string', 's', '["s"]'), - BasicFilterQueryTest('with an integer', 1, '[1]'), - BasicFilterQueryTest('with a double', 1.2, '[1.2]'), - BasicFilterQueryTest('with a whole number double', 1.0, '[1.0]'), - BasicFilterQueryTest('with a bool', False, '[false]'), - BasicFilterQueryTest('with a list', ['a', 'b', 'c'], '["a","b","c"]'), + BasicFilterQueryTest('with a string', 's', ['s']), + BasicFilterQueryTest('with an integer', 1, [1]), + BasicFilterQueryTest('with a double', 1.2, [1.2]), + BasicFilterQueryTest('with a whole number double', 1.0, [1.0]), + BasicFilterQueryTest('with a bool', False, [False]), + BasicFilterQueryTest('with a list', ['a', 'b', 'c'], ['a', 'b', 'c']), ] @@ -25,89 +35,343 @@ class TestQueryMethods(unittest.TestCase): def test_equal(self): for t in tests: self.assertEqual( - Query.equal('attr', t.value), - {% verbatim %}f'{{"method":"equal","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + parsed(Query.equal('attr', t.value)), + {'method': 'equal', 'attribute': 'attr', 'values': t.expected_values}, t.description, ) def test_not_equal(self): for t in tests: self.assertEqual( - Query.not_equal('attr', t.value), - {% verbatim %}f'{{"method":"notEqual","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + parsed(Query.not_equal('attr', t.value)), + {'method': 'notEqual', 'attribute': 'attr', 'values': t.expected_values}, t.description, ) def test_less_than(self): for t in tests: self.assertEqual( - Query.less_than('attr', t.value), - {% verbatim %}f'{{"method":"lessThan","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + parsed(Query.less_than('attr', t.value)), + {'method': 'lessThan', 'attribute': 'attr', 'values': t.expected_values}, t.description, ) def test_less_than_equal(self): for t in tests: self.assertEqual( - Query.less_than_equal('attr', t.value), - {% verbatim %}f'{{"method":"lessThanEqual","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + parsed(Query.less_than_equal('attr', t.value)), + {'method': 'lessThanEqual', 'attribute': 'attr', 'values': t.expected_values}, t.description, ) def test_greater_than(self): for t in tests: self.assertEqual( - Query.greater_than('attr', t.value), - {% verbatim %}f'{{"method":"greaterThan","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + parsed(Query.greater_than('attr', t.value)), + {'method': 'greaterThan', 'attribute': 'attr', 'values': t.expected_values}, t.description, ) def test_greater_than_equal(self): for t in tests: self.assertEqual( - Query.greater_than_equal('attr', t.value), - {% verbatim %}f'{{"method":"greaterThanEqual","attribute":"attr","values":{t.expected_values}}}',{% endverbatim %} + parsed(Query.greater_than_equal('attr', t.value)), + {'method': 'greaterThanEqual', 'attribute': 'attr', 'values': t.expected_values}, t.description, ) def test_search(self): self.assertEqual( - Query.search('attr', 'keyword1 keyword2'), - '{"method":"search","attribute":"attr","values":["keyword1 keyword2"]}', + parsed(Query.search('attr', 'keyword1 keyword2')), + {'method': 'search', 'attribute': 'attr', 'values': ['keyword1 keyword2']}, ) def test_is_null(self): - self.assertEqual(Query.is_null('attr'), '{"method":"isNull","attribute":"attr"}') + self.assertEqual(parsed(Query.is_null('attr')), {'method': 'isNull', 'attribute': 'attr'}) def test_is_not_null(self): - self.assertEqual(Query.is_not_null('attr'), '{"method":"isNotNull","attribute":"attr"}') + self.assertEqual(parsed(Query.is_not_null('attr')), {'method': 'isNotNull', 'attribute': 'attr'}) def test_between_with_integers(self): - self.assertEqual(Query.between('attr', 1, 2), '{"method":"between","attribute":"attr","values":[1,2]}') + self.assertEqual( + parsed(Query.between('attr', 1, 2)), {'method': 'between', 'attribute': 'attr', 'values': [1, 2]} + ) def test_between_with_doubles(self): - self.assertEqual(Query.between('attr', 1.0, 2.0), '{"method":"between","attribute":"attr","values":[1.0,2.0]}') + self.assertEqual( + parsed(Query.between('attr', 1.0, 2.0)), {'method': 'between', 'attribute': 'attr', 'values': [1.0, 2.0]} + ) def test_between_with_strings(self): - self.assertEqual(Query.between('attr', 'a', 'z'), '{"method":"between","attribute":"attr","values":["a","z"]}') + self.assertEqual( + parsed(Query.between('attr', 'a', 'z')), {'method': 'between', 'attribute': 'attr', 'values': ['a', 'z']} + ) def test_select(self): - self.assertEqual(Query.select(['attr1', 'attr2']), '{"method":"select","values":["attr1","attr2"]}') + self.assertEqual(parsed(Query.select(['attr1', 'attr2'])), {'method': 'select', 'values': ['attr1', 'attr2']}) def test_order_asc(self): - self.assertEqual(Query.order_asc('attr'), '{"method":"orderAsc","attribute":"attr"}') + self.assertEqual(parsed(Query.order_asc('attr')), {'method': 'orderAsc', 'attribute': 'attr'}) def test_order_desc(self): - self.assertEqual(Query.order_desc('attr'), '{"method":"orderDesc","attribute":"attr"}') + self.assertEqual(parsed(Query.order_desc('attr')), {'method': 'orderDesc', 'attribute': 'attr'}) def test_cursor_before(self): - self.assertEqual(Query.cursor_before('custom'), '{"method":"cursorBefore","values":["custom"]}') + self.assertEqual(parsed(Query.cursor_before('custom')), {'method': 'cursorBefore', 'values': ['custom']}) def test_cursor_after(self): - self.assertEqual(Query.cursor_after('custom'), '{"method":"cursorAfter","values":["custom"]}') + self.assertEqual(parsed(Query.cursor_after('custom')), {'method': 'cursorAfter', 'values': ['custom']}) def test_limit(self): - self.assertEqual(Query.limit(1), '{"method":"limit","values":[1]}') + self.assertEqual(parsed(Query.limit(1)), {'method': 'limit', 'values': [1]}) def test_offset(self): - self.assertEqual(Query.offset(1), '{"method":"offset","values":[1]}') + self.assertEqual(parsed(Query.offset(1)), {'method': 'offset', 'values': [1]}) + + def test_count(self): + self.assertEqual(parsed(Query.count('*', 'total')), {'method': 'count', 'attribute': '*', 'values': ['total']}) + + def test_count_distinct(self): + self.assertEqual( + parsed(Query.count_distinct('year', 'uniqueYears')), + {'method': 'countDistinct', 'attribute': 'year', 'values': ['uniqueYears']}, + ) + + def test_sum(self): + self.assertEqual( + parsed(Query.sum('price', 'total')), {'method': 'sum', 'attribute': 'price', 'values': ['total']} + ) + + def test_avg(self): + self.assertEqual( + parsed(Query.avg('price', 'avgPrice')), {'method': 'avg', 'attribute': 'price', 'values': ['avgPrice']} + ) + + def test_min(self): + self.assertEqual( + parsed(Query.min('price', 'lowest')), {'method': 'min', 'attribute': 'price', 'values': ['lowest']} + ) + + def test_max(self): + self.assertEqual( + parsed(Query.max('price', 'highest')), {'method': 'max', 'attribute': 'price', 'values': ['highest']} + ) + + def test_stddev(self): + self.assertEqual( + parsed(Query.stddev('price', 'sd')), {'method': 'stddev', 'attribute': 'price', 'values': ['sd']} + ) + + def test_stddev_pop(self): + self.assertEqual( + parsed(Query.stddev_pop('price', 'sdp')), {'method': 'stddevPop', 'attribute': 'price', 'values': ['sdp']} + ) + + def test_stddev_samp(self): + self.assertEqual( + parsed(Query.stddev_samp('price', 'sds')), {'method': 'stddevSamp', 'attribute': 'price', 'values': ['sds']} + ) + + def test_variance(self): + self.assertEqual( + parsed(Query.variance('price', 'var')), {'method': 'variance', 'attribute': 'price', 'values': ['var']} + ) + + def test_var_pop(self): + self.assertEqual( + parsed(Query.var_pop('price', 'vp')), {'method': 'varPop', 'attribute': 'price', 'values': ['vp']} + ) + + def test_var_samp(self): + self.assertEqual( + parsed(Query.var_samp('price', 'vs')), {'method': 'varSamp', 'attribute': 'price', 'values': ['vs']} + ) + + def test_bit_and(self): + self.assertEqual( + parsed(Query.bit_and('flags', 'band')), {'method': 'bitAnd', 'attribute': 'flags', 'values': ['band']} + ) + + def test_bit_or(self): + self.assertEqual( + parsed(Query.bit_or('flags', 'bor')), {'method': 'bitOr', 'attribute': 'flags', 'values': ['bor']} + ) + + def test_bit_xor(self): + self.assertEqual( + parsed(Query.bit_xor('flags', 'bxor')), {'method': 'bitXor', 'attribute': 'flags', 'values': ['bxor']} + ) + + def test_having(self): + query = json.loads(Query.having([Query.greater_than('total', 1)])) + self.assertEqual(query['method'], 'having') + self.assertEqual(query['values'][0]['method'], 'greaterThan') + + def test_join(self): + self.assertEqual( + parsed(Query.join('orders', '$id', 'customerId')), + {'method': 'join', 'attribute': 'orders', 'values': ['$id', '=', 'customerId']}, + ) + + def test_on(self): + self.assertEqual(parsed(Query.on('$id', 'customerId')), {'method': 'on', 'values': ['$id', '=', 'customerId']}) + + def test_nested_left_join(self): + query = json.loads( + Query.left_join( + 'orders', + 'ord', + [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ], + ) + ) + self.assertEqual(query['method'], 'leftJoin') + self.assertEqual(query['attribute'], 'orders') + self.assertEqual(query['values'][0], 'ord') + self.assertEqual(query['values'][1]['method'], 'on') + self.assertEqual(query['values'][2]['method'], 'equal') + + def test_group_by(self): + self.assertEqual(parsed(Query.group_by(['status'])), {'method': 'groupBy', 'values': ['status']}) + + def test_distinct(self): + self.assertEqual(parsed(Query.distinct()), {'method': 'distinct'}) + + def test_covers(self): + self.assertEqual( + parsed(Query.covers('location', [1, 2])), {'method': 'covers', 'attribute': 'location', 'values': [[1, 2]]} + ) + + def test_left_join(self): + self.assertEqual( + parsed(Query.left_join('orders', '$id', 'customerId', '=', 'ord')), + {'method': 'leftJoin', 'attribute': 'orders', 'values': ['$id', '=', 'customerId', 'ord']}, + ) + + def test_right_join(self): + self.assertEqual( + parsed(Query.right_join('orders', '$id', 'customerId')), + {'method': 'rightJoin', 'attribute': 'orders', 'values': ['$id', '=', 'customerId']}, + ) + + def test_full_outer_join(self): + self.assertEqual( + parsed(Query.full_outer_join('orders', '$id', 'customerId')), + {'method': 'fullOuterJoin', 'attribute': 'orders', 'values': ['$id', '=', 'customerId']}, + ) + + def test_cross_join(self): + self.assertEqual( + parsed(Query.cross_join('orders', 'ord')), {'method': 'crossJoin', 'attribute': 'orders', 'values': ['ord']} + ) + + def test_not_covers(self): + self.assertEqual( + parsed(Query.not_covers('location', [1, 2])), + {'method': 'notCovers', 'attribute': 'location', 'values': [[1, 2]]}, + ) + + def test_spatial_equals(self): + self.assertEqual( + parsed(Query.spatial_equals('location', [1, 2])), + {'method': 'spatialEquals', 'attribute': 'location', 'values': [[1, 2]]}, + ) + + def test_not_spatial_equals(self): + self.assertEqual( + parsed(Query.not_spatial_equals('location', [1, 2])), + {'method': 'notSpatialEquals', 'attribute': 'location', 'values': [[1, 2]]}, + ) + + def test_page(self): + queries = [parsed(query) for query in Query.page(2, 10)] + self.assertEqual( + queries, + [ + {'method': 'limit', 'values': [10]}, + {'method': 'offset', 'values': [10]}, + ], + ) + + def test_builder_build(self): + expected = [ + Query.equal('status', 'published'), + Query.left_join('orders', '$id', 'customerId', '=', 'ord'), + Query.count('*', 'total'), + Query.group_by(['status']), + Query.limit(25), + ] + built = ( + Query.builder() + .equal('status', 'published') + .left_join('orders', '$id', 'customerId', '=', 'ord') + .count('*', 'total') + .group_by(['status']) + .limit(25) + .build() + ) + self.assertEqual(expected, built) + + def test_flatten_spreads_wrapped_builder(self): + self.assertEqual([Query.limit(1)], Query._flatten([Query.builder().limit(1)])) + + def test_flatten_keeps_nested_numeric_arrays(self): + geometry = [[1, 2], [3, 4]] + self.assertEqual(geometry, Query._flatten(geometry)) + + def test_flatten_keeps_query_string_list(self): + self.assertEqual([Query.limit(1)], Query._flatten([Query.limit(1)])) + + def test_flatten_keeps_nested_string_arrays(self): + nested = [['a', 'b']] + self.assertEqual([['a', 'b']], Query._flatten(nested)) + + def test_flatten_builder_param_becomes_query_strings(self): + builder = Query.builder().equal('status', 'active').limit(1) + self.assertEqual([Query.equal('status', 'active'), Query.limit(1)], Query._flatten(builder)) + + def test_flatten_splices_builders_beside_nested_string_arrays(self): + builder = Query.builder().limit(1).offset(2) + self.assertEqual( + [Query.limit(1), Query.offset(2), ['a', 'b']], + Query._flatten([builder, ['a', 'b']]), + ) + + @requests_mock.Mocker() + def test_client_sends_flattened_params(self, m): + m.register_uri( + requests_mock.ANY, + requests_mock.ANY, + json={}, + headers={'Content-Type': 'application/json'}, + ) + client = Client().set_endpoint('https://example.com/v1') + queries = [Query.limit(1), Query.offset(2)] + + def params(): + return {'queries': Query.builder().limit(1).offset(2), 'tags': [['a', 'b']]} + + client.call('post', '/items', {'content-type': 'application/json'}, params()) + client.call('get', '/items', {'content-type': 'application/json'}, params()) + client.call('post', '/items', {'content-type': 'multipart/form-data'}, params()) + + json_request, get_request, multipart_request = m.request_history + self.assertEqual( + {'queries': queries, 'tags': [['a', 'b']]}, + json.loads(json_request.body), + ) + self.assertEqual( + [ + ('queries[0]', queries[0]), + ('queries[1]', queries[1]), + ('tags[0][0]', 'a'), + ('tags[0][1]', 'b'), + ], + parse_qsl(urlsplit(get_request.url).query), + ) + multipart_body = multipart_request.body.decode() + self.assertIn('name="tags[0][0]"\r\n\r\na\r\n', multipart_body) + self.assertIn('name="tags[0][1]"\r\n\r\nb\r\n', multipart_body) + self.assertIn(f'name="queries[1]"\r\n\r\n{queries[1]}\r\n', multipart_body) diff --git a/templates/react-native/src/client.ts.twig b/templates/react-native/src/client.ts.twig index 5d7021fa25..7f1020ff23 100644 --- a/templates/react-native/src/client.ts.twig +++ b/templates/react-native/src/client.ts.twig @@ -1,7 +1,7 @@ import { Models } from './models'; import { Service } from './service'; import { Platform } from 'react-native'; -import { Query } from './query'; +import { Query, flattenParam } from './query'; import JSONbigModule from 'json-bigint'; const JSONbigParser = JSONbigModule({ storeAsString: false }); const JSONbigSerializer = JSONbigModule({ useNativeBigInt: true }); @@ -786,6 +786,10 @@ class Client { ): Promise { method = method.toUpperCase(); + for (const key of Object.keys(params)) { + params[key] = flattenParam(params[key]); + } + headers = Object.assign({}, this.headers, headers); headers.Origin = `{{ spec.info.title | caseLower }}-${Platform.OS}://${this.config.platform}`; diff --git a/templates/react-native/src/index.ts.twig b/templates/react-native/src/index.ts.twig index 4cde5f0c10..ec1ca3e828 100644 --- a/templates/react-native/src/index.ts.twig +++ b/templates/react-native/src/index.ts.twig @@ -19,8 +19,8 @@ export type { UploadProgress, } from './client'; export type { RealtimeSubscription } from './services/realtime'; -export type { QueryTypes, QueryTypesList } from './query'; -export { Query } from './query'; +export type { QueryTypes, QueryTypesList, QueryInput } from './query'; +export { Query, QueryBuilder, flattenParam } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; diff --git a/templates/react-native/src/query.ts.twig b/templates/react-native/src/query.ts.twig index 104b1ab970..be2dff7077 100644 --- a/templates/react-native/src/query.ts.twig +++ b/templates/react-native/src/query.ts.twig @@ -501,4 +501,772 @@ export class Query { */ static notTouches = (attribute: string, values: any[]): string => new Query('notTouches', attribute, [values]).toString(); + + /** + * Count documents, optionally aliased. + */ + static count = (attribute: string = '*', alias: string = ''): string => + new Query( + 'count', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** + * Count distinct values of attribute, optionally aliased. + */ + static countDistinct = (attribute: string, alias: string = ''): string => + new Query( + 'countDistinct', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static sum = (attribute: string, alias: string = ''): string => + new Query( + 'sum', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static avg = (attribute: string, alias: string = ''): string => + new Query( + 'avg', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static min = (attribute: string, alias: string = ''): string => + new Query( + 'min', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static max = (attribute: string, alias: string = ''): string => + new Query( + 'max', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static stddev = (attribute: string, alias: string = ''): string => + new Query( + 'stddev', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static stddevPop = (attribute: string, alias: string = ''): string => + new Query( + 'stddevPop', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static stddevSamp = (attribute: string, alias: string = ''): string => + new Query( + 'stddevSamp', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static variance = (attribute: string, alias: string = ''): string => + new Query( + 'variance', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static varPop = (attribute: string, alias: string = ''): string => + new Query( + 'varPop', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static varSamp = (attribute: string, alias: string = ''): string => + new Query( + 'varSamp', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static bitAnd = (attribute: string, alias: string = ''): string => + new Query( + 'bitAnd', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static bitOr = (attribute: string, alias: string = ''): string => + new Query( + 'bitOr', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + static bitXor = (attribute: string, alias: string = ''): string => + new Query( + 'bitXor', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** + * Group results by attributes. + */ + static groupBy = (attributes: string[]): string => + new Query('groupBy', undefined, attributes).toString(); + + /** + * Filter grouped results. `queries` are nested like `and`/`or`. + */ + static having = (queries: string[]): string => + new Query( + 'having', + undefined, + queries.map((query) => JSONbig.parse(query)), + ).toString(); + + static distinct = (): string => new Query('distinct').toString(); + + /** + * Column-to-column join ON condition. + */ + static on = (left: string, right: string, operator: string = '='): string => + new Query('on', undefined, [left, operator, right]).toString(); + + /** + * Inner join. `table` is the joined collection/table ID. + */ + static join = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'join', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + static leftJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'leftJoin', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + static rightJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'rightJoin', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + static fullOuterJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'fullOuterJoin', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + static crossJoin = (table: string, alias: string = ''): string => + new Query( + 'crossJoin', + table, + alias !== '' ? alias : undefined, + ).toString(); + + static covers = (attribute: string, values: any[]): string => + new Query('covers', attribute, [values]).toString(); + + static notCovers = (attribute: string, values: any[]): string => + new Query('notCovers', attribute, [values]).toString(); + + static spatialEquals = (attribute: string, values: any[]): string => + new Query('spatialEquals', attribute, [values]).toString(); + + static notSpatialEquals = (attribute: string, values: any[]): string => + new Query('notSpatialEquals', attribute, [values]).toString(); + + /** + * Return limit and offset queries for 1-based page pagination. + */ + static page = (page: number, perPage: number = 25): string[] => { + if (page < 1) { + throw new Error('Page must be >= 1, got ' + page); + } + if (perPage < 1) { + throw new Error('Per page must be >= 1, got ' + perPage); + } + return [Query.limit(perPage), Query.offset((page - 1) * perPage)]; + }; + + static builder = (): Query.Builder => new Query.Builder(); + + private static createJoin( + method: string, + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string { + if (Array.isArray(leftOrAliasOrOn)) { + return Query.nestedJoin(method, table, '', leftOrAliasOrOn); + } + if (Array.isArray(rightOrOn)) { + return Query.nestedJoin(method, table, leftOrAliasOrOn, rightOrOn); + } + return new Query( + method, + table, + alias !== '' + ? [leftOrAliasOrOn, operator, rightOrOn, alias] + : [leftOrAliasOrOn, operator, rightOrOn], + ).toString(); + } + + private static nestedJoin( + method: string, + table: string, + alias: string, + on: string[], + ): string { + const values: any[] = []; + if (alias !== '') { + values.push(alias); + } + for (const query of on) { + values.push(JSONbig.parse(query)); + } + return new Query(method, table, values).toString(); + } } + +export type QueryInput = string | Query | Query.Builder | QueryInput[]; + +/** + * Fluent collector of Query fragments. Call {@link Query.Builder.build} to + * produce the `string[]` expected by list/createQuery methods. + */ +export namespace Query { + export class Builder { + private readonly queries: string[] = []; + + build(): string[] { + return [...this.queries]; + } + + equal(attribute: string, value: QueryTypes): this { + return this.push(Query.equal(attribute, value)); + } + + notEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.notEqual(attribute, value)); + } + + lessThan(attribute: string, value: QueryTypes): this { + return this.push(Query.lessThan(attribute, value)); + } + + lessThanEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.lessThanEqual(attribute, value)); + } + + greaterThan(attribute: string, value: QueryTypes): this { + return this.push(Query.greaterThan(attribute, value)); + } + + greaterThanEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.greaterThanEqual(attribute, value)); + } + + search(attribute: string, value: string): this { + return this.push(Query.search(attribute, value)); + } + + isNull(attribute: string): this { + return this.push(Query.isNull(attribute)); + } + + isNotNull(attribute: string): this { + return this.push(Query.isNotNull(attribute)); + } + + between( + attribute: string, + start: string | number | bigint, + end: string | number | bigint, + ): this { + return this.push(Query.between(attribute, start, end)); + } + + startsWith(attribute: string, value: string): this { + return this.push(Query.startsWith(attribute, value)); + } + + endsWith(attribute: string, value: string): this { + return this.push(Query.endsWith(attribute, value)); + } + + select(attributes: string[]): this { + return this.push(Query.select(attributes)); + } + + cursorAfter(documentId: string): this { + return this.push(Query.cursorAfter(documentId)); + } + + cursorBefore(documentId: string): this { + return this.push(Query.cursorBefore(documentId)); + } + + orderAsc(attribute: string): this { + return this.push(Query.orderAsc(attribute)); + } + + orderDesc(attribute: string): this { + return this.push(Query.orderDesc(attribute)); + } + + orderRandom(): this { + return this.push(Query.orderRandom()); + } + + limit(limit: number): this { + return this.push(Query.limit(limit)); + } + + offset(offset: number): this { + return this.push(Query.offset(offset)); + } + + contains(attribute: string, value: string | any[]): this { + return this.push(Query.contains(attribute, value)); + } + + containsAny(attribute: string, value: any[]): this { + return this.push(Query.containsAny(attribute, value)); + } + + containsAll(attribute: string, value: any[]): this { + return this.push(Query.containsAll(attribute, value)); + } + + notContains(attribute: string, value: string | any[]): this { + return this.push(Query.notContains(attribute, value)); + } + + notSearch(attribute: string, value: string): this { + return this.push(Query.notSearch(attribute, value)); + } + + notBetween( + attribute: string, + start: string | number | bigint, + end: string | number | bigint, + ): this { + return this.push(Query.notBetween(attribute, start, end)); + } + + notStartsWith(attribute: string, value: string): this { + return this.push(Query.notStartsWith(attribute, value)); + } + + notEndsWith(attribute: string, value: string): this { + return this.push(Query.notEndsWith(attribute, value)); + } + + regex(attribute: string, pattern: string): this { + return this.push(Query.regex(attribute, pattern)); + } + + exists(attributes: string[]): this { + return this.push(Query.exists(attributes)); + } + + notExists(attributes: string[]): this { + return this.push(Query.notExists(attributes)); + } + + createdBefore(value: string): this { + return this.push(Query.createdBefore(value)); + } + + createdAfter(value: string): this { + return this.push(Query.createdAfter(value)); + } + + createdBetween(start: string, end: string): this { + return this.push(Query.createdBetween(start, end)); + } + + updatedBefore(value: string): this { + return this.push(Query.updatedBefore(value)); + } + + updatedAfter(value: string): this { + return this.push(Query.updatedAfter(value)); + } + + updatedBetween(start: string, end: string): this { + return this.push(Query.updatedBetween(start, end)); + } + + or(queries: string[]): this { + return this.push(Query.or(queries)); + } + + and(queries: string[]): this { + return this.push(Query.and(queries)); + } + + elemMatch(attribute: string, queries: string[]): this { + return this.push(Query.elemMatch(attribute, queries)); + } + + distanceEqual( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceEqual(attribute, values, distance, meters), + ); + } + + distanceNotEqual( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceNotEqual(attribute, values, distance, meters), + ); + } + + distanceGreaterThan( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceGreaterThan(attribute, values, distance, meters), + ); + } + + distanceLessThan( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceLessThan(attribute, values, distance, meters), + ); + } + + intersects(attribute: string, values: any[]): this { + return this.push(Query.intersects(attribute, values)); + } + + notIntersects(attribute: string, values: any[]): this { + return this.push(Query.notIntersects(attribute, values)); + } + + crosses(attribute: string, values: any[]): this { + return this.push(Query.crosses(attribute, values)); + } + + notCrosses(attribute: string, values: any[]): this { + return this.push(Query.notCrosses(attribute, values)); + } + + overlaps(attribute: string, values: any[]): this { + return this.push(Query.overlaps(attribute, values)); + } + + notOverlaps(attribute: string, values: any[]): this { + return this.push(Query.notOverlaps(attribute, values)); + } + + touches(attribute: string, values: any[]): this { + return this.push(Query.touches(attribute, values)); + } + + notTouches(attribute: string, values: any[]): this { + return this.push(Query.notTouches(attribute, values)); + } + + count(attribute: string = '*', alias: string = ''): this { + return this.push(Query.count(attribute, alias)); + } + + countDistinct(attribute: string, alias: string = ''): this { + return this.push(Query.countDistinct(attribute, alias)); + } + + sum(attribute: string, alias: string = ''): this { + return this.push(Query.sum(attribute, alias)); + } + + avg(attribute: string, alias: string = ''): this { + return this.push(Query.avg(attribute, alias)); + } + + min(attribute: string, alias: string = ''): this { + return this.push(Query.min(attribute, alias)); + } + + max(attribute: string, alias: string = ''): this { + return this.push(Query.max(attribute, alias)); + } + + stddev(attribute: string, alias: string = ''): this { + return this.push(Query.stddev(attribute, alias)); + } + + stddevPop(attribute: string, alias: string = ''): this { + return this.push(Query.stddevPop(attribute, alias)); + } + + stddevSamp(attribute: string, alias: string = ''): this { + return this.push(Query.stddevSamp(attribute, alias)); + } + + variance(attribute: string, alias: string = ''): this { + return this.push(Query.variance(attribute, alias)); + } + + varPop(attribute: string, alias: string = ''): this { + return this.push(Query.varPop(attribute, alias)); + } + + varSamp(attribute: string, alias: string = ''): this { + return this.push(Query.varSamp(attribute, alias)); + } + + bitAnd(attribute: string, alias: string = ''): this { + return this.push(Query.bitAnd(attribute, alias)); + } + + bitOr(attribute: string, alias: string = ''): this { + return this.push(Query.bitOr(attribute, alias)); + } + + bitXor(attribute: string, alias: string = ''): this { + return this.push(Query.bitXor(attribute, alias)); + } + + groupBy(attributes: string[]): this { + return this.push(Query.groupBy(attributes)); + } + + having(queries: string[]): this { + return this.push(Query.having(queries)); + } + + distinct(): this { + return this.push(Query.distinct()); + } + + join( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias), + ); + } + + leftJoin( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.leftJoin( + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ), + ); + } + + rightJoin( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.rightJoin( + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ), + ); + } + + fullOuterJoin( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.fullOuterJoin( + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ), + ); + } + + crossJoin(table: string, alias: string = ''): this { + return this.push(Query.crossJoin(table, alias)); + } + + vectorDot(attribute: string, vector: number[]): this { + return this.push(Query.vectorDot(attribute, vector)); + } + + vectorCosine(attribute: string, vector: number[]): this { + return this.push(Query.vectorCosine(attribute, vector)); + } + + vectorEuclidean(attribute: string, vector: number[]): this { + return this.push(Query.vectorEuclidean(attribute, vector)); + } + + covers(attribute: string, values: any[]): this { + return this.push(Query.covers(attribute, values)); + } + + notCovers(attribute: string, values: any[]): this { + return this.push(Query.notCovers(attribute, values)); + } + + spatialEquals(attribute: string, values: any[]): this { + return this.push(Query.spatialEquals(attribute, values)); + } + + notSpatialEquals(attribute: string, values: any[]): this { + return this.push(Query.notSpatialEquals(attribute, values)); + } + + page(page: number, perPage: number = 25): this { + return this.push(Query.page(page, perPage)); + } + + private push(query: string | string[]): this { + if (Array.isArray(query)) { + this.queries.push(...query); + } else { + this.queries.push(query); + } + return this; + } + } +} + +export function normalizeQueries(queries?: QueryInput | null): string[] { + if (queries == null) { + return []; + } + if (queries instanceof Query.Builder) { + return queries.build(); + } + if (!Array.isArray(queries)) { + if (queries instanceof Query) { + return [queries.toString()]; + } + if (typeof queries === 'string') { + return [queries]; + } + return []; + } + + const out: string[] = []; + for (const item of queries) { + out.push(...normalizeQueries(item)); + } + return out; +} + +function isQueryValue(value: unknown): value is Query | Query.Builder { + return value instanceof Query.Builder || value instanceof Query; +} + +export function flattenParam(value: unknown): unknown { + if (isQueryValue(value)) { + return normalizeQueries(value); + } + if (!Array.isArray(value) || !value.some(isQueryValue)) { + return value; + } + + const out: unknown[] = []; + for (const item of value) { + if (isQueryValue(item)) { + out.push(...normalizeQueries(item)); + } else { + out.push(item); + } + } + return out; +} + +export const QueryBuilder = Query.Builder; +export type QueryBuilder = Query.Builder; diff --git a/templates/ruby/lib/container/client.rb.twig b/templates/ruby/lib/container/client.rb.twig index 5d90805893..9c46f50044 100644 --- a/templates/ruby/lib/container/client.rb.twig +++ b/templates/ruby/lib/container/client.rb.twig @@ -110,6 +110,8 @@ module {{ spec.info.title | caseUcfirst }} params: {}, response_type: nil ) + params = params.transform_values { |value| Query._flatten(value) } + separator = path.include?('?') ? '&' : '?' uri = URI.parse(@endpoint + path + ((method == "GET" && params.length) ? separator + encode(params) : '')) diff --git a/templates/ruby/lib/container/query.rb.twig b/templates/ruby/lib/container/query.rb.twig index cde5739790..a353ef1cdf 100644 --- a/templates/ruby/lib/container/query.rb.twig +++ b/templates/ruby/lib/container/query.rb.twig @@ -275,6 +275,187 @@ module {{spec.info.title | caseUcfirst}} def not_touches(attribute, values) return Query.new("notTouches", attribute, [values]).to_s end + + def count(attribute = '*', alias_name = '') + return Query.new("count", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def count_distinct(attribute, alias_name = '') + return Query.new("countDistinct", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def sum(attribute, alias_name = '') + return Query.new("sum", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def avg(attribute, alias_name = '') + return Query.new("avg", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def min(attribute, alias_name = '') + return Query.new("min", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def max(attribute, alias_name = '') + return Query.new("max", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def stddev(attribute, alias_name = '') + return Query.new("stddev", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def stddev_pop(attribute, alias_name = '') + return Query.new("stddevPop", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def stddev_samp(attribute, alias_name = '') + return Query.new("stddevSamp", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def variance(attribute, alias_name = '') + return Query.new("variance", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def var_pop(attribute, alias_name = '') + return Query.new("varPop", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def var_samp(attribute, alias_name = '') + return Query.new("varSamp", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def bit_and(attribute, alias_name = '') + return Query.new("bitAnd", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def bit_or(attribute, alias_name = '') + return Query.new("bitOr", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def bit_xor(attribute, alias_name = '') + return Query.new("bitXor", attribute, alias_name != '' ? alias_name : nil).to_s + end + + def group_by(attributes) + return Query.new("groupBy", nil, attributes).to_s + end + + def having(queries) + return Query.new("having", nil, queries.map { |query| JSON.parse(query) }).to_s + end + + def distinct() + return Query.new("distinct", nil, nil).to_s + end + + def on(left, right, operator = '=') + return Query.new("on", nil, [left, operator, right]).to_s + end + + def join(table, left, right = '', operator = '=', alias_name = '') + return create_join("join", table, left, right, operator, alias_name) + end + + def left_join(table, left, right = '', operator = '=', alias_name = '') + return create_join("leftJoin", table, left, right, operator, alias_name) + end + + def right_join(table, left, right = '', operator = '=', alias_name = '') + return create_join("rightJoin", table, left, right, operator, alias_name) + end + + def full_outer_join(table, left, right = '', operator = '=', alias_name = '') + return create_join("fullOuterJoin", table, left, right, operator, alias_name) + end + + def cross_join(table, alias_name = '') + return Query.new("crossJoin", table, alias_name != '' ? alias_name : nil).to_s + end + + def covers(attribute, values) + return Query.new("covers", attribute, [values]).to_s + end + + def not_covers(attribute, values) + return Query.new("notCovers", attribute, [values]).to_s + end + + def spatial_equals(attribute, values) + return Query.new("spatialEquals", attribute, [values]).to_s + end + + def not_spatial_equals(attribute, values) + return Query.new("notSpatialEquals", attribute, [values]).to_s + end + + def page(page, per_page = 25) + if page < 1 + raise ArgumentError, "Page must be >= 1, got #{page}" + end + if per_page < 1 + raise ArgumentError, "Per page must be >= 1, got #{per_page}" + end + + return [ + limit(per_page), + offset((page - 1) * per_page), + ] + end + + def builder + return Builder.new + end + + def _flatten(value) + return value.build if value.is_a?(Builder) + return value.to_s if value.is_a?(Query) + return value unless value.is_a?(Array) + + out = [] + changed = false + value.each do |item| + flat = _flatten(item) + if item.is_a?(Builder) || item.is_a?(Query) + if flat.is_a?(Array) + out.concat(flat) + else + out << flat + end + changed = true + next + end + changed = true if flat != item + out << flat + end + + changed ? out : value + end + + private + + def create_join(method, table, left, right, operator, alias_name) + return nested_join(method, table, '', left) if left.is_a?(Array) + return nested_join(method, table, left, right) if right.is_a?(Array) + + return join_query(method, table, left, right, operator, alias_name) + end + + def nested_join(method, table, alias_name, on) + values = [] + values << alias_name if alias_name != '' + on.each { |query| values << JSON.parse(query) } + + return Query.new(method, table, values).to_s + end + + def join_query(method, table, left, right, operator, alias_name) + values = [left, operator, right] + values << alias_name if alias_name != '' + + return Query.new(method, table, values).to_s + end end end + + require_relative 'query/builder' end diff --git a/templates/ruby/lib/container/query/builder.rb.twig b/templates/ruby/lib/container/query/builder.rb.twig new file mode 100644 index 0000000000..7690e5b38b --- /dev/null +++ b/templates/ruby/lib/container/query/builder.rb.twig @@ -0,0 +1,357 @@ +require 'json' + +module {{spec.info.title | caseUcfirst}} + class Query::Builder + def initialize + @queries = [] + end + + def build + return @queries.dup + end + + def equal(attribute, value) + return push(Query.equal(attribute, value)) + end + + def not_equal(attribute, value) + return push(Query.not_equal(attribute, value)) + end + + def less_than(attribute, value) + return push(Query.less_than(attribute, value)) + end + + def less_than_equal(attribute, value) + return push(Query.less_than_equal(attribute, value)) + end + + def greater_than(attribute, value) + return push(Query.greater_than(attribute, value)) + end + + def greater_than_equal(attribute, value) + return push(Query.greater_than_equal(attribute, value)) + end + + def search(attribute, value) + return push(Query.search(attribute, value)) + end + + def is_null(attribute) + return push(Query.is_null(attribute)) + end + + def is_not_null(attribute) + return push(Query.is_not_null(attribute)) + end + + def between(attribute, start, ending) + return push(Query.between(attribute, start, ending)) + end + + def starts_with(attribute, value) + return push(Query.starts_with(attribute, value)) + end + + def ends_with(attribute, value) + return push(Query.ends_with(attribute, value)) + end + + def select(attributes) + return push(Query.select(attributes)) + end + + def cursor_after(id) + return push(Query.cursor_after(id)) + end + + def cursor_before(id) + return push(Query.cursor_before(id)) + end + + def order_asc(attribute) + return push(Query.order_asc(attribute)) + end + + def order_desc(attribute) + return push(Query.order_desc(attribute)) + end + + def order_random() + return push(Query.order_random()) + end + + def limit(limit) + return push(Query.limit(limit)) + end + + def offset(offset) + return push(Query.offset(offset)) + end + + def contains(attribute, value) + return push(Query.contains(attribute, value)) + end + + def contains_any(attribute, value) + return push(Query.contains_any(attribute, value)) + end + + def contains_all(attribute, value) + return push(Query.contains_all(attribute, value)) + end + + def not_contains(attribute, value) + return push(Query.not_contains(attribute, value)) + end + + def not_search(attribute, value) + return push(Query.not_search(attribute, value)) + end + + def not_between(attribute, start, ending) + return push(Query.not_between(attribute, start, ending)) + end + + def not_starts_with(attribute, value) + return push(Query.not_starts_with(attribute, value)) + end + + def not_ends_with(attribute, value) + return push(Query.not_ends_with(attribute, value)) + end + + def regex(attribute, pattern) + return push(Query.regex(attribute, pattern)) + end + + def exists(attributes) + return push(Query.exists(attributes)) + end + + def not_exists(attributes) + return push(Query.not_exists(attributes)) + end + + def created_before(value) + return push(Query.created_before(value)) + end + + def created_after(value) + return push(Query.created_after(value)) + end + + def created_between(start, ending) + return push(Query.created_between(start, ending)) + end + + def updated_before(value) + return push(Query.updated_before(value)) + end + + def updated_after(value) + return push(Query.updated_after(value)) + end + + def updated_between(start, ending) + return push(Query.updated_between(start, ending)) + end + + def or(queries) + return push(Query.or(queries)) + end + + def and(queries) + return push(Query.and(queries)) + end + + def elem_match(attribute, queries) + return push(Query.elem_match(attribute, queries)) + end + + def distance_equal(attribute, values, distance, meters = true) + return push(Query.distance_equal(attribute, values, distance, meters)) + end + + def distance_not_equal(attribute, values, distance, meters = true) + return push(Query.distance_not_equal(attribute, values, distance, meters)) + end + + def distance_greater_than(attribute, values, distance, meters = true) + return push(Query.distance_greater_than(attribute, values, distance, meters)) + end + + def distance_less_than(attribute, values, distance, meters = true) + return push(Query.distance_less_than(attribute, values, distance, meters)) + end + + def intersects(attribute, values) + return push(Query.intersects(attribute, values)) + end + + def not_intersects(attribute, values) + return push(Query.not_intersects(attribute, values)) + end + + def crosses(attribute, values) + return push(Query.crosses(attribute, values)) + end + + def not_crosses(attribute, values) + return push(Query.not_crosses(attribute, values)) + end + + def overlaps(attribute, values) + return push(Query.overlaps(attribute, values)) + end + + def not_overlaps(attribute, values) + return push(Query.not_overlaps(attribute, values)) + end + + def touches(attribute, values) + return push(Query.touches(attribute, values)) + end + + def not_touches(attribute, values) + return push(Query.not_touches(attribute, values)) + end + + def count(attribute = '*', alias_name = '') + return push(Query.count(attribute, alias_name)) + end + + def count_distinct(attribute, alias_name = '') + return push(Query.count_distinct(attribute, alias_name)) + end + + def sum(attribute, alias_name = '') + return push(Query.sum(attribute, alias_name)) + end + + def avg(attribute, alias_name = '') + return push(Query.avg(attribute, alias_name)) + end + + def min(attribute, alias_name = '') + return push(Query.min(attribute, alias_name)) + end + + def max(attribute, alias_name = '') + return push(Query.max(attribute, alias_name)) + end + + def stddev(attribute, alias_name = '') + return push(Query.stddev(attribute, alias_name)) + end + + def stddev_pop(attribute, alias_name = '') + return push(Query.stddev_pop(attribute, alias_name)) + end + + def stddev_samp(attribute, alias_name = '') + return push(Query.stddev_samp(attribute, alias_name)) + end + + def variance(attribute, alias_name = '') + return push(Query.variance(attribute, alias_name)) + end + + def var_pop(attribute, alias_name = '') + return push(Query.var_pop(attribute, alias_name)) + end + + def var_samp(attribute, alias_name = '') + return push(Query.var_samp(attribute, alias_name)) + end + + def bit_and(attribute, alias_name = '') + return push(Query.bit_and(attribute, alias_name)) + end + + def bit_or(attribute, alias_name = '') + return push(Query.bit_or(attribute, alias_name)) + end + + def bit_xor(attribute, alias_name = '') + return push(Query.bit_xor(attribute, alias_name)) + end + + def group_by(attributes) + return push(Query.group_by(attributes)) + end + + def having(queries) + return push(Query.having(queries)) + end + + def distinct() + return push(Query.distinct()) + end + + def join(table, left, right = '', operator = '=', alias_name = '') + return push(Query.join(table, left, right, operator, alias_name)) + end + + def left_join(table, left, right = '', operator = '=', alias_name = '') + return push(Query.left_join(table, left, right, operator, alias_name)) + end + + def right_join(table, left, right = '', operator = '=', alias_name = '') + return push(Query.right_join(table, left, right, operator, alias_name)) + end + + def full_outer_join(table, left, right = '', operator = '=', alias_name = '') + return push(Query.full_outer_join(table, left, right, operator, alias_name)) + end + + def cross_join(table, alias_name = '') + return push(Query.cross_join(table, alias_name)) + end + + def vector_dot(attribute, vector) + return push(Query.vector_dot(attribute, vector)) + end + + def vector_cosine(attribute, vector) + return push(Query.vector_cosine(attribute, vector)) + end + + def vector_euclidean(attribute, vector) + return push(Query.vector_euclidean(attribute, vector)) + end + + def covers(attribute, values) + return push(Query.covers(attribute, values)) + end + + def not_covers(attribute, values) + return push(Query.not_covers(attribute, values)) + end + + def spatial_equals(attribute, values) + return push(Query.spatial_equals(attribute, values)) + end + + def not_spatial_equals(attribute, values) + return push(Query.not_spatial_equals(attribute, values)) + end + + def page(page, per_page = 25) + return push(Query.page(page, per_page)) + end + + private + + def push(query) + if query.is_a?(Array) + @queries.concat(query) + else + @queries << query + end + + return self + end + end +end diff --git a/templates/rust/src/client.rs.twig b/templates/rust/src/client.rs.twig index 8bdf1d9fe2..1df80573e8 100644 --- a/templates/rust/src/client.rs.twig +++ b/templates/rust/src/client.rs.twig @@ -1272,6 +1272,7 @@ impl Client { #[cfg(test)] mod tests { use super::*; + use crate::query::Query; #[test] fn test_client_creation() { @@ -1288,4 +1289,60 @@ mod tests { assert_eq!(client.endpoint(), "https://custom.example.com/v1"); } + + async fn mock_server() -> wiremock::MockServer { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&server) + .await; + server + } + + fn params() -> HashMap { + HashMap::from([ + ("values".to_string(), serde_json::json!([["a", "b"]])), + ( + "queries".to_string(), + serde_json::json!(Query::builder().limit(1)), + ), + ]) + } + + #[tokio::test] + async fn json_body_keeps_nested_string_arrays() { + let server = mock_server().await; + let client = Client::new().set_endpoint(server.uri()); + let headers = HashMap::from([("content-type".to_string(), "application/json".to_string())]); + + let _: Value = client + .call(Method::POST, "/", Some(headers), Some(params())) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["values"], serde_json::json!([["a", "b"]])); + assert_eq!( + body["queries"], + serde_json::json!([Query::limit(1).to_string()]) + ); + } + + #[tokio::test] + async fn query_string_keeps_nested_string_arrays() { + let server = mock_server().await; + let client = Client::new().set_endpoint(server.uri()); + + let _: Value = client + .call(Method::GET, "/", None, Some(params())) + .await + .unwrap(); + + let requests = server.received_requests().await.unwrap(); + let pairs: Vec<(String, String)> = requests[0].url.query_pairs().into_owned().collect(); + assert!(pairs.contains(&("values[]".to_string(), "[\"a\",\"b\"]".to_string()))); + assert!(pairs.contains(&("queries[]".to_string(), Query::limit(1).to_string()))); + assert_eq!(pairs.len(), 2); + } } diff --git a/templates/rust/src/query/builder.rs.twig b/templates/rust/src/query/builder.rs.twig new file mode 100644 index 0000000000..f0e780be64 --- /dev/null +++ b/templates/rust/src/query/builder.rs.twig @@ -0,0 +1,612 @@ +use serde::Serialize; +use serde_json::Value; + +use super::Query; + +#[derive(Debug, Clone, Default)] +pub struct Builder { + queries: Vec, +} + +impl Builder { + pub fn new() -> Self { + Self { + queries: Vec::new(), + } + } + + pub fn build(&self) -> Vec { + self.queries.iter().map(|query| query.to_string()).collect() + } + + pub fn equal, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::equal(attribute, value)); + self + } + + pub fn not_equal, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::not_equal(attribute, value)); + self + } + + pub fn less_than, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::less_than(attribute, value)); + self + } + + pub fn less_than_equal, V: Into>( + mut self, + attribute: S, + value: V, + ) -> Self { + self.queries.push(Query::less_than_equal(attribute, value)); + self + } + + pub fn greater_than, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::greater_than(attribute, value)); + self + } + + pub fn greater_than_equal, V: Into>( + mut self, + attribute: S, + value: V, + ) -> Self { + self.queries + .push(Query::greater_than_equal(attribute, value)); + self + } + + pub fn is_null>(mut self, attribute: S) -> Self { + self.queries.push(Query::is_null(attribute)); + self + } + + pub fn is_not_null>(mut self, attribute: S) -> Self { + self.queries.push(Query::is_not_null(attribute)); + self + } + + pub fn between, V: Into>( + mut self, + attribute: S, + start: V, + end: V, + ) -> Self { + self.queries.push(Query::between(attribute, start, end)); + self + } + + pub fn starts_with, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::starts_with(attribute, value)); + self + } + + pub fn ends_with, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::ends_with(attribute, value)); + self + } + + pub fn select, S: Into>(mut self, attributes: I) -> Self { + self.queries.push(Query::select(attributes)); + self + } + + pub fn search, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::search(attribute, value)); + self + } + + pub fn not_search, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::not_search(attribute, value)); + self + } + + pub fn order_asc>(mut self, attribute: S) -> Self { + self.queries.push(Query::order_asc(attribute)); + self + } + + pub fn order_desc>(mut self, attribute: S) -> Self { + self.queries.push(Query::order_desc(attribute)); + self + } + + pub fn order_random(mut self) -> Self { + self.queries.push(Query::order_random()); + self + } + + pub fn cursor_after>(mut self, document_id: S) -> Self { + self.queries.push(Query::cursor_after(document_id)); + self + } + + pub fn cursor_before>(mut self, document_id: S) -> Self { + self.queries.push(Query::cursor_before(document_id)); + self + } + + pub fn limit(mut self, value: u32) -> Self { + self.queries.push(Query::limit(value)); + self + } + + pub fn offset(mut self, value: u32) -> Self { + self.queries.push(Query::offset(value)); + self + } + + pub fn contains, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::contains(attribute, value)); + self + } + + pub fn contains_any, V: Into>( + mut self, + attribute: S, + values: Vec, + ) -> Self { + self.queries.push(Query::contains_any(attribute, values)); + self + } + + pub fn contains_all, V: Into>( + mut self, + attribute: S, + values: Vec, + ) -> Self { + self.queries.push(Query::contains_all(attribute, values)); + self + } + + pub fn not_contains, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::not_contains(attribute, value)); + self + } + + pub fn not_between, V: Into>( + mut self, + attribute: S, + start: V, + end: V, + ) -> Self { + self.queries.push(Query::not_between(attribute, start, end)); + self + } + + pub fn not_starts_with, V: Into>( + mut self, + attribute: S, + value: V, + ) -> Self { + self.queries.push(Query::not_starts_with(attribute, value)); + self + } + + pub fn not_ends_with, V: Into>( + mut self, + attribute: S, + value: V, + ) -> Self { + self.queries.push(Query::not_ends_with(attribute, value)); + self + } + + pub fn created_before>(mut self, value: V) -> Self { + self.queries.push(Query::created_before(value)); + self + } + + pub fn created_after>(mut self, value: V) -> Self { + self.queries.push(Query::created_after(value)); + self + } + + pub fn created_between>(mut self, start: V, end: V) -> Self { + self.queries.push(Query::created_between(start, end)); + self + } + + pub fn updated_before>(mut self, value: V) -> Self { + self.queries.push(Query::updated_before(value)); + self + } + + pub fn updated_after>(mut self, value: V) -> Self { + self.queries.push(Query::updated_after(value)); + self + } + + pub fn updated_between>(mut self, start: V, end: V) -> Self { + self.queries.push(Query::updated_between(start, end)); + self + } + + pub fn distance_equal, V: Into>( + mut self, + attribute: S, + values: V, + distance: impl Into, + meters: bool, + ) -> Self { + self.queries + .push(Query::distance_equal(attribute, values, distance, meters)); + self + } + + pub fn distance_not_equal, V: Into>( + mut self, + attribute: S, + values: V, + distance: impl Into, + meters: bool, + ) -> Self { + self.queries.push(Query::distance_not_equal( + attribute, values, distance, meters, + )); + self + } + + pub fn distance_greater_than, V: Into>( + mut self, + attribute: S, + values: V, + distance: impl Into, + meters: bool, + ) -> Self { + self.queries.push(Query::distance_greater_than( + attribute, values, distance, meters, + )); + self + } + + pub fn distance_less_than, V: Into>( + mut self, + attribute: S, + values: V, + distance: impl Into, + meters: bool, + ) -> Self { + self.queries.push(Query::distance_less_than( + attribute, values, distance, meters, + )); + self + } + + pub fn intersects, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::intersects(attribute, values)); + self + } + + pub fn not_intersects, V: Into>( + mut self, + attribute: S, + values: V, + ) -> Self { + self.queries.push(Query::not_intersects(attribute, values)); + self + } + + pub fn crosses, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::crosses(attribute, values)); + self + } + + pub fn not_crosses, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::not_crosses(attribute, values)); + self + } + + pub fn overlaps, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::overlaps(attribute, values)); + self + } + + pub fn not_overlaps, V: Into>( + mut self, + attribute: S, + values: V, + ) -> Self { + self.queries.push(Query::not_overlaps(attribute, values)); + self + } + + pub fn touches, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::touches(attribute, values)); + self + } + + pub fn not_touches, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::not_touches(attribute, values)); + self + } + + pub fn or(mut self, queries: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.queries.push(Query::or(queries)); + self + } + + pub fn and(mut self, queries: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.queries.push(Query::and(queries)); + self + } + + pub fn regex, V: Into>(mut self, attribute: S, value: V) -> Self { + self.queries.push(Query::regex(attribute, value)); + self + } + + pub fn exists>(mut self, attributes: Vec) -> Self { + self.queries.push(Query::exists(attributes)); + self + } + + pub fn not_exists>(mut self, attributes: Vec) -> Self { + self.queries.push(Query::not_exists(attributes)); + self + } + + pub fn elem_match(mut self, attribute: S, queries: I) -> Self + where + S: Into, + I: IntoIterator, + Q: AsRef, + { + self.queries.push(Query::elem_match(attribute, queries)); + self + } + + pub fn count(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::count(attribute, alias)); + self + } + + pub fn count_distinct( + mut self, + attribute: impl Into, + alias: impl Into, + ) -> Self { + self.queries.push(Query::count_distinct(attribute, alias)); + self + } + + pub fn sum(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::sum(attribute, alias)); + self + } + + pub fn avg(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::avg(attribute, alias)); + self + } + + pub fn min(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::min(attribute, alias)); + self + } + + pub fn max(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::max(attribute, alias)); + self + } + + pub fn stddev(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::stddev(attribute, alias)); + self + } + + pub fn stddev_pop(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::stddev_pop(attribute, alias)); + self + } + + pub fn stddev_samp(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::stddev_samp(attribute, alias)); + self + } + + pub fn variance(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::variance(attribute, alias)); + self + } + + pub fn var_pop(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::var_pop(attribute, alias)); + self + } + + pub fn var_samp(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::var_samp(attribute, alias)); + self + } + + pub fn bit_and(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::bit_and(attribute, alias)); + self + } + + pub fn bit_or(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::bit_or(attribute, alias)); + self + } + + pub fn bit_xor(mut self, attribute: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::bit_xor(attribute, alias)); + self + } + + pub fn group_by(mut self, attributes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.queries.push(Query::group_by(attributes)); + self + } + + pub fn having(mut self, queries: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.queries.push(Query::having(queries)); + self + } + + pub fn distinct(mut self) -> Self { + self.queries.push(Query::distinct()); + self + } + + pub fn join( + mut self, + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + self.queries + .push(Query::join(table, left, right, operator, alias)); + self + } + + pub fn left_join( + mut self, + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + self.queries + .push(Query::left_join(table, left, right, operator, alias)); + self + } + + pub fn left_join_on( + mut self, + table: impl Into, + alias: impl Into, + on: I, + ) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.queries.push(Query::left_join_on(table, alias, on)); + self + } + + pub fn right_join( + mut self, + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + self.queries + .push(Query::right_join(table, left, right, operator, alias)); + self + } + + pub fn full_outer_join( + mut self, + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + self.queries + .push(Query::full_outer_join(table, left, right, operator, alias)); + self + } + + pub fn cross_join(mut self, table: impl Into, alias: impl Into) -> Self { + self.queries.push(Query::cross_join(table, alias)); + self + } + + pub fn vector_dot, N: Into>( + mut self, + attribute: S, + vector: Vec, + ) -> Self { + let values = Value::Array(vector.into_iter().map(|n| n.into()).collect()); + self.queries.push(Query::vector_dot(attribute, values)); + self + } + + pub fn vector_cosine, N: Into>( + mut self, + attribute: S, + vector: Vec, + ) -> Self { + let values = Value::Array(vector.into_iter().map(|n| n.into()).collect()); + self.queries.push(Query::vector_cosine(attribute, values)); + self + } + + pub fn vector_euclidean, N: Into>( + mut self, + attribute: S, + vector: Vec, + ) -> Self { + let values = Value::Array(vector.into_iter().map(|n| n.into()).collect()); + self.queries + .push(Query::vector_euclidean(attribute, values)); + self + } + + pub fn covers, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::covers(attribute, values)); + self + } + + pub fn not_covers, V: Into>(mut self, attribute: S, values: V) -> Self { + self.queries.push(Query::not_covers(attribute, values)); + self + } + + pub fn spatial_equals, V: Into>( + mut self, + attribute: S, + values: V, + ) -> Self { + self.queries.push(Query::spatial_equals(attribute, values)); + self + } + + pub fn not_spatial_equals, V: Into>( + mut self, + attribute: S, + values: V, + ) -> Self { + self.queries + .push(Query::not_spatial_equals(attribute, values)); + self + } + + pub fn page(mut self, page: u32, per_page: u32) -> Self { + self.queries.extend(Query::page(page, per_page)); + self + } +} + +impl Serialize for Builder { + fn serialize(&self, serializer: S) -> Result { + self.build().serialize(serializer) + } +} diff --git a/templates/rust/src/query.rs.twig b/templates/rust/src/query/mod.rs.twig similarity index 67% rename from templates/rust/src/query.rs.twig rename to templates/rust/src/query/mod.rs.twig index 57664a9ae8..f74d033d6f 100644 --- a/templates/rust/src/query.rs.twig +++ b/templates/rust/src/query/mod.rs.twig @@ -3,6 +3,9 @@ use serde::Serialize; use serde_json::Value; +mod builder; +pub use builder::Builder; + /// Query builder for filtering and sorting database queries #[derive(Debug, Clone, Serialize)] pub struct Query { @@ -458,6 +461,289 @@ impl Query { Self::new("elemMatch".to_string(), Some(attribute.into()), values) } + pub fn count(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("count", attribute, alias) + } + + pub fn count_distinct(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("countDistinct", attribute, alias) + } + + pub fn sum(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("sum", attribute, alias) + } + + pub fn avg(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("avg", attribute, alias) + } + + pub fn min(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("min", attribute, alias) + } + + pub fn max(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("max", attribute, alias) + } + + pub fn stddev(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("stddev", attribute, alias) + } + + pub fn stddev_pop(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("stddevPop", attribute, alias) + } + + pub fn stddev_samp(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("stddevSamp", attribute, alias) + } + + pub fn variance(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("variance", attribute, alias) + } + + pub fn var_pop(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("varPop", attribute, alias) + } + + pub fn var_samp(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("varSamp", attribute, alias) + } + + pub fn bit_and(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("bitAnd", attribute, alias) + } + + pub fn bit_or(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("bitOr", attribute, alias) + } + + pub fn bit_xor(attribute: impl Into, alias: impl Into) -> Self { + Self::aliased("bitXor", attribute, alias) + } + + pub fn group_by(attributes: I) -> Self + where + I: IntoIterator, + S: Into, + { + let values: Vec = attributes + .into_iter() + .map(|s| Value::String(s.into())) + .collect(); + Self::new("groupBy".to_string(), None, values) + } + + pub fn having(queries: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let values: Vec = queries + .into_iter() + .filter_map(|query| serde_json::from_str(query.as_ref()).ok()) + .collect(); + Self::new("having".to_string(), None, values) + } + + pub fn distinct() -> Self { + Self::new("distinct".to_string(), None, vec![]) + } + + pub fn on( + left: impl Into, + right: impl Into, + operator: impl Into, + ) -> Self { + Self::new( + "on".to_string(), + None, + vec![ + Value::String(left.into()), + Value::String(operator.into()), + Value::String(right.into()), + ], + ) + } + + pub fn join( + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + Self::join_query("join", table, left, right, operator, alias) + } + + pub fn left_join( + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + Self::join_query("leftJoin", table, left, right, operator, alias) + } + + pub fn right_join( + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + Self::join_query("rightJoin", table, left, right, operator, alias) + } + + pub fn full_outer_join( + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + Self::join_query("fullOuterJoin", table, left, right, operator, alias) + } + + pub fn cross_join(table: impl Into, alias: impl Into) -> Self { + Self::aliased("crossJoin", table, alias) + } + + pub fn join_on(table: impl Into, alias: impl Into, on: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self::nested_join("join", table, alias, on) + } + + pub fn left_join_on(table: impl Into, alias: impl Into, on: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self::nested_join("leftJoin", table, alias, on) + } + + pub fn right_join_on(table: impl Into, alias: impl Into, on: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self::nested_join("rightJoin", table, alias, on) + } + + pub fn full_outer_join_on( + table: impl Into, + alias: impl Into, + on: I, + ) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self::nested_join("fullOuterJoin", table, alias, on) + } + + pub fn covers, V: Into>(attribute: S, values: V) -> Self { + Self::new( + "covers".to_string(), + Some(attribute.into()), + vec![values.into()], + ) + } + + pub fn not_covers, V: Into>(attribute: S, values: V) -> Self { + Self::new( + "notCovers".to_string(), + Some(attribute.into()), + vec![values.into()], + ) + } + + pub fn spatial_equals, V: Into>(attribute: S, values: V) -> Self { + Self::new( + "spatialEquals".to_string(), + Some(attribute.into()), + vec![values.into()], + ) + } + + pub fn not_spatial_equals, V: Into>(attribute: S, values: V) -> Self { + Self::new( + "notSpatialEquals".to_string(), + Some(attribute.into()), + vec![values.into()], + ) + } + + pub fn page(page: u32, per_page: u32) -> Vec { + if page < 1 { + panic!("Page must be >= 1, got {}", page); + } + if per_page < 1 { + panic!("Per page must be >= 1, got {}", per_page); + } + vec![Self::limit(per_page), Self::offset((page - 1) * per_page)] + } + + pub fn builder() -> Builder { + Builder::new() + } + + fn aliased(method: &str, attribute: impl Into, alias: impl Into) -> Self { + let alias = alias.into(); + let values = if alias.is_empty() { + vec![] + } else { + vec![Value::String(alias)] + }; + Self::new(method.to_string(), Some(attribute.into()), values) + } + + fn join_query( + method: &str, + table: impl Into, + left: impl Into, + right: impl Into, + operator: impl Into, + alias: impl Into, + ) -> Self { + let mut values = vec![ + Value::String(left.into()), + Value::String(operator.into()), + Value::String(right.into()), + ]; + let alias = alias.into(); + if !alias.is_empty() { + values.push(Value::String(alias)); + } + Self::new(method.to_string(), Some(table.into()), values) + } + + fn nested_join( + method: &str, + table: impl Into, + alias: impl Into, + on: I, + ) -> Self + where + I: IntoIterator, + S: AsRef, + { + let mut values = Vec::new(); + let alias = alias.into(); + if !alias.is_empty() { + values.push(Value::String(alias)); + } + for query in on { + if let Ok(parsed) = serde_json::from_str::(query.as_ref()) { + values.push(parsed); + } + } + Self::new(method.to_string(), Some(table.into()), values) + } + /// Convert query to JSON value (matches Go/Python SDK format) pub fn to_value(self) -> Value { let mut obj = serde_json::Map::new(); @@ -1093,4 +1379,278 @@ mod tests { assert_eq!(value["attribute"], "content"); assert_eq!(value["values"][0], "spam keyword"); } + + #[test] + fn test_count_with_alias() { + let query = Query::count("*", "total"); + let value = query.to_value(); + assert_eq!(value["method"], "count"); + assert_eq!(value["attribute"], "*"); + assert_eq!(value["values"][0], "total"); + } + + #[test] + fn test_count_without_alias() { + let query = Query::count("*", ""); + let value = query.to_value(); + assert_eq!(value["method"], "count"); + assert_eq!(value["attribute"], "*"); + assert!(value.get("values").is_none()); + } + + #[test] + fn test_count_distinct() { + let value = Query::count_distinct("year", "uniqueYears").to_value(); + assert_eq!(value["method"], "countDistinct"); + assert_eq!(value["attribute"], "year"); + assert_eq!(value["values"][0], "uniqueYears"); + } + + #[test] + fn test_sum() { + assert_eq!(Query::sum("price", "total").to_value()["method"], "sum"); + } + + #[test] + fn test_avg() { + assert_eq!(Query::avg("price", "avgPrice").to_value()["method"], "avg"); + } + + #[test] + fn test_min() { + assert_eq!(Query::min("price", "lowest").to_value()["method"], "min"); + } + + #[test] + fn test_max() { + assert_eq!(Query::max("price", "highest").to_value()["method"], "max"); + } + + #[test] + fn test_stddev() { + assert_eq!(Query::stddev("price", "sd").to_value()["method"], "stddev"); + } + + #[test] + fn test_stddev_pop() { + assert_eq!( + Query::stddev_pop("price", "sdp").to_value()["method"], + "stddevPop" + ); + } + + #[test] + fn test_stddev_samp() { + assert_eq!( + Query::stddev_samp("price", "sds").to_value()["method"], + "stddevSamp" + ); + } + + #[test] + fn test_variance() { + assert_eq!( + Query::variance("price", "var").to_value()["method"], + "variance" + ); + } + + #[test] + fn test_var_pop() { + assert_eq!(Query::var_pop("price", "vp").to_value()["method"], "varPop"); + } + + #[test] + fn test_var_samp() { + assert_eq!( + Query::var_samp("price", "vs").to_value()["method"], + "varSamp" + ); + } + + #[test] + fn test_bit_and() { + assert_eq!( + Query::bit_and("flags", "band").to_value()["method"], + "bitAnd" + ); + } + + #[test] + fn test_bit_or() { + assert_eq!(Query::bit_or("flags", "bor").to_value()["method"], "bitOr"); + } + + #[test] + fn test_bit_xor() { + assert_eq!( + Query::bit_xor("flags", "bxor").to_value()["method"], + "bitXor" + ); + } + + #[test] + fn test_group_by() { + let query = Query::group_by(vec!["status", "year"]); + let value = query.to_value(); + assert_eq!(value["method"], "groupBy"); + assert_eq!(value["values"][0], "status"); + assert_eq!(value["values"][1], "year"); + } + + #[test] + fn test_having() { + let query = Query::having(vec![Query::greater_than("total", 1).to_string()]); + let value = query.to_value(); + assert_eq!(value["method"], "having"); + assert_eq!(value["values"][0]["method"], "greaterThan"); + assert_eq!(value["values"][0]["attribute"], "total"); + assert_eq!(value["values"][0]["values"][0], 1); + } + + #[test] + fn test_distinct() { + let query = Query::distinct(); + let value = query.to_value(); + assert_eq!(value["method"], "distinct"); + } + + #[test] + fn test_join() { + let query = Query::join("orders", "$id", "customerId", "=", ""); + let value = query.to_value(); + assert_eq!(value["method"], "join"); + assert_eq!(value["attribute"], "orders"); + assert_eq!(value["values"][0], "$id"); + assert_eq!(value["values"][1], "="); + assert_eq!(value["values"][2], "customerId"); + assert_eq!(value["values"].as_array().unwrap().len(), 3); + } + + #[test] + fn test_left_join_with_alias() { + let query = Query::left_join("orders", "$id", "customerId", "=", "ord"); + let value = query.to_value(); + assert_eq!(value["method"], "leftJoin"); + assert_eq!(value["attribute"], "orders"); + assert_eq!(value["values"][3], "ord"); + } + + #[test] + fn test_on() { + let query = Query::on("$id", "customerId", "="); + let value = query.to_value(); + assert_eq!(value["method"], "on"); + assert_eq!(value["values"][0], "$id"); + assert_eq!(value["values"][1], "="); + assert_eq!(value["values"][2], "customerId"); + } + + #[test] + fn test_nested_left_join() { + let query = Query::left_join_on( + "orders", + "ord", + [ + Query::on("$id", "customerId", "=").to_string(), + Query::equal("ord.status", "paid").to_string(), + ], + ); + let value = query.to_value(); + assert_eq!(value["method"], "leftJoin"); + assert_eq!(value["attribute"], "orders"); + assert_eq!(value["values"][0], "ord"); + assert_eq!(value["values"][1]["method"], "on"); + assert_eq!(value["values"][2]["method"], "equal"); + } + + #[test] + fn test_cross_join() { + let query = Query::cross_join("orders", "ord"); + let value = query.to_value(); + assert_eq!(value["method"], "crossJoin"); + assert_eq!(value["attribute"], "orders"); + assert_eq!(value["values"][0], "ord"); + } + + #[test] + fn test_right_join() { + let value = Query::right_join("orders", "$id", "customerId", "=", "").to_value(); + assert_eq!(value["method"], "rightJoin"); + assert_eq!(value["values"][0], "$id"); + assert_eq!(value["values"][1], "="); + assert_eq!(value["values"][2], "customerId"); + } + + #[test] + fn test_full_outer_join() { + let value = Query::full_outer_join("orders", "$id", "customerId", "=", "").to_value(); + assert_eq!(value["method"], "fullOuterJoin"); + } + + #[test] + fn test_not_covers() { + let value = Query::not_covers( + "location", + Value::Array(vec![Value::from(1), Value::from(2)]), + ) + .to_value(); + assert_eq!(value["method"], "notCovers"); + assert_eq!(value["values"][0][0], 1); + assert_eq!(value["values"][0][1], 2); + } + + #[test] + fn test_spatial_equals() { + let value = Query::spatial_equals( + "location", + Value::Array(vec![Value::from(1), Value::from(2)]), + ) + .to_value(); + assert_eq!(value["method"], "spatialEquals"); + } + + #[test] + fn test_not_spatial_equals() { + let value = Query::not_spatial_equals( + "location", + Value::Array(vec![Value::from(1), Value::from(2)]), + ) + .to_value(); + assert_eq!(value["method"], "notSpatialEquals"); + } + + #[test] + fn test_page() { + let queries = Query::page(2, 10); + assert_eq!(queries.len(), 2); + let limit = queries[0].clone().to_value(); + let offset = queries[1].clone().to_value(); + assert_eq!(limit["method"], "limit"); + assert_eq!(limit["values"][0], 10); + assert_eq!(offset["method"], "offset"); + assert_eq!(offset["values"][0], 10); + } + + #[test] + fn test_builder_build() { + let built = Query::builder() + .equal("status", "published") + .left_join("orders", "$id", "customerId", "=", "ord") + .count("*", "total") + .group_by(vec!["status"]) + .limit(25) + .build(); + + assert_eq!( + built, + vec![ + Query::equal("status", "published").to_string(), + Query::left_join("orders", "$id", "customerId", "=", "ord").to_string(), + Query::count("*", "total").to_string(), + Query::group_by(vec!["status"]).to_string(), + Query::limit(25).to_string(), + ] + ); + } } diff --git a/templates/rust/tests/tests.rs b/templates/rust/tests/tests.rs index d47a3bf21f..2020acd9a9 100644 --- a/templates/rust/tests/tests.rs +++ b/templates/rust/tests/tests.rs @@ -188,7 +188,7 @@ async fn test_general_service(client: &Client, string_in_array: &[String]) -> Re let (): () = general.empty().await?; // Test Queries - test_queries(); + test_queries(client).await?; // Test Permission Helpers test_permission_helpers(); @@ -259,7 +259,7 @@ async fn test_large_upload(client: &Client, string_in_array: &[String]) -> Resul Ok(()) } -fn test_queries() { +async fn test_queries(client: &Client) -> Result<(), Box> { println!("{}", Query::equal("released", true)); println!("{}", Query::equal("title", vec!["Spiderman", "Dr. Strange"])); println!("{}", Query::not_equal("title", "Spiderman")); @@ -334,6 +334,46 @@ fn test_queries() { Query::equal("name", "Alice").to_string(), Query::greater_than("age", 18).to_string(), ])); + let general = General::new(client); + let mut queries: Vec = vec![ + Query::count("*", "total").to_string(), + Query::join("orders", "$id", "customerId", "=", "").to_string(), + Query::group_by(vec!["status"]).to_string(), + Query::distinct().to_string(), + Query::covers("location", json!([1, 2])).to_string(), + Query::count_distinct("year", "uniqueYears").to_string(), + Query::sum("price", "total").to_string(), + Query::avg("price", "avgPrice").to_string(), + Query::min("price", "lowest").to_string(), + Query::max("price", "highest").to_string(), + Query::stddev("price", "sd").to_string(), + Query::stddev_pop("price", "sdp").to_string(), + Query::stddev_samp("price", "sds").to_string(), + Query::variance("price", "var").to_string(), + Query::var_pop("price", "vp").to_string(), + Query::var_samp("price", "vs").to_string(), + Query::bit_and("flags", "band").to_string(), + Query::bit_or("flags", "bor").to_string(), + Query::bit_xor("flags", "bxor").to_string(), + Query::having(vec![Query::greater_than("total", 1).to_string()]).to_string(), + Query::left_join("orders", "$id", "customerId", "=", "ord").to_string(), + Query::right_join("orders", "$id", "customerId", "=", "").to_string(), + Query::full_outer_join("orders", "$id", "customerId", "=", "").to_string(), + Query::cross_join("orders", "ord").to_string(), + Query::on("$id", "customerId", "=").to_string(), + Query::left_join_on("orders", "ord", vec![ + Query::on("$id", "customerId", "=").to_string(), + Query::equal("ord.status", "paid").to_string(), + ]).to_string(), + Query::not_covers("location", json!([1, 2])).to_string(), + Query::spatial_equals("location", json!([1, 2])).to_string(), + Query::not_spatial_equals("location", json!([1, 2])).to_string(), + ]; + queries.extend(Query::page(2, 10).iter().map(|query| query.to_string())); + queries.extend(Query::builder().limit(1).build()); + println!("{}", general.list_rows(Some(queries)).await?.result); + + Ok(()) } fn test_permission_helpers() { diff --git a/templates/skills/dart.md.twig b/templates/skills/dart.md.twig index 751d63ce0d..13abc23ae7 100644 --- a/templates/skills/dart.md.twig +++ b/templates/skills/dart.md.twig @@ -189,6 +189,41 @@ Query.cursorBefore('[ROW_ID]') Query.select(['field1', 'field2']) // return only specified fields Query.or([Query.equal('a', 1), Query.equal('b', 2)]) // OR Query.and([Query.greaterThan('age', 18), Query.lessThan('age', 65)]) // AND (default) + +// Aggregations (optional alias) +Query.count('*', 'total') +Query.sum('amount', 'revenue') +Query.avg('score', 'avgScore') +Query.min('price', 'minPrice') +Query.max('price', 'maxPrice') + +// Grouping +Query.groupBy(['status', 'year']) +Query.having([Query.greaterThan('total', 1)]) +Query.distinct() + +// Joins (table id, left key, right key, operator, alias) +Query.join('orders', r'$id', 'customerId') +Query.leftJoin('orders', r'$id', 'customerId', '=', 'ord') +Query.leftJoin('orders', 'ord', [ + Query.on(r'$id', 'customerId'), + Query.equal('ord.status', 'paid'), +]) + +// Fluent builder. Pass build() into list methods. +final queries = Query.builder() + .equal('status', 'published') + .leftJoin('orders', r'$id', 'customerId', '=', 'ord') + .count('*', 'total') + .groupBy(['status']) + .limit(25) + .build(); + +await tablesDB.listRows( + databaseId: '[DATABASE_ID]', + tableId: '[TABLE_ID]', + queries: queries, +); ``` ### File Storage diff --git a/templates/skills/dotnet.md.twig b/templates/skills/dotnet.md.twig index 2cd7350986..3681bed2af 100644 --- a/templates/skills/dotnet.md.twig +++ b/templates/skills/dotnet.md.twig @@ -132,6 +132,37 @@ Query.CursorBefore("[ROW_ID]") Query.Select(new List { "field1", "field2" }) Query.Or(new List { Query.Equal("a", 1), Query.Equal("b", 2) }) // OR Query.And(new List { Query.GreaterThan("age", 18), Query.LessThan("age", 65) }) // AND (default) + +// Aggregations (optional alias) +Query.Count("*", "total") +Query.Sum("amount", "revenue") +Query.Avg("score", "avgScore") +Query.Min("price", "minPrice") +Query.Max("price", "maxPrice") + +// Grouping +Query.GroupBy(new List { "status", "year" }) +Query.Having(new List { Query.GreaterThan("total", 1) }) +Query.Distinct() + +// Joins (table id, left key, right key, operator, alias) +Query.Join("orders", "$id", "customerId") +Query.LeftJoin("orders", "$id", "customerId", "=", "ord") +Query.LeftJoin("orders", "ord", new List { + Query.On("$id", "customerId"), + Query.Equal("ord.status", "paid"), +}) + +// Fluent builder. Pass Build() into list methods. +var queries = new Query.Builder() + .Equal("status", "published") + .LeftJoin("orders", "$id", "customerId", "=", "ord") + .Count("*", "total") + .GroupBy(new List { "status" }) + .Limit(25) + .Build(); + +await tablesDB.ListRows("[DATABASE_ID]", "[TABLE_ID]", queries); ``` ### File Storage diff --git a/templates/skills/go.md.twig b/templates/skills/go.md.twig index 3e88f95d23..8d67a9b331 100644 --- a/templates/skills/go.md.twig +++ b/templates/skills/go.md.twig @@ -157,6 +157,37 @@ query.CursorBefore("[ROW_ID]") query.Select([]string{"field1", "field2"}) query.Or([]string{query.Equal("a", 1), query.Equal("b", 2)}) // OR query.And([]string{query.GreaterThan("age", 18), query.LessThan("age", 65)}) // AND (default) + +// Aggregations (optional alias) +query.Count("*", "total") +query.Sum("amount", "revenue") +query.Avg("score", "avgScore") +query.Min("price", "minPrice") +query.Max("price", "maxPrice") + +// Grouping +query.GroupBy([]interface{}{"status", "year"}) +query.Having([]string{query.GreaterThan("total", 1)}) +query.Distinct() + +// Joins (table id, left key, right key, operator, alias) +query.Join("orders", "$id", "customerId", "=", "") +query.LeftJoin("orders", "$id", "customerId", "=", "ord") +query.LeftJoinOn("orders", "ord", []string{ + query.On("$id", "customerId", ""), + query.Equal("ord.status", "paid"), +}) + +// Fluent builder. Pass Build() into list methods. +queries := query.NewBuilder(). + Equal("status", "published"). + LeftJoin("orders", "$id", "customerId", "=", "ord"). + Count("*", "total"). + GroupBy([]interface{}{"status"}). + Limit(25). + Build() + +_, _ = service.ListRows("[DATABASE_ID]", "[TABLE_ID]", service.WithListRowsQueries(queries)) ``` ### File Storage diff --git a/templates/skills/kotlin.md.twig b/templates/skills/kotlin.md.twig index b7c49276c8..bdf12deaa3 100644 --- a/templates/skills/kotlin.md.twig +++ b/templates/skills/kotlin.md.twig @@ -213,6 +213,41 @@ Query.cursorBefore("[ROW_ID]") Query.select(listOf("field1", "field2")) Query.or(listOf(Query.equal("a", 1), Query.equal("b", 2))) // OR Query.and(listOf(Query.greaterThan("age", 18), Query.lessThan("age", 65))) // AND (default) + +// Aggregations (optional alias) +Query.count("*", "total") +Query.sum("amount", "revenue") +Query.avg("score", "avgScore") +Query.min("price", "minPrice") +Query.max("price", "maxPrice") + +// Grouping +Query.groupBy(listOf("status", "year")) +Query.having(listOf(Query.greaterThan("total", 1))) +Query.distinct() + +// Joins (table id, left key, right key, operator, alias) +Query.join("orders", "\$id", "customerId") +Query.leftJoin("orders", "\$id", "customerId", "=", "ord") +Query.leftJoin("orders", "ord", listOf( + Query.on("\$id", "customerId"), + Query.equal("ord.status", "paid"), +)) + +// Fluent builder. Pass build() into list methods. +val queries = Query.builder() + .equal("status", "published") + .leftJoin("orders", "\$id", "customerId", "=", "ord") + .count("*", "total") + .groupBy(listOf("status")) + .limit(25) + .build() + +tablesDB.listRows( + databaseId = "[DATABASE_ID]", + tableId = "[TABLE_ID]", + queries = queries, +) ``` ### File Storage diff --git a/templates/skills/php.md.twig b/templates/skills/php.md.twig index 705f8f262f..0933e68874 100644 --- a/templates/skills/php.md.twig +++ b/templates/skills/php.md.twig @@ -140,6 +140,39 @@ Query::cursorBefore('[ROW_ID]') Query::select(['field1', 'field2']) // return only specified fields Query::or([Query::equal('a', [1]), Query::equal('b', [2])]) // OR Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)]) // AND (default) + +// Aggregations (optional alias) +Query::count('*', 'total') +Query::sum('amount', 'revenue') +Query::avg('score', 'avgScore') +Query::min('price', 'minPrice') +Query::max('price', 'maxPrice') + +// Grouping +Query::groupBy(['status', 'year']) +Query::having([Query::greaterThan('total', 1)]) +Query::distinct() + +// Joins (table id, left key, right key, operator, alias) +Query::join('orders', '$id', 'customerId') +Query::leftJoin('orders', '$id', 'customerId', '=', 'ord') + +// Nested join ON: extra predicates stay on the JOIN, not WHERE +Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', 'paid'), +]) + +// Fluent builder. Pass build() into list methods. +$queries = Query::builder() + ->equal('status', 'published') + ->leftJoin('orders', '$id', 'customerId', '=', 'ord') + ->count('*', 'total') + ->groupBy(['status']) + ->limit(25) + ->build(); + +$tablesDB->listRows('[DATABASE_ID]', '[TABLE_ID]', $queries); ``` ### File Storage diff --git a/templates/skills/python.md.twig b/templates/skills/python.md.twig index 658eed487c..38430bb0fb 100644 --- a/templates/skills/python.md.twig +++ b/templates/skills/python.md.twig @@ -147,6 +147,39 @@ Query.cursor_before('[ROW_ID]') Query.select(['field1', 'field2']) # return only specified fields Query.or_queries([Query.equal('a', 1), Query.equal('b', 2)]) # OR Query.and_queries([Query.greater_than('age', 18), Query.less_than('age', 65)]) # AND (default) + +# Aggregations (optional alias) +Query.count('*', 'total') +Query.sum('amount', 'revenue') +Query.avg('score', 'avg_score') +Query.min('price', 'min_price') +Query.max('price', 'max_price') + +# Grouping +Query.group_by(['status', 'year']) +Query.having([Query.greater_than('total', 1)]) +Query.distinct() + +# Joins (table id, left key, right key, operator, alias) +Query.join('orders', '$id', 'customerId') +Query.left_join('orders', '$id', 'customerId', '=', 'ord') +Query.left_join('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), +]) + +# Fluent builder. Pass build() into list methods. +queries = ( + Query.builder() + .equal('status', 'published') + .left_join('orders', '$id', 'customerId', '=', 'ord') + .count('*', 'total') + .group_by(['status']) + .limit(25) + .build() +) + +tables_db.list_rows('[DATABASE_ID]', '[TABLE_ID]', queries) ``` ### File Storage diff --git a/templates/skills/ruby.md.twig b/templates/skills/ruby.md.twig index 485de2a07f..2280e6a122 100644 --- a/templates/skills/ruby.md.twig +++ b/templates/skills/ruby.md.twig @@ -147,6 +147,37 @@ Query.cursor_before('[ROW_ID]') Query.select(['field1', 'field2']) # return only specified fields Query.or([Query.equal('a', 1), Query.equal('b', 2)]) # OR Query.and([Query.greater_than('age', 18), Query.less_than('age', 65)]) # AND (default) + +# Aggregations (optional alias) +Query.count('*', 'total') +Query.sum('amount', 'revenue') +Query.avg('score', 'avg_score') +Query.min('price', 'min_price') +Query.max('price', 'max_price') + +# Grouping +Query.group_by(['status', 'year']) +Query.having([Query.greater_than('total', 1)]) +Query.distinct + +# Joins (table id, left key, right key, operator, alias) +Query.join('orders', '$id', 'customerId') +Query.left_join('orders', '$id', 'customerId', '=', 'ord') +Query.left_join('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), +]) + +# Fluent builder. Pass build() into list methods. +queries = Query.builder + .equal('status', 'published') + .left_join('orders', '$id', 'customerId', '=', 'ord') + .count('*', 'total') + .group_by(['status']) + .limit(25) + .build + +tables_db.list_rows(database_id: '[DATABASE_ID]', table_id: '[TABLE_ID]', queries: queries) ``` ### File Storage diff --git a/templates/skills/swift.md.twig b/templates/skills/swift.md.twig index 10d229e719..87f01d3e8b 100644 --- a/templates/skills/swift.md.twig +++ b/templates/skills/swift.md.twig @@ -171,6 +171,37 @@ Query.cursorBefore("[ROW_ID]") Query.select(["field1", "field2"]) Query.or([Query.equal("a", value: 1), Query.equal("b", value: 2)]) // OR Query.and([Query.greaterThan("age", value: 18), Query.lessThan("age", value: 65)]) // AND (default) + +// Aggregations (optional alias) +Query.count("*", alias: "total") +Query.sum("amount", alias: "revenue") +Query.avg("score", alias: "avgScore") +Query.min("price", alias: "minPrice") +Query.max("price", alias: "maxPrice") + +// Grouping +Query.groupBy(["status", "year"]) +Query.having([Query.greaterThan("total", value: 1)]) +Query.distinct() + +// Joins (table id, left key, right key, operator, alias) +Query.join("orders", left: "$id", right: "customerId") +Query.leftJoin("orders", left: "$id", right: "customerId", operator: "=", alias: "ord") +Query.leftJoin("orders", alias: "ord", on: [ + Query.on("$id", "customerId"), + Query.equal("ord.status", value: "paid"), +]) + +// Fluent builder. Pass build() into list methods. +let queries = Query.builder() + .equal("status", value: "published") + .leftJoin("orders", left: "$id", right: "customerId", operator: "=", alias: "ord") + .count("*", alias: "total") + .groupBy(["status"]) + .limit(25) + .build() + +_ = try await tablesDB.listRows(databaseId: "[DATABASE_ID]", tableId: "[TABLE_ID]", queries: queries) ``` ### File Storage diff --git a/templates/skills/typescript.md.twig b/templates/skills/typescript.md.twig index d7afc9e2c7..4b0def9081 100644 --- a/templates/skills/typescript.md.twig +++ b/templates/skills/typescript.md.twig @@ -330,6 +330,41 @@ Query.select(['field1', 'field2']) // return only specified fields // Logical Query.or([Query.equal('a', 1), Query.equal('b', 2)]) // OR condition Query.and([Query.greaterThan('age', 18), Query.lessThan('age', 65)]) // explicit AND (queries are AND by default) + +// Aggregations (optional alias) +Query.count('*', 'total') +Query.sum('amount', 'revenue') +Query.avg('score', 'avgScore') +Query.min('price', 'minPrice') +Query.max('price', 'maxPrice') + +// Grouping +Query.groupBy(['status', 'year']) +Query.having([Query.greaterThan('total', 1)]) +Query.distinct() + +// Joins (table id, left key, right key, operator, alias) +Query.join('orders', '$id', 'customerId') +Query.leftJoin('orders', '$id', 'customerId', '=', 'ord') +Query.leftJoin('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), +]) + +// Fluent builder. Pass build() into list methods. +const queries = Query.builder() + .equal('status', 'published') + .leftJoin('orders', '$id', 'customerId', '=', 'ord') + .count('*', 'total') + .groupBy(['status']) + .limit(25) + .build(); + +await tablesDB.listRows({ + databaseId: '[DATABASE_ID]', + tableId: '[TABLE_ID]', + queries, +}); ``` ### File Storage diff --git a/templates/swift/Package.swift.twig b/templates/swift/Package.swift.twig index b1307bc3d6..928f9b8aca 100644 --- a/templates/swift/Package.swift.twig +++ b/templates/swift/Package.swift.twig @@ -15,8 +15,12 @@ let package = Package( name: "{{spec.info.title | caseUcfirst}}", targets: [ "{{spec.info.title | caseUcfirst}}", + {%~ if allEnums is not empty %} "{{spec.info.title | caseUcfirst}}Enums", + {%~ endif %} + {%~ if definitions is not empty %} "{{spec.info.title | caseUcfirst}}Models", + {%~ endif %} "JSONCodable", ] ) diff --git a/templates/swift/Sources/Client.swift.twig b/templates/swift/Sources/Client.swift.twig index ad51dfdc2e..5e00f3d83a 100644 --- a/templates/swift/Sources/Client.swift.twig +++ b/templates/swift/Sources/Client.swift.twig @@ -320,7 +320,10 @@ open class Client { headers: [String: String] = [:], params: [String: Any?] = [:] ) throws -> HTTPClientRequest { - let validParams = params.filter { $0.value != nil } + let validParams = + params + .mapValues { flatten($0) } + .filter { $0.value != nil } let queryParameters = method == "GET" && !validParams.isEmpty @@ -719,6 +722,69 @@ open class Client { value: "{{ spec.info.title | caseLower }}-\(operatingSystem)://\(packageInfo.packageName)" ) } + + public func flatten(_ value: Any?) -> Any? { + return flattened(value).value + } + + private func flattened(_ value: Any?) -> (value: Any?, changed: Bool) { + if let builder = value as? Query.Builder { + return (builder.build(), true) + } + if let query = value as? Query { + return (query.description, true) + } + if value is [String] { + return (value, false) + } + guard let items = arrayElements(value) else { + return (value, false) + } + + var out: [Any] = [] + var changed = false + for item in items { + let flat = flattened(item) + changed = changed || flat.changed + if item is Query.Builder, let queries = flat.value as? [String] { + out.append(contentsOf: queries) + } else if let entry = flat.value { + out.append(entry) + } + } + return changed ? (out, true) : (value, false) + } + + private func arrayElements(_ value: Any?) -> [Any]? { + guard let value, !(value is any StringProtocol) else { + return nil + } + if let typed = value as? [Query.Builder] { + return typed + } + if let typed = value as? [Query] { + return typed + } + if let typed = value as? [String] { + return typed + } + if let array = value as? NSArray { + return (0.. String { + return Query( + method: "count", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func countDistinct(_ attribute: String, alias: String = "") -> String { + return Query( + method: "countDistinct", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func sum(_ attribute: String, alias: String = "") -> String { + return Query( + method: "sum", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func avg(_ attribute: String, alias: String = "") -> String { + return Query( + method: "avg", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func min(_ attribute: String, alias: String = "") -> String { + return Query( + method: "min", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func max(_ attribute: String, alias: String = "") -> String { + return Query( + method: "max", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func stddev(_ attribute: String, alias: String = "") -> String { + return Query( + method: "stddev", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func stddevPop(_ attribute: String, alias: String = "") -> String { + return Query( + method: "stddevPop", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func stddevSamp(_ attribute: String, alias: String = "") -> String { + return Query( + method: "stddevSamp", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func variance(_ attribute: String, alias: String = "") -> String { + return Query( + method: "variance", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func varPop(_ attribute: String, alias: String = "") -> String { + return Query( + method: "varPop", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func varSamp(_ attribute: String, alias: String = "") -> String { + return Query( + method: "varSamp", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func bitAnd(_ attribute: String, alias: String = "") -> String { + return Query( + method: "bitAnd", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func bitOr(_ attribute: String, alias: String = "") -> String { + return Query( + method: "bitOr", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func bitXor(_ attribute: String, alias: String = "") -> String { + return Query( + method: "bitXor", + attribute: attribute, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func groupBy(_ attributes: [String]) -> String { + return Query( + method: "groupBy", + values: attributes + ).description + } + + public static func having(_ queries: [String]) -> String { + return Query( + method: "having", + values: decodeQueries(queries) + ).description + } + + public static func distinct() -> String { + return Query( + method: "distinct" + ).description + } + + public static func on(_ left: String, _ right: String, operator op: String = "=") -> String { + return Query( + method: "on", + values: [left, op, right] + ).description + } + + public static func join(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> String { + return joinQuery("join", table: table, left: left, right: right, operator: op, alias: alias) + } + + public static func join(_ table: String, on: [String]) -> String { + return nestedJoin("join", table: table, alias: "", on: on) + } + + public static func join(_ table: String, alias: String, on: [String]) -> String { + return nestedJoin("join", table: table, alias: alias, on: on) + } + + public static func leftJoin(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> String { + return joinQuery("leftJoin", table: table, left: left, right: right, operator: op, alias: alias) + } + + public static func leftJoin(_ table: String, on: [String]) -> String { + return nestedJoin("leftJoin", table: table, alias: "", on: on) + } + + public static func leftJoin(_ table: String, alias: String, on: [String]) -> String { + return nestedJoin("leftJoin", table: table, alias: alias, on: on) + } + + public static func rightJoin(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> String { + return joinQuery("rightJoin", table: table, left: left, right: right, operator: op, alias: alias) + } + + public static func rightJoin(_ table: String, on: [String]) -> String { + return nestedJoin("rightJoin", table: table, alias: "", on: on) + } + + public static func rightJoin(_ table: String, alias: String, on: [String]) -> String { + return nestedJoin("rightJoin", table: table, alias: alias, on: on) + } + + public static func fullOuterJoin(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> String { + return joinQuery("fullOuterJoin", table: table, left: left, right: right, operator: op, alias: alias) + } + + public static func fullOuterJoin(_ table: String, on: [String]) -> String { + return nestedJoin("fullOuterJoin", table: table, alias: "", on: on) + } + + public static func fullOuterJoin(_ table: String, alias: String, on: [String]) -> String { + return nestedJoin("fullOuterJoin", table: table, alias: alias, on: on) + } + + public static func crossJoin(_ table: String, alias: String = "") -> String { + return Query( + method: "crossJoin", + attribute: table, + values: alias.isEmpty ? nil : alias + ).description + } + + public static func covers(_ attribute: String, values: [Any]) -> String { + return Query( + method: "covers", + attribute: attribute, + values: [values] + ).description + } + + public static func notCovers(_ attribute: String, values: [Any]) -> String { + return Query( + method: "notCovers", + attribute: attribute, + values: [values] + ).description + } + + public static func spatialEquals(_ attribute: String, values: [Any]) -> String { + return Query( + method: "spatialEquals", + attribute: attribute, + values: [values] + ).description + } + + public static func notSpatialEquals(_ attribute: String, values: [Any]) -> String { + return Query( + method: "notSpatialEquals", + attribute: attribute, + values: [values] + ).description + } + + public static func page(_ page: Int, perPage: Int = 25) -> [String] { + precondition(page >= 1, "Page must be >= 1, got \(page)") + precondition(perPage >= 1, "Per page must be >= 1, got \(perPage)") + return [limit(perPage), offset((page - 1) * perPage)] + } + + public static func builder() -> Builder { + return Builder() + } + + private static func joinQuery(_ method: String, table: String, left: String, right: String, operator op: String, alias: String) -> String { + var values: [String] = [left, op, right] + if !alias.isEmpty { + values.append(alias) + } + return Query( + method: method, + attribute: table, + values: values + ).description + } + + private static func nestedJoin(_ method: String, table: String, alias: String, on: [String]) -> String { + var values: [Any] = [] + if !alias.isEmpty { + values.append(alias) + } + for query in on { + if let data = query.data(using: .utf8), + let parsed = try? JSONDecoder().decode(Query.self, from: data) + { + values.append(parsed) + } + } + return Query( + method: method, + attribute: table, + values: values + ).description + } + + private static func decodeQueries(_ queries: [String]) -> [Query] { + let decoder = JSONDecoder() + return queries.compactMap { queryStr -> Query? in + guard let data = queryStr.data(using: .utf8) else { + return nil + } + return try? decoder.decode(Query.self, from: data) + } + } + private static func parseValue(_ value: Any) -> [Any] { if let value = value as? [Any] { return value diff --git a/templates/swift/Sources/Query/Builder.swift.twig b/templates/swift/Sources/Query/Builder.swift.twig new file mode 100644 index 0000000000..0d950f92ad --- /dev/null +++ b/templates/swift/Sources/Query/Builder.swift.twig @@ -0,0 +1,501 @@ +import Foundation + +extension Query { + + public class Builder { + private var queries: [String] = [] + + public init() {} + + public func build() -> [String] { + return queries + } + + @discardableResult + public func equal(_ attribute: String, value: Any) -> Builder { + return push(Query.equal(attribute, value: value)) + } + + @discardableResult + public func notEqual(_ attribute: String, value: Any) -> Builder { + return push(Query.notEqual(attribute, value: value)) + } + + @discardableResult + public func lessThan(_ attribute: String, value: Any) -> Builder { + return push(Query.lessThan(attribute, value: value)) + } + + @discardableResult + public func lessThanEqual(attribute: String, value: Any) -> Builder { + return push(Query.lessThanEqual(attribute: attribute, value: value)) + } + + @discardableResult + public func greaterThan(_ attribute: String, value: Any) -> Builder { + return push(Query.greaterThan(attribute, value: value)) + } + + @discardableResult + public func greaterThanEqual(_ attribute: String, value: Any) -> Builder { + return push(Query.greaterThanEqual(attribute, value: value)) + } + + @discardableResult + public func search(_ attribute: String, value: String) -> Builder { + return push(Query.search(attribute, value: value)) + } + + @discardableResult + public func isNull(_ attribute: String) -> Builder { + return push(Query.isNull(attribute)) + } + + @discardableResult + public func isNotNull(_ attribute: String) -> Builder { + return push(Query.isNotNull(attribute)) + } + + @discardableResult + public func between(_ attribute: String, start: Int, end: Int) -> Builder { + return push(Query.between(attribute, start: start, end: end)) + } + + @discardableResult + public func between(_ attribute: String, start: Double, end: Double) -> Builder { + return push(Query.between(attribute, start: start, end: end)) + } + + @discardableResult + public func between(_ attribute: String, start: String, end: String) -> Builder { + return push(Query.between(attribute, start: start, end: end)) + } + + @discardableResult + public func startsWith(_ attribute: String, value: String) -> Builder { + return push(Query.startsWith(attribute, value: value)) + } + + @discardableResult + public func endsWith(_ attribute: String, value: String) -> Builder { + return push(Query.endsWith(attribute, value: value)) + } + + @discardableResult + public func select(_ attributes: [String]) -> Builder { + return push(Query.select(attributes)) + } + + @discardableResult + public func cursorAfter(_ id: String) -> Builder { + return push(Query.cursorAfter(id)) + } + + @discardableResult + public func cursorBefore(_ id: String) -> Builder { + return push(Query.cursorBefore(id)) + } + + @discardableResult + public func orderAsc(_ attribute: String) -> Builder { + return push(Query.orderAsc(attribute)) + } + + @discardableResult + public func orderDesc(_ attribute: String) -> Builder { + return push(Query.orderDesc(attribute)) + } + + @discardableResult + public func orderRandom() -> Builder { + return push(Query.orderRandom()) + } + + @discardableResult + public func limit(_ limit: Int) -> Builder { + return push(Query.limit(limit)) + } + + @discardableResult + public func offset(_ offset: Int) -> Builder { + return push(Query.offset(offset)) + } + + @discardableResult + public func contains(_ attribute: String, value: Any) -> Builder { + return push(Query.contains(attribute, value: value)) + } + + @discardableResult + public func containsAny(_ attribute: String, value: [Any]) -> Builder { + return push(Query.containsAny(attribute, value: value)) + } + + @discardableResult + public func containsAll(_ attribute: String, value: [Any]) -> Builder { + return push(Query.containsAll(attribute, value: value)) + } + + @discardableResult + public func notContains(_ attribute: String, value: Any) -> Builder { + return push(Query.notContains(attribute, value: value)) + } + + @discardableResult + public func notSearch(_ attribute: String, value: String) -> Builder { + return push(Query.notSearch(attribute, value: value)) + } + + @discardableResult + public func notBetween(_ attribute: String, start: Int, end: Int) -> Builder { + return push(Query.notBetween(attribute, start: start, end: end)) + } + + @discardableResult + public func notBetween(_ attribute: String, start: Double, end: Double) -> Builder { + return push(Query.notBetween(attribute, start: start, end: end)) + } + + @discardableResult + public func notBetween(_ attribute: String, start: String, end: String) -> Builder { + return push(Query.notBetween(attribute, start: start, end: end)) + } + + @discardableResult + public func notStartsWith(_ attribute: String, value: String) -> Builder { + return push(Query.notStartsWith(attribute, value: value)) + } + + @discardableResult + public func notEndsWith(_ attribute: String, value: String) -> Builder { + return push(Query.notEndsWith(attribute, value: value)) + } + + @discardableResult + public func regex(_ attribute: String, pattern: String) -> Builder { + return push(Query.regex(attribute, pattern: pattern)) + } + + @discardableResult + public func exists(_ attributes: [String]) -> Builder { + return push(Query.exists(attributes)) + } + + @discardableResult + public func notExists(_ attributes: [String]) -> Builder { + return push(Query.notExists(attributes)) + } + + @discardableResult + public func createdBefore(_ value: String) -> Builder { + return push(Query.createdBefore(value)) + } + + @discardableResult + public func createdAfter(_ value: String) -> Builder { + return push(Query.createdAfter(value)) + } + + @discardableResult + public func createdBetween(_ start: String, _ end: String) -> Builder { + return push(Query.createdBetween(start, end)) + } + + @discardableResult + public func updatedBefore(_ value: String) -> Builder { + return push(Query.updatedBefore(value)) + } + + @discardableResult + public func updatedAfter(_ value: String) -> Builder { + return push(Query.updatedAfter(value)) + } + + @discardableResult + public func updatedBetween(_ start: String, _ end: String) -> Builder { + return push(Query.updatedBetween(start, end)) + } + + @discardableResult + public func or(_ queries: [String]) -> Builder { + return push(Query.or(queries)) + } + + @discardableResult + public func and(_ queries: [String]) -> Builder { + return push(Query.and(queries)) + } + + @discardableResult + public func elemMatch(_ attribute: String, queries: [String]) -> Builder { + return push(Query.elemMatch(attribute, queries: queries)) + } + + @discardableResult + public func distanceEqual(_ attribute: String, values: [Any], distance: Double, meters: Bool = true) -> Builder { + return push(Query.distanceEqual(attribute, values: values, distance: distance, meters: meters)) + } + + @discardableResult + public func distanceNotEqual(_ attribute: String, values: [Any], distance: Double, meters: Bool = true) -> Builder { + return push(Query.distanceNotEqual(attribute, values: values, distance: distance, meters: meters)) + } + + @discardableResult + public func distanceGreaterThan(_ attribute: String, values: [Any], distance: Double, meters: Bool = true) -> Builder { + return push(Query.distanceGreaterThan(attribute, values: values, distance: distance, meters: meters)) + } + + @discardableResult + public func distanceLessThan(_ attribute: String, values: [Any], distance: Double, meters: Bool = true) -> Builder { + return push(Query.distanceLessThan(attribute, values: values, distance: distance, meters: meters)) + } + + @discardableResult + public func intersects(_ attribute: String, values: [Any]) -> Builder { + return push(Query.intersects(attribute, values: values)) + } + + @discardableResult + public func notIntersects(_ attribute: String, values: [Any]) -> Builder { + return push(Query.notIntersects(attribute, values: values)) + } + + @discardableResult + public func crosses(_ attribute: String, values: [Any]) -> Builder { + return push(Query.crosses(attribute, values: values)) + } + + @discardableResult + public func notCrosses(_ attribute: String, values: [Any]) -> Builder { + return push(Query.notCrosses(attribute, values: values)) + } + + @discardableResult + public func overlaps(_ attribute: String, values: [Any]) -> Builder { + return push(Query.overlaps(attribute, values: values)) + } + + @discardableResult + public func notOverlaps(_ attribute: String, values: [Any]) -> Builder { + return push(Query.notOverlaps(attribute, values: values)) + } + + @discardableResult + public func touches(_ attribute: String, values: [Any]) -> Builder { + return push(Query.touches(attribute, values: values)) + } + + @discardableResult + public func notTouches(_ attribute: String, values: [Any]) -> Builder { + return push(Query.notTouches(attribute, values: values)) + } + + @discardableResult + public func count(_ attribute: String = "*", alias: String = "") -> Builder { + return push(Query.count(attribute, alias: alias)) + } + + @discardableResult + public func countDistinct(_ attribute: String, alias: String = "") -> Builder { + return push(Query.countDistinct(attribute, alias: alias)) + } + + @discardableResult + public func sum(_ attribute: String, alias: String = "") -> Builder { + return push(Query.sum(attribute, alias: alias)) + } + + @discardableResult + public func avg(_ attribute: String, alias: String = "") -> Builder { + return push(Query.avg(attribute, alias: alias)) + } + + @discardableResult + public func min(_ attribute: String, alias: String = "") -> Builder { + return push(Query.min(attribute, alias: alias)) + } + + @discardableResult + public func max(_ attribute: String, alias: String = "") -> Builder { + return push(Query.max(attribute, alias: alias)) + } + + @discardableResult + public func stddev(_ attribute: String, alias: String = "") -> Builder { + return push(Query.stddev(attribute, alias: alias)) + } + + @discardableResult + public func stddevPop(_ attribute: String, alias: String = "") -> Builder { + return push(Query.stddevPop(attribute, alias: alias)) + } + + @discardableResult + public func stddevSamp(_ attribute: String, alias: String = "") -> Builder { + return push(Query.stddevSamp(attribute, alias: alias)) + } + + @discardableResult + public func variance(_ attribute: String, alias: String = "") -> Builder { + return push(Query.variance(attribute, alias: alias)) + } + + @discardableResult + public func varPop(_ attribute: String, alias: String = "") -> Builder { + return push(Query.varPop(attribute, alias: alias)) + } + + @discardableResult + public func varSamp(_ attribute: String, alias: String = "") -> Builder { + return push(Query.varSamp(attribute, alias: alias)) + } + + @discardableResult + public func bitAnd(_ attribute: String, alias: String = "") -> Builder { + return push(Query.bitAnd(attribute, alias: alias)) + } + + @discardableResult + public func bitOr(_ attribute: String, alias: String = "") -> Builder { + return push(Query.bitOr(attribute, alias: alias)) + } + + @discardableResult + public func bitXor(_ attribute: String, alias: String = "") -> Builder { + return push(Query.bitXor(attribute, alias: alias)) + } + + @discardableResult + public func groupBy(_ attributes: [String]) -> Builder { + return push(Query.groupBy(attributes)) + } + + @discardableResult + public func having(_ queries: [String]) -> Builder { + return push(Query.having(queries)) + } + + @discardableResult + public func distinct() -> Builder { + return push(Query.distinct()) + } + + @discardableResult + public func join(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> Builder { + return push(Query.join(table, left: left, right: right, operator: op, alias: alias)) + } + + @discardableResult + public func join(_ table: String, on: [String]) -> Builder { + return push(Query.join(table, on: on)) + } + + @discardableResult + public func join(_ table: String, alias: String, on: [String]) -> Builder { + return push(Query.join(table, alias: alias, on: on)) + } + + @discardableResult + public func leftJoin(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> Builder { + return push(Query.leftJoin(table, left: left, right: right, operator: op, alias: alias)) + } + + @discardableResult + public func leftJoin(_ table: String, on: [String]) -> Builder { + return push(Query.leftJoin(table, on: on)) + } + + @discardableResult + public func leftJoin(_ table: String, alias: String, on: [String]) -> Builder { + return push(Query.leftJoin(table, alias: alias, on: on)) + } + + @discardableResult + public func rightJoin(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> Builder { + return push(Query.rightJoin(table, left: left, right: right, operator: op, alias: alias)) + } + + @discardableResult + public func rightJoin(_ table: String, on: [String]) -> Builder { + return push(Query.rightJoin(table, on: on)) + } + + @discardableResult + public func rightJoin(_ table: String, alias: String, on: [String]) -> Builder { + return push(Query.rightJoin(table, alias: alias, on: on)) + } + + @discardableResult + public func fullOuterJoin(_ table: String, left: String, right: String, operator op: String = "=", alias: String = "") -> Builder { + return push(Query.fullOuterJoin(table, left: left, right: right, operator: op, alias: alias)) + } + + @discardableResult + public func fullOuterJoin(_ table: String, on: [String]) -> Builder { + return push(Query.fullOuterJoin(table, on: on)) + } + + @discardableResult + public func fullOuterJoin(_ table: String, alias: String, on: [String]) -> Builder { + return push(Query.fullOuterJoin(table, alias: alias, on: on)) + } + + @discardableResult + public func crossJoin(_ table: String, alias: String = "") -> Builder { + return push(Query.crossJoin(table, alias: alias)) + } + + @discardableResult + public func vectorDot(_ attribute: String, vector: [Double]) -> Builder { + return push(Query.vectorDot(attribute, vector: vector)) + } + + @discardableResult + public func vectorCosine(_ attribute: String, vector: [Double]) -> Builder { + return push(Query.vectorCosine(attribute, vector: vector)) + } + + @discardableResult + public func vectorEuclidean(_ attribute: String, vector: [Double]) -> Builder { + return push(Query.vectorEuclidean(attribute, vector: vector)) + } + + @discardableResult + public func covers(_ attribute: String, values: [Any]) -> Builder { + return push(Query.covers(attribute, values: values)) + } + + @discardableResult + public func notCovers(_ attribute: String, values: [Any]) -> Builder { + return push(Query.notCovers(attribute, values: values)) + } + + @discardableResult + public func spatialEquals(_ attribute: String, values: [Any]) -> Builder { + return push(Query.spatialEquals(attribute, values: values)) + } + + @discardableResult + public func notSpatialEquals(_ attribute: String, values: [Any]) -> Builder { + return push(Query.notSpatialEquals(attribute, values: values)) + } + + @discardableResult + public func page(_ page: Int, perPage: Int = 25) -> Builder { + return push(Query.page(page, perPage: perPage)) + } + + @discardableResult + private func push(_ query: String) -> Builder { + queries.append(query) + return self + } + + @discardableResult + private func push(_ query: [String]) -> Builder { + queries.append(contentsOf: query) + return self + } + } +} diff --git a/templates/swift/Tests/Tests.swift.twig b/templates/swift/Tests/Tests.swift.twig index 89e865705c..267523b7c8 100644 --- a/templates/swift/Tests/Tests.swift.twig +++ b/templates/swift/Tests/Tests.swift.twig @@ -1,5 +1,183 @@ +import {{ spec.info.title | caseUcfirst }} +import Foundation import XCTest class Tests: XCTestCase { + func method(_ raw: String) -> String { + let object = try! JSONSerialization.jsonObject(with: Data(raw.utf8)) as! [String: Any] + return object["method"] as! String + } + func testCountDistinct() { + XCTAssertEqual(method(Query.countDistinct("year", alias: "uniqueYears")), "countDistinct") + } + + func testSum() { + XCTAssertEqual(method(Query.sum("price", alias: "total")), "sum") + } + + func testAvg() { + XCTAssertEqual(method(Query.avg("price", alias: "avgPrice")), "avg") + } + + func testMin() { + XCTAssertEqual(method(Query.min("price", alias: "lowest")), "min") + } + + func testMax() { + XCTAssertEqual(method(Query.max("price", alias: "highest")), "max") + } + + func testStddev() { + XCTAssertEqual(method(Query.stddev("price", alias: "sd")), "stddev") + } + + func testStddevPop() { + XCTAssertEqual(method(Query.stddevPop("price", alias: "sdp")), "stddevPop") + } + + func testStddevSamp() { + XCTAssertEqual(method(Query.stddevSamp("price", alias: "sds")), "stddevSamp") + } + + func testVariance() { + XCTAssertEqual(method(Query.variance("price", alias: "var")), "variance") + } + + func testVarPop() { + XCTAssertEqual(method(Query.varPop("price", alias: "vp")), "varPop") + } + + func testVarSamp() { + XCTAssertEqual(method(Query.varSamp("price", alias: "vs")), "varSamp") + } + + func testBitAnd() { + XCTAssertEqual(method(Query.bitAnd("flags", alias: "band")), "bitAnd") + } + + func testBitOr() { + XCTAssertEqual(method(Query.bitOr("flags", alias: "bor")), "bitOr") + } + + func testBitXor() { + XCTAssertEqual(method(Query.bitXor("flags", alias: "bxor")), "bitXor") + } + + func testHaving() { + XCTAssertEqual(method(Query.having([Query.greaterThan("total", value: 1)])), "having") + } + + func testLeftJoin() { + XCTAssertEqual(method(Query.leftJoin("orders", left: "$id", right: "customerId", operator: "=", alias: "ord")), "leftJoin") + } + + func testRightJoin() { + XCTAssertEqual(method(Query.rightJoin("orders", left: "$id", right: "customerId")), "rightJoin") + } + + func testFullOuterJoin() { + XCTAssertEqual(method(Query.fullOuterJoin("orders", left: "$id", right: "customerId")), "fullOuterJoin") + } + + func testCrossJoin() { + XCTAssertEqual(method(Query.crossJoin("orders", alias: "ord")), "crossJoin") + } + + func testOn() { + XCTAssertEqual(method(Query.on("$id", "customerId")), "on") + } + + func testNestedLeftJoin() { + let raw = Query.leftJoin( + "orders", alias: "ord", + on: [ + Query.on("$id", "customerId"), + Query.equal("ord.status", value: "paid"), + ]) + let object = try! JSONSerialization.jsonObject(with: Data(raw.utf8)) as! [String: Any] + XCTAssertEqual(object["method"] as? String, "leftJoin") + let values = object["values"] as! [Any] + XCTAssertEqual(values[0] as? String, "ord") + XCTAssertEqual((values[1] as! [String: Any])["method"] as? String, "on") + XCTAssertEqual((values[2] as! [String: Any])["method"] as? String, "equal") + } + + func testNotCovers() { + XCTAssertEqual(method(Query.notCovers("location", values: [1, 2])), "notCovers") + } + + func testSpatialEquals() { + XCTAssertEqual(method(Query.spatialEquals("location", values: [1, 2])), "spatialEquals") + } + + func testNotSpatialEquals() { + XCTAssertEqual(method(Query.notSpatialEquals("location", values: [1, 2])), "notSpatialEquals") + } + + func testPage() { + let queries = Query.page(2, perPage: 10) + XCTAssertEqual(queries.count, 2) + XCTAssertEqual(method(queries[0]), "limit") + XCTAssertEqual(method(queries[1]), "offset") + } + + func queries(_ value: Any?) -> [NSDictionary]? { + guard let list = value as? [Any] else { + return nil + } + return list.compactMap { item in + guard let raw = item as? String else { + return nil + } + return try? JSONSerialization.jsonObject(with: Data(raw.utf8)) as? NSDictionary + } + } + + func testFlattenSpreadsWrappedBuilder() { + let client = Client() + XCTAssertEqual(queries(client.flatten([Query.builder().limit(1)] as [Any])), queries([Query.limit(1)])) + } + + func testFlattenKeepsNestedNumericArrays() { + let client = Client() + let geometry = [[1, 2], [3, 4]] + XCTAssertEqual(client.flatten(geometry) as? [[Int]], geometry) + } + + func testFlattenKeepsQueryStringList() { + let client = Client() + XCTAssertEqual(queries(client.flatten([Query.limit(1)] as [Any])), queries([Query.limit(1)])) + } + + func testFlattenKeepsNestedStringArrays() { + let client = Client() + let nested = [["a", "b"]] + XCTAssertEqual(client.flatten(nested) as? [[String]], nested) + let untyped: [Any] = [["a", "b"]] + XCTAssertEqual((client.flatten(untyped) as? [Any])?.first as? [String], ["a", "b"]) + XCTAssertEqual((client.flatten(["x", ["a", "b"]] as [Any]) as? [Any])?.count, 2) + } + + func testFlattenBuildsBuilder() { + let client = Client() + let builder = Query.builder().equal("status", value: "active").limit(1) + XCTAssertEqual(queries(client.flatten(builder)), queries(builder.build())) + } + + func testFlattenSpreadsBuildersBesideStrings() { + let client = Client() + let builder = Query.builder().limit(1) + XCTAssertEqual( + queries(client.flatten([Query.offset(2), builder] as [Any])), + queries([Query.offset(2), Query.limit(1)]) + ) + } + + func testFlattenSpreadsBuildersInsideNestedList() { + let client = Client() + let builder = Query.builder().limit(1) + let flat = client.flatten([[builder]] as [Any]) as? [Any] + XCTAssertEqual(queries(flat?.first), queries([Query.limit(1)])) + } } diff --git a/templates/web/src/client.ts.twig b/templates/web/src/client.ts.twig index 4deb9bf64f..a21b4b117a 100644 --- a/templates/web/src/client.ts.twig +++ b/templates/web/src/client.ts.twig @@ -1,6 +1,6 @@ import { Models } from './models'; import { Channel, ActionableChannel, ResolvedChannel } from './channel'; -import { Query } from './query'; +import { flattenParam, normalizeQueries, type QueryInput } from './query'; import { ID } from './id'; import JSONbigModule from 'json-bigint'; const JSONbigParser = JSONbigModule({ storeAsString: false }); @@ -451,9 +451,9 @@ class Client { 'x-sdk-platform': '{{ sdk.platform }}', 'x-sdk-language': '{{ language.name | caseLower }}', 'x-sdk-version': '{{ sdk.version }}', - {%~ for key,header in defaultHeaders %} +{%~ for key,header in defaultHeaders %} '{{key}}': '{{header}}', - {%~ endfor %} +{%~ endfor %} }; /** @@ -632,26 +632,26 @@ class Client { } {%~ endif %} - {%~ for headerKey, header in globalHeaders %} +{%~ for headerKey, header in globalHeaders %} /** * Set {{headerKey | caseUcfirst}} * - {%~ if header.description %} +{%~ if header.description %} * {{header.description}} * - {%~ endif %} +{%~ endif %} * @param value string * * @return {this} */ set{{headerKey | caseUcfirst}}(value: string): this { - {%~ if (headerKey != 'Project') %} +{%~ if (headerKey != 'Project') %} this.headers['{{ header.name }}'] = {% if header.scheme == 'bearer' %}`Bearer ${value}`{% else %}value{% endif %}; - {%~ endif %} +{%~ endif %} this.config.{{ headerKey | caseLower }} = value; return this; } - {%~ endfor %} +{%~ endfor %} private realtime: Realtime = { socket: undefined, @@ -966,7 +966,7 @@ class Client { | ResolvedChannel | (Channel | ActionableChannel | ResolvedChannel)[], callback: (payload: RealtimeResponseEvent) => void, - queries: (string | Query)[] = [], + queries: QueryInput = [], ): () => void { const channelArray = Array.isArray(channels) ? channels : [channels]; // Convert Channel instances to strings @@ -985,9 +985,7 @@ class Client { this.realtime.channels.add(channel), ); - const queryStrings = (queries ?? []).map((q) => - typeof q === 'string' ? q : q.toString(), - ); + const queryStrings = normalizeQueries(queries); let subscriptionId = ''; const attempts = this.realtime.subscriptions.size + 1; @@ -1040,10 +1038,21 @@ class Client { ): { uri: string; options: RequestInit } { method = method.toUpperCase(); + for (const key of Object.keys(params)) { + params[key] = flattenParam(params[key]); + } + headers = Object.assign({}, this.headers, headers); -{% set guard = ["typeof window !== 'undefined'", 'window.localStorage'] %}{% if sdk.platform == 'console' %}{% set guard = guard|merge(['this.shouldUseFallbackCredentials(url)']) %}{% endif %} - {{ guard | jsGuard(8) | raw }} +{%~ if sdk.platform == 'console' %} + if ( + typeof window !== 'undefined' && + window.localStorage && + this.shouldUseFallbackCredentials(url) + ) { +{%~ else %} + if (typeof window !== 'undefined' && window.localStorage) { +{%~ endif %} const cookieFallback = window.localStorage.getItem('cookieFallback'); if (cookieFallback) { @@ -1278,20 +1287,20 @@ class Client { return finalResponse ?? lastResponse; } - {%~ for clientMethodName, clientMethods in clientMethods %} - {%~ for clientMethod in clientMethods %} +{%~ for clientMethodName, clientMethods in clientMethods %} +{%~ for clientMethod in clientMethods %} async {{ clientMethodName | caseCamel }}(): Promise { return this.call('{{ clientMethod.method.value | caseUpper }}', new URL(this.config.endpoint + '{{ clientMethod.path }}'), { - {%~ for key, security in (clientMethod | securityHeaders) %} - {{ security.name | jsKey | raw }}: this.config.{{ key | caseLower }}, - {%~ endfor %} - {%~ for key, header in (clientMethod | methodHeaders) %} - {{ key | jsKey | raw }}: '{{ header }}', - {%~ endfor %} +{%~ for key, security in (clientMethod | securityHeaders) %} + '{{ security.name }}': this.config.{{ key | caseLower }}, +{%~ endfor %} +{%~ for key, header in (clientMethod | methodHeaders) %} + {% if key matches '/^[A-Za-z_$][A-Za-z0-9_$]*$/' %}{{ key }}{% else %}'{{ key }}'{% endif %}: '{{ header }}', +{%~ endfor %} }); } - {%~ endfor %} - {%~ endfor %} +{%~ endfor %} +{%~ endfor %} async call( method: string, @@ -1345,7 +1354,7 @@ class Client { } if (400 <= response.status) { - let responseText: string; + let responseText; if ( response.headers .get('content-type') @@ -1369,9 +1378,12 @@ class Client { if ( typeof window !== 'undefined' && window.localStorage && - cookieFallback{% if sdk.platform == 'console' %} && - this.shouldUseFallbackCredentials(url){% endif %} - +{%~ if sdk.platform == 'console' %} + cookieFallback && + this.shouldUseFallbackCredentials(url) +{%~ else %} + cookieFallback +{%~ endif %} ) { window.console.warn( '{{spec.info.title | caseUcfirst}} is using localStorage for session management. Increase your security by adding a custom domain as your API endpoint.', @@ -1408,7 +1420,7 @@ class Client { } export { Client, {{spec.info.title | caseUcfirst}}Exception }; -export { Query } from './query'; +export { Query, QueryBuilder, flattenParam } from './query'; export type { Models, Payload, UploadProgress }; export type { RealtimeResponseEvent }; -export type { QueryTypes, QueryTypesList } from './query'; +export type { QueryTypes, QueryTypesList, QueryInput } from './query'; diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index 212ef88fee..c0a7713936 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -7,7 +7,13 @@ * [previous releases](https://github.com/{{sdk.gitUserName}}/{{sdk.gitRepoName}}/releases) * when APIs differ. */ -export { Client, Query, {{spec.info.title | caseUcfirst}}Exception } from './client'; +export { + Client, + Query, + QueryBuilder, + flattenParam, + {{spec.info.title | caseUcfirst}}Exception, +} from './client'; {% for service in services %} export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseKebab}}'; {% endfor %} @@ -38,7 +44,7 @@ export type { TrackingOptions, } from './services/analytics-tracking'; {% endif %} -export type { QueryTypes, QueryTypesList } from './query'; +export type { QueryTypes, QueryTypesList, QueryInput } from './query'; export { Permission } from './permission'; export { Role } from './role'; export { ID } from './id'; @@ -47,4 +53,4 @@ export { Channel } from './channel'; export { Operator, Condition } from './operator'; {% for enum in allEnums %} export { {{ enum.title | caseUcfirst }} } from './enums/{{enum.title | caseKebab}}'; -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/web/src/query.ts.twig b/templates/web/src/query.ts.twig index 7a7da885ef..f4c2447a1c 100644 --- a/templates/web/src/query.ts.twig +++ b/templates/web/src/query.ts.twig @@ -659,4 +659,795 @@ export class Query { */ static notTouches = (attribute: string, values: any[]): string => new Query('notTouches', attribute, [values]).toString(); + + /** + * Count documents, optionally aliased. + */ + static count = (attribute: string = '*', alias: string = ''): string => + new Query( + 'count', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** + * Count distinct values of attribute, optionally aliased. + */ + static countDistinct = (attribute: string, alias: string = ''): string => + new Query( + 'countDistinct', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Sum values of `attribute`, optionally aliased. */ + static sum = (attribute: string, alias: string = ''): string => + new Query( + 'sum', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Average values of `attribute`, optionally aliased. */ + static avg = (attribute: string, alias: string = ''): string => + new Query( + 'avg', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Minimum value of `attribute`, optionally aliased. */ + static min = (attribute: string, alias: string = ''): string => + new Query( + 'min', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Maximum value of `attribute`, optionally aliased. */ + static max = (attribute: string, alias: string = ''): string => + new Query( + 'max', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Standard deviation of `attribute`, optionally aliased. */ + static stddev = (attribute: string, alias: string = ''): string => + new Query( + 'stddev', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Population standard deviation of `attribute`, optionally aliased. */ + static stddevPop = (attribute: string, alias: string = ''): string => + new Query( + 'stddevPop', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Sample standard deviation of `attribute`, optionally aliased. */ + static stddevSamp = (attribute: string, alias: string = ''): string => + new Query( + 'stddevSamp', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Variance of `attribute`, optionally aliased. */ + static variance = (attribute: string, alias: string = ''): string => + new Query( + 'variance', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Population variance of `attribute`, optionally aliased. */ + static varPop = (attribute: string, alias: string = ''): string => + new Query( + 'varPop', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Sample variance of `attribute`, optionally aliased. */ + static varSamp = (attribute: string, alias: string = ''): string => + new Query( + 'varSamp', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Bitwise AND of `attribute`, optionally aliased. */ + static bitAnd = (attribute: string, alias: string = ''): string => + new Query( + 'bitAnd', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Bitwise OR of `attribute`, optionally aliased. */ + static bitOr = (attribute: string, alias: string = ''): string => + new Query( + 'bitOr', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** Bitwise XOR of `attribute`, optionally aliased. */ + static bitXor = (attribute: string, alias: string = ''): string => + new Query( + 'bitXor', + attribute, + alias !== '' ? alias : undefined, + ).toString(); + + /** + * Group results by attributes. + */ + static groupBy = (attributes: string[]): string => + new Query('groupBy', undefined, attributes).toString(); + + /** + * Filter grouped results. `queries` are nested like `and`/`or`. + */ + static having = (queries: string[]): string => + new Query( + 'having', + undefined, + queries.map((query) => JSONbig.parse(query)), + ).toString(); + + /** Select distinct rows. */ + static distinct = (): string => new Query('distinct').toString(); + + /** + * Column-to-column join ON condition. + */ + static on = (left: string, right: string, operator: string = '='): string => + new Query('on', undefined, [left, operator, right]).toString(); + + /** + * Inner join. `table` is the joined collection/table ID. + */ + static join = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'join', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + /** Left join. `table` is the joined collection/table ID. */ + static leftJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'leftJoin', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + /** Right join. `table` is the joined collection/table ID. */ + static rightJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'rightJoin', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + /** Full outer join. `table` is the joined collection/table ID. */ + static fullOuterJoin = ( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string => + Query.createJoin( + 'fullOuterJoin', + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ); + + /** Cross join. `table` is the joined collection/table ID. */ + static crossJoin = (table: string, alias: string = ''): string => + new Query( + 'crossJoin', + table, + alias !== '' ? alias : undefined, + ).toString(); + + /** Filter resources where `attribute` covers the given geometry. */ + static covers = (attribute: string, values: any[]): string => + new Query('covers', attribute, [values]).toString(); + + /** Filter resources where `attribute` does not cover the given geometry. */ + static notCovers = (attribute: string, values: any[]): string => + new Query('notCovers', attribute, [values]).toString(); + + /** Filter resources where `attribute` is spatially equal to the given geometry. */ + static spatialEquals = (attribute: string, values: any[]): string => + new Query('spatialEquals', attribute, [values]).toString(); + + /** Filter resources where `attribute` is not spatially equal to the given geometry. */ + static notSpatialEquals = (attribute: string, values: any[]): string => + new Query('notSpatialEquals', attribute, [values]).toString(); + + /** + * Return limit and offset queries for 1-based page pagination. + */ + static page = (page: number, perPage: number = 25): string[] => { + if (page < 1) { + throw new Error('Page must be >= 1, got ' + page); + } + if (perPage < 1) { + throw new Error('Per page must be >= 1, got ' + perPage); + } + return [Query.limit(perPage), Query.offset((page - 1) * perPage)]; + }; + + /** Start a fluent query builder. */ + static builder = (): Query.Builder => new Query.Builder(); + + private static createJoin( + method: string, + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): string { + if (Array.isArray(leftOrAliasOrOn)) { + return Query.nestedJoin(method, table, '', leftOrAliasOrOn); + } + if (Array.isArray(rightOrOn)) { + return Query.nestedJoin(method, table, leftOrAliasOrOn, rightOrOn); + } + return new Query( + method, + table, + alias !== '' + ? [leftOrAliasOrOn, operator, rightOrOn, alias] + : [leftOrAliasOrOn, operator, rightOrOn], + ).toString(); + } + + private static nestedJoin( + method: string, + table: string, + alias: string, + on: string[], + ): string { + const values: any[] = []; + if (alias !== '') { + values.push(alias); + } + for (const query of on) { + values.push(JSONbig.parse(query)); + } + return new Query(method, table, values).toString(); + } } + +export type QueryInput = string | Query | Query.Builder | QueryInput[]; + +/** + * Fluent collector of Query fragments. Call {@link Query.Builder.build} to + * produce the `string[]` expected by list/createQuery methods. + */ +export namespace Query { + export class Builder { + private readonly queries: string[] = []; + + build(): string[] { + return [...this.queries]; + } + + equal(attribute: string, value: QueryTypes): this { + return this.push(Query.equal(attribute, value)); + } + + notEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.notEqual(attribute, value)); + } + + lessThan(attribute: string, value: QueryTypes): this { + return this.push(Query.lessThan(attribute, value)); + } + + lessThanEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.lessThanEqual(attribute, value)); + } + + greaterThan(attribute: string, value: QueryTypes): this { + return this.push(Query.greaterThan(attribute, value)); + } + + greaterThanEqual(attribute: string, value: QueryTypes): this { + return this.push(Query.greaterThanEqual(attribute, value)); + } + + search(attribute: string, value: string): this { + return this.push(Query.search(attribute, value)); + } + + isNull(attribute: string): this { + return this.push(Query.isNull(attribute)); + } + + isNotNull(attribute: string): this { + return this.push(Query.isNotNull(attribute)); + } + + between( + attribute: string, + start: string | number | bigint, + end: string | number | bigint, + ): this { + return this.push(Query.between(attribute, start, end)); + } + + startsWith(attribute: string, value: string): this { + return this.push(Query.startsWith(attribute, value)); + } + + endsWith(attribute: string, value: string): this { + return this.push(Query.endsWith(attribute, value)); + } + + select(attributes: string[]): this { + return this.push(Query.select(attributes)); + } + + cursorAfter(documentId: string): this { + return this.push(Query.cursorAfter(documentId)); + } + + cursorBefore(documentId: string): this { + return this.push(Query.cursorBefore(documentId)); + } + + orderAsc(attribute: string): this { + return this.push(Query.orderAsc(attribute)); + } + + orderDesc(attribute: string): this { + return this.push(Query.orderDesc(attribute)); + } + + orderRandom(): this { + return this.push(Query.orderRandom()); + } + + limit(limit: number): this { + return this.push(Query.limit(limit)); + } + + offset(offset: number): this { + return this.push(Query.offset(offset)); + } + + contains(attribute: string, value: string | any[]): this { + return this.push(Query.contains(attribute, value)); + } + + containsAny(attribute: string, value: any[]): this { + return this.push(Query.containsAny(attribute, value)); + } + + containsAll(attribute: string, value: any[]): this { + return this.push(Query.containsAll(attribute, value)); + } + + notContains(attribute: string, value: string | any[]): this { + return this.push(Query.notContains(attribute, value)); + } + + notSearch(attribute: string, value: string): this { + return this.push(Query.notSearch(attribute, value)); + } + + notBetween( + attribute: string, + start: string | number | bigint, + end: string | number | bigint, + ): this { + return this.push(Query.notBetween(attribute, start, end)); + } + + notStartsWith(attribute: string, value: string): this { + return this.push(Query.notStartsWith(attribute, value)); + } + + notEndsWith(attribute: string, value: string): this { + return this.push(Query.notEndsWith(attribute, value)); + } + + regex(attribute: string, pattern: string): this { + return this.push(Query.regex(attribute, pattern)); + } + + exists(attributes: string[]): this { + return this.push(Query.exists(attributes)); + } + + notExists(attributes: string[]): this { + return this.push(Query.notExists(attributes)); + } + + createdBefore(value: string): this { + return this.push(Query.createdBefore(value)); + } + + createdAfter(value: string): this { + return this.push(Query.createdAfter(value)); + } + + createdBetween(start: string, end: string): this { + return this.push(Query.createdBetween(start, end)); + } + + updatedBefore(value: string): this { + return this.push(Query.updatedBefore(value)); + } + + updatedAfter(value: string): this { + return this.push(Query.updatedAfter(value)); + } + + updatedBetween(start: string, end: string): this { + return this.push(Query.updatedBetween(start, end)); + } + + or(queries: string[]): this { + return this.push(Query.or(queries)); + } + + and(queries: string[]): this { + return this.push(Query.and(queries)); + } + + elemMatch(attribute: string, queries: string[]): this { + return this.push(Query.elemMatch(attribute, queries)); + } + + distanceEqual( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceEqual(attribute, values, distance, meters), + ); + } + + distanceNotEqual( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceNotEqual(attribute, values, distance, meters), + ); + } + + distanceGreaterThan( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceGreaterThan(attribute, values, distance, meters), + ); + } + + distanceLessThan( + attribute: string, + values: any[], + distance: number, + meters: boolean = true, + ): this { + return this.push( + Query.distanceLessThan(attribute, values, distance, meters), + ); + } + + intersects(attribute: string, values: any[]): this { + return this.push(Query.intersects(attribute, values)); + } + + notIntersects(attribute: string, values: any[]): this { + return this.push(Query.notIntersects(attribute, values)); + } + + crosses(attribute: string, values: any[]): this { + return this.push(Query.crosses(attribute, values)); + } + + notCrosses(attribute: string, values: any[]): this { + return this.push(Query.notCrosses(attribute, values)); + } + + overlaps(attribute: string, values: any[]): this { + return this.push(Query.overlaps(attribute, values)); + } + + notOverlaps(attribute: string, values: any[]): this { + return this.push(Query.notOverlaps(attribute, values)); + } + + touches(attribute: string, values: any[]): this { + return this.push(Query.touches(attribute, values)); + } + + notTouches(attribute: string, values: any[]): this { + return this.push(Query.notTouches(attribute, values)); + } + + count(attribute: string = '*', alias: string = ''): this { + return this.push(Query.count(attribute, alias)); + } + + countDistinct(attribute: string, alias: string = ''): this { + return this.push(Query.countDistinct(attribute, alias)); + } + + sum(attribute: string, alias: string = ''): this { + return this.push(Query.sum(attribute, alias)); + } + + avg(attribute: string, alias: string = ''): this { + return this.push(Query.avg(attribute, alias)); + } + + min(attribute: string, alias: string = ''): this { + return this.push(Query.min(attribute, alias)); + } + + max(attribute: string, alias: string = ''): this { + return this.push(Query.max(attribute, alias)); + } + + stddev(attribute: string, alias: string = ''): this { + return this.push(Query.stddev(attribute, alias)); + } + + stddevPop(attribute: string, alias: string = ''): this { + return this.push(Query.stddevPop(attribute, alias)); + } + + stddevSamp(attribute: string, alias: string = ''): this { + return this.push(Query.stddevSamp(attribute, alias)); + } + + variance(attribute: string, alias: string = ''): this { + return this.push(Query.variance(attribute, alias)); + } + + varPop(attribute: string, alias: string = ''): this { + return this.push(Query.varPop(attribute, alias)); + } + + varSamp(attribute: string, alias: string = ''): this { + return this.push(Query.varSamp(attribute, alias)); + } + + bitAnd(attribute: string, alias: string = ''): this { + return this.push(Query.bitAnd(attribute, alias)); + } + + bitOr(attribute: string, alias: string = ''): this { + return this.push(Query.bitOr(attribute, alias)); + } + + bitXor(attribute: string, alias: string = ''): this { + return this.push(Query.bitXor(attribute, alias)); + } + + groupBy(attributes: string[]): this { + return this.push(Query.groupBy(attributes)); + } + + having(queries: string[]): this { + return this.push(Query.having(queries)); + } + + distinct(): this { + return this.push(Query.distinct()); + } + + join( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.join(table, leftOrAliasOrOn, rightOrOn, operator, alias), + ); + } + + leftJoin( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.leftJoin( + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ), + ); + } + + rightJoin( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.rightJoin( + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ), + ); + } + + fullOuterJoin( + table: string, + leftOrAliasOrOn: string | string[], + rightOrOn: string | string[] = '', + operator: string = '=', + alias: string = '', + ): this { + return this.push( + Query.fullOuterJoin( + table, + leftOrAliasOrOn, + rightOrOn, + operator, + alias, + ), + ); + } + + crossJoin(table: string, alias: string = ''): this { + return this.push(Query.crossJoin(table, alias)); + } + + vectorDot(attribute: string, vector: number[]): this { + return this.push(Query.vectorDot(attribute, vector)); + } + + vectorCosine(attribute: string, vector: number[]): this { + return this.push(Query.vectorCosine(attribute, vector)); + } + + vectorEuclidean(attribute: string, vector: number[]): this { + return this.push(Query.vectorEuclidean(attribute, vector)); + } + + covers(attribute: string, values: any[]): this { + return this.push(Query.covers(attribute, values)); + } + + notCovers(attribute: string, values: any[]): this { + return this.push(Query.notCovers(attribute, values)); + } + + spatialEquals(attribute: string, values: any[]): this { + return this.push(Query.spatialEquals(attribute, values)); + } + + notSpatialEquals(attribute: string, values: any[]): this { + return this.push(Query.notSpatialEquals(attribute, values)); + } + + page(page: number, perPage: number = 25): this { + return this.push(Query.page(page, perPage)); + } + + private push(query: string | string[]): this { + if (Array.isArray(query)) { + this.queries.push(...query); + } else { + this.queries.push(query); + } + return this; + } + } +} + +export function normalizeQueries(queries?: QueryInput | null): string[] { + if (queries == null) { + return []; + } + if (queries instanceof Query.Builder) { + return queries.build(); + } + if (!Array.isArray(queries)) { + if (queries instanceof Query) { + return [queries.toString()]; + } + if (typeof queries === 'string') { + return [queries]; + } + return []; + } + + const out: string[] = []; + for (const item of queries) { + out.push(...normalizeQueries(item)); + } + return out; +} + +function isQueryValue(value: unknown): value is Query | Query.Builder { + return value instanceof Query.Builder || value instanceof Query; +} + +export function flattenParam(value: unknown): unknown { + if (isQueryValue(value)) { + return normalizeQueries(value); + } + if (!Array.isArray(value) || !value.some(isQueryValue)) { + return value; + } + + const out: unknown[] = []; + for (const item of value) { + if (isQueryValue(item)) { + out.push(...normalizeQueries(item)); + } else { + out.push(item); + } + } + return out; +} + +export const QueryBuilder = Query.Builder; +export type QueryBuilder = Query.Builder; diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index e6e3f83f4f..c983b6272e 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -1,13 +1,14 @@ import { {{ spec.info.title | caseUcfirst}}Exception, Client, JSONbig } from '../client'; import { Channel, ActionableChannel, ResolvedChannel } from '../channel'; -import { Query } from '../query'; +import { normalizeQueries, type QueryInput } from '../query'; import { ID } from '../id'; {% if language.name == 'ReactNative' %} import { Platform } from 'react-native'; {% endif %} + export type RealtimeSubscriptionUpdate = { channels?: (string | Channel | ActionableChannel | ResolvedChannel)[]; - queries?: (string | Query)[]; + queries?: QueryInput; }; export type RealtimeSubscription = { @@ -211,11 +212,11 @@ export class Realtime { } // URL carries only the project; channels/queries are sent via the subscribe message. -{% if language.name != 'ReactNative' %} - let queryParams = `project=${projectId}`; -{% else %} +{%~ if language.name == 'ReactNative' %} const queryParams = `project=${projectId}`; -{% endif %} +{%~ else %} + let queryParams = `project=${projectId}`; +{%~ endif %} const jwt = this.client.config.jwt; {% if language.name != 'ReactNative' %} @@ -223,6 +224,7 @@ export class Realtime { queryParams += `&jwt=${encodeURIComponent(jwt)}`; } {% endif %} + const endpoint = this.client.config.endpointRealtime !== '' ? this.client.config.endpointRealtime @@ -271,6 +273,7 @@ export class Realtime { {% else %} const socket = (this.socket = new WebSocket(url)); {% endif %} + socket.addEventListener('open', () => { if (connectionId !== this.connectionId) { return; @@ -526,7 +529,7 @@ export class Realtime { public async subscribe( channel: string | Channel | ActionableChannel | ResolvedChannel, callback: (event: RealtimeResponseEvent) => void, - queries?: (string | Query)[], + queries?: QueryInput, ): Promise; /** @@ -541,7 +544,7 @@ export class Realtime { string | Channel | ActionableChannel | ResolvedChannel )[], callback: (event: RealtimeResponseEvent) => void, - queries?: (string | Query)[], + queries?: QueryInput, ): Promise; /** @@ -554,7 +557,7 @@ export class Realtime { public async subscribe( channel: string | Channel | ActionableChannel | ResolvedChannel, callback: (event: RealtimeResponseEvent) => void, - queries?: (string | Query)[], + queries?: QueryInput, ): Promise; /** @@ -569,7 +572,7 @@ export class Realtime { string | Channel | ActionableChannel | ResolvedChannel )[], callback: (event: RealtimeResponseEvent) => void, - queries?: (string | Query)[], + queries?: QueryInput, ): Promise; public async subscribe( @@ -580,7 +583,7 @@ export class Realtime { | ResolvedChannel | (string | Channel | ActionableChannel | ResolvedChannel)[], callback: (event: RealtimeResponseEvent) => void, - queries: (string | Query)[] = [], + queries: QueryInput = [], ): Promise { const channelArray = Array.isArray(channelsOrChannel) ? channelsOrChannel @@ -592,21 +595,7 @@ export class Realtime { ); const channels = new Set(channelStrings); - // Convert queries to array of strings - // Ensure each query is a separate string in the array - const queryStrings: string[] = []; - for (const q of queries ?? []) { - if (Array.isArray(q)) { - // Handle nested arrays: [[q1, q2]] -> [q1, q2] - for (const inner of q) { - queryStrings.push( - typeof inner === 'string' ? inner : inner.toString(), - ); - } - } else { - queryStrings.push(typeof q === 'string' ? q : q.toString()); - } - } + const queryStrings = normalizeQueries(queries); const subscriptionId = this.generateUniqueSubscriptionId(); @@ -657,23 +646,7 @@ export class Realtime { } if (changes.queries !== undefined) { - const nextQueries: string[] = []; - for (const q of changes.queries) { - if (Array.isArray(q)) { - for (const inner of q) { - nextQueries.push( - typeof inner === 'string' - ? inner - : (inner as Query).toString(), - ); - } - } else { - nextQueries.push( - typeof q === 'string' ? q : q.toString(), - ); - } - } - subscription.queries = nextQueries; + subscription.queries = normalizeQueries(changes.queries); } this.enqueuePendingSubscribe(subscriptionId); diff --git a/tests/e2e/Android16Java17Test.php b/tests/e2e/Android16Java17Test.php index fd02d771b4..3755fcfcf6 100644 --- a/tests/e2e/Android16Java17Test.php +++ b/tests/e2e/Android16Java17Test.php @@ -60,6 +60,7 @@ final class Android16Java17Test extends Base ...Base::REALTIME_RESPONSES, // ...Base::COOKIE_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Android5Java17Test.php b/tests/e2e/Android5Java17Test.php index e1da0cf355..7363b3320c 100644 --- a/tests/e2e/Android5Java17Test.php +++ b/tests/e2e/Android5Java17Test.php @@ -57,6 +57,7 @@ final class Android5Java17Test extends Base ...Base::REALTIME_RESPONSES, // ...Base::COOKIE_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/AppleSwift61Test.php b/tests/e2e/AppleSwift61Test.php index d2a02b990b..0683369587 100644 --- a/tests/e2e/AppleSwift61Test.php +++ b/tests/e2e/AppleSwift61Test.php @@ -58,6 +58,7 @@ final class AppleSwift61Test extends Base ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index 9f83998aad..e36bb1beab 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -442,6 +442,46 @@ abstract class Base extends TestCase '{"method":"elemMatch","attribute":"friends","values":[{"method":"equal","attribute":"name","values":["Alice"]},{"method":"greaterThan","attribute":"age","values":[18]}]}', ]; + // Each language harness sends these through a real listRows request and prints + // the echo the mock server returns, so the assertion reads the request the + // server received rather than what a helper rendered in process. + protected const QUERY_TRANSPORT_RESPONSE = '__QUERY_TRANSPORT_RESPONSE__'; + + protected const QUERY_TRANSPORT_QUERIES = [ + '{"method":"count","attribute":"*","values":["total"]}', + '{"method":"join","attribute":"orders","values":["$id","=","customerId"]}', + '{"method":"groupBy","values":["status"]}', + '{"method":"distinct"}', + '{"method":"covers","attribute":"location","values":[[1,2]]}', + '{"method":"countDistinct","attribute":"year","values":["uniqueYears"]}', + '{"method":"sum","attribute":"price","values":["total"]}', + '{"method":"avg","attribute":"price","values":["avgPrice"]}', + '{"method":"min","attribute":"price","values":["lowest"]}', + '{"method":"max","attribute":"price","values":["highest"]}', + '{"method":"stddev","attribute":"price","values":["sd"]}', + '{"method":"stddevPop","attribute":"price","values":["sdp"]}', + '{"method":"stddevSamp","attribute":"price","values":["sds"]}', + '{"method":"variance","attribute":"price","values":["var"]}', + '{"method":"varPop","attribute":"price","values":["vp"]}', + '{"method":"varSamp","attribute":"price","values":["vs"]}', + '{"method":"bitAnd","attribute":"flags","values":["band"]}', + '{"method":"bitOr","attribute":"flags","values":["bor"]}', + '{"method":"bitXor","attribute":"flags","values":["bxor"]}', + '{"method":"having","values":[{"method":"greaterThan","attribute":"total","values":[1]}]}', + '{"method":"leftJoin","attribute":"orders","values":["$id","=","customerId","ord"]}', + '{"method":"rightJoin","attribute":"orders","values":["$id","=","customerId"]}', + '{"method":"fullOuterJoin","attribute":"orders","values":["$id","=","customerId"]}', + '{"method":"crossJoin","attribute":"orders","values":["ord"]}', + '{"method":"on","values":["$id","=","customerId"]}', + '{"method":"leftJoin","attribute":"orders","values":["ord",{"method":"on","values":["$id","=","customerId"]},{"method":"equal","attribute":"ord.status","values":["paid"]}]}', + '{"method":"notCovers","attribute":"location","values":[[1,2]]}', + '{"method":"spatialEquals","attribute":"location","values":[[1,2]]}', + '{"method":"notSpatialEquals","attribute":"location","values":[[1,2]]}', + '{"method":"limit","values":[10]}', + '{"method":"offset","values":[10]}', + '{"method":"limit","values":[1]}', + ]; + protected const PERMISSION_HELPER_RESPONSES = [ 'read("any")', 'write("user:userid")', @@ -805,6 +845,11 @@ public function testHTTPSuccess(): void $expected = $this->getExpectedSdkHeaders() . '; accept: application/json, text/plain'; } + if ($expected === self::QUERY_TRANSPORT_RESPONSE) { + $this->assertQueriesReachedTheServer($output[$index] ?? null); + continue; + } + // HACK: Swift does not guarantee the order of the JSON parameters if (\str_starts_with((string) $expected, '{')) { $this->assertEquals( @@ -822,6 +867,30 @@ public function testHTTPSuccess(): void } } + private function assertQueriesReachedTheServer(?string $echo): void + { + $received = \json_decode((string) $echo, true); + + $this->assertIsArray( + $received, + "The SDK did not print the query list the mock server echoes back, it printed: {$echo}" + ); + $this->assertCount( + \count(self::QUERY_TRANSPORT_QUERIES), + $received, + 'The server received a different number of queries than the SDK was given' + ); + + foreach (self::QUERY_TRANSPORT_QUERIES as $position => $expected) { + // HACK: Swift does not guarantee the order of the JSON parameters + $this->assertEquals( + \json_decode($expected, true), + \json_decode((string) $received[$position], true), + "Query {$position} did not reach the server as the helper described it" + ); + } + } + private function rmdirRecursive(string $dir): void { if (!\is_dir($dir)) { diff --git a/tests/e2e/DartBetaTest.php b/tests/e2e/DartBetaTest.php index 00224ec053..5c319fa7c3 100644 --- a/tests/e2e/DartBetaTest.php +++ b/tests/e2e/DartBetaTest.php @@ -55,6 +55,7 @@ final class DartBetaTest extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/DartStableTest.php b/tests/e2e/DartStableTest.php index fefdf13625..f4d2f29b89 100644 --- a/tests/e2e/DartStableTest.php +++ b/tests/e2e/DartStableTest.php @@ -55,6 +55,7 @@ final class DartStableTest extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Deno1193Test.php b/tests/e2e/Deno1193Test.php index efed5ce6b3..86908db26c 100644 --- a/tests/e2e/Deno1193Test.php +++ b/tests/e2e/Deno1193Test.php @@ -53,6 +53,7 @@ final class Deno1193Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES diff --git a/tests/e2e/Deno1303Test.php b/tests/e2e/Deno1303Test.php index 7c4ff8a4c2..fbd5d6b485 100644 --- a/tests/e2e/Deno1303Test.php +++ b/tests/e2e/Deno1303Test.php @@ -53,6 +53,7 @@ final class Deno1303Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::OPERATOR_HELPER_RESPONSES diff --git a/tests/e2e/DotNet60Test.php b/tests/e2e/DotNet60Test.php index d505913bb0..93703708b1 100644 --- a/tests/e2e/DotNet60Test.php +++ b/tests/e2e/DotNet60Test.php @@ -55,6 +55,7 @@ final class DotNet60Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/DotNet80Test.php b/tests/e2e/DotNet80Test.php index ab5d5ac204..81e326f673 100644 --- a/tests/e2e/DotNet80Test.php +++ b/tests/e2e/DotNet80Test.php @@ -55,6 +55,7 @@ final class DotNet80Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/DotNet90Test.php b/tests/e2e/DotNet90Test.php index d84f589cc0..4d2d5f4373 100644 --- a/tests/e2e/DotNet90Test.php +++ b/tests/e2e/DotNet90Test.php @@ -55,6 +55,7 @@ final class DotNet90Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/FlutterBetaTest.php b/tests/e2e/FlutterBetaTest.php index 00adc7e05c..546ed9d6a9 100644 --- a/tests/e2e/FlutterBetaTest.php +++ b/tests/e2e/FlutterBetaTest.php @@ -56,6 +56,7 @@ final class FlutterBetaTest extends Base ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/FlutterStableTest.php b/tests/e2e/FlutterStableTest.php index 9ce6d0cbe0..93cc5e0d23 100644 --- a/tests/e2e/FlutterStableTest.php +++ b/tests/e2e/FlutterStableTest.php @@ -76,6 +76,7 @@ final class FlutterStableTest extends Base ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Go113Test.php b/tests/e2e/Go113Test.php index b705fd5aca..bb1f99ff89 100644 --- a/tests/e2e/Go113Test.php +++ b/tests/e2e/Go113Test.php @@ -49,6 +49,7 @@ final class Go113Test extends Base ...Base::ENUM_ARRAY_MODEL_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Go118Test.php b/tests/e2e/Go118Test.php index 3b28a6f9e7..750f95daf6 100644 --- a/tests/e2e/Go118Test.php +++ b/tests/e2e/Go118Test.php @@ -49,6 +49,7 @@ final class Go118Test extends Base ...Base::ENUM_ARRAY_MODEL_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/KotlinJava17Test.php b/tests/e2e/KotlinJava17Test.php index b33796f3de..d4fcaaae93 100644 --- a/tests/e2e/KotlinJava17Test.php +++ b/tests/e2e/KotlinJava17Test.php @@ -56,6 +56,7 @@ final class KotlinJava17Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Node18Test.php b/tests/e2e/Node18Test.php index 596e3ffb97..376fc566a2 100644 --- a/tests/e2e/Node18Test.php +++ b/tests/e2e/Node18Test.php @@ -66,6 +66,7 @@ final class Node18Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Node20Test.php b/tests/e2e/Node20Test.php index a62de628ea..5678ea549f 100644 --- a/tests/e2e/Node20Test.php +++ b/tests/e2e/Node20Test.php @@ -66,6 +66,7 @@ final class Node20Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Node26Test.php b/tests/e2e/Node26Test.php index e1735a8cd0..6bb5811c87 100644 --- a/tests/e2e/Node26Test.php +++ b/tests/e2e/Node26Test.php @@ -66,6 +66,7 @@ final class Node26Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/PHP85Test.php b/tests/e2e/PHP85Test.php index 46548cd643..a1d68aa1ca 100644 --- a/tests/e2e/PHP85Test.php +++ b/tests/e2e/PHP85Test.php @@ -56,6 +56,7 @@ final class PHP85Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Python310Test.php b/tests/e2e/Python310Test.php index d832be9977..7f2de09ce1 100644 --- a/tests/e2e/Python310Test.php +++ b/tests/e2e/Python310Test.php @@ -56,6 +56,7 @@ final class Python310Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Python311Test.php b/tests/e2e/Python311Test.php index 5b8e8327d6..1daaa12f96 100644 --- a/tests/e2e/Python311Test.php +++ b/tests/e2e/Python311Test.php @@ -56,6 +56,7 @@ final class Python311Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Python312Test.php b/tests/e2e/Python312Test.php index 2bf3d2cbe8..0dbf08158a 100644 --- a/tests/e2e/Python312Test.php +++ b/tests/e2e/Python312Test.php @@ -56,6 +56,7 @@ final class Python312Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Python313Test.php b/tests/e2e/Python313Test.php index 557d86aeb8..43d5dbe553 100644 --- a/tests/e2e/Python313Test.php +++ b/tests/e2e/Python313Test.php @@ -56,6 +56,7 @@ final class Python313Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Python39Test.php b/tests/e2e/Python39Test.php index 68bccaaf3f..76e11be0a1 100644 --- a/tests/e2e/Python39Test.php +++ b/tests/e2e/Python39Test.php @@ -56,6 +56,7 @@ final class Python39Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/ReactNativeTest.php b/tests/e2e/ReactNativeTest.php index 8044e9f5e4..6b0cb1a8e5 100644 --- a/tests/e2e/ReactNativeTest.php +++ b/tests/e2e/ReactNativeTest.php @@ -69,6 +69,7 @@ final class ReactNativeTest extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Ruby27Test.php b/tests/e2e/Ruby27Test.php index 20d21caac0..00d20f20d0 100644 --- a/tests/e2e/Ruby27Test.php +++ b/tests/e2e/Ruby27Test.php @@ -52,6 +52,7 @@ final class Ruby27Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Ruby30Test.php b/tests/e2e/Ruby30Test.php index e1f73dbf35..bd4adcdc9e 100644 --- a/tests/e2e/Ruby30Test.php +++ b/tests/e2e/Ruby30Test.php @@ -52,6 +52,7 @@ final class Ruby30Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Ruby31Test.php b/tests/e2e/Ruby31Test.php index 302ea41f3f..f3c7cf803a 100644 --- a/tests/e2e/Ruby31Test.php +++ b/tests/e2e/Ruby31Test.php @@ -52,6 +52,7 @@ final class Ruby31Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Rust183Test.php b/tests/e2e/Rust183Test.php index e7f453e91a..47c7106ada 100644 --- a/tests/e2e/Rust183Test.php +++ b/tests/e2e/Rust183Test.php @@ -46,6 +46,7 @@ final class Rust183Test extends Base ...Base::ENUM_DEFAULT_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Swift61Test.php b/tests/e2e/Swift61Test.php index d8f4d613ad..e6f2208cf2 100644 --- a/tests/e2e/Swift61Test.php +++ b/tests/e2e/Swift61Test.php @@ -57,6 +57,7 @@ final class Swift61Test extends Base ...Base::EXCEPTION_RESPONSES, ...Base::OAUTH_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/Unity2021Test.php b/tests/e2e/Unity2021Test.php index 6cb8cc39c7..6a8115484f 100644 --- a/tests/e2e/Unity2021Test.php +++ b/tests/e2e/Unity2021Test.php @@ -112,6 +112,7 @@ public function tearDown(): void ...Base::REALTIME_RESPONSES, ...Base::COOKIE_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/WebChromiumTest.php b/tests/e2e/WebChromiumTest.php index a3d04bac8c..c35770789a 100644 --- a/tests/e2e/WebChromiumTest.php +++ b/tests/e2e/WebChromiumTest.php @@ -65,6 +65,7 @@ final class WebChromiumTest extends Base ...Base::EXCEPTION_RESPONSES, ...Base::REALTIME_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/WebNodeTest.php b/tests/e2e/WebNodeTest.php index 9197e0c59e..9ed880152d 100644 --- a/tests/e2e/WebNodeTest.php +++ b/tests/e2e/WebNodeTest.php @@ -56,6 +56,7 @@ final class WebNodeTest extends Base ...Base::NULLABLE_PARAM_RESPONSES, ...Base::EXCEPTION_RESPONSES, ...Base::QUERY_HELPER_RESPONSES, + Base::QUERY_TRANSPORT_RESPONSE, ...Base::PERMISSION_HELPER_RESPONSES, ...Base::ID_HELPER_RESPONSES, ...Base::TOPIC_HELPER_RESPONSES, diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 47d2feac20..6ba77aa903 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -458,6 +458,43 @@ class ServiceTest { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ))) + val queryTransport = general.listRows( + listOf( + Query.count("*", "total"), + Query.join("orders", "\$id", "customerId"), + Query.groupBy(listOf("status")), + Query.distinct(), + Query.covers("location", listOf(1, 2)), + Query.countDistinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddevPop("price", "sdp"), + Query.stddevSamp("price", "sds"), + Query.variance("price", "var"), + Query.varPop("price", "vp"), + Query.varSamp("price", "vs"), + Query.bitAnd("flags", "band"), + Query.bitOr("flags", "bor"), + Query.bitXor("flags", "bxor"), + Query.having(listOf(Query.greaterThan("total", 1))), + Query.leftJoin("orders", "\$id", "customerId", "=", "ord"), + Query.rightJoin("orders", "\$id", "customerId"), + Query.fullOuterJoin("orders", "\$id", "customerId"), + Query.crossJoin("orders", "ord"), + Query.on("\$id", "customerId"), + Query.leftJoin("orders", "ord", listOf( + Query.on("\$id", "customerId"), + Query.equal("ord.status", "paid"), + )), + Query.notCovers("location", listOf(1, 2)), + Query.spatialEquals("location", listOf(1, 2)), + Query.notSpatialEquals("location", listOf(1, 2)), + ) + Query.page(2, 10) + Query.builder().limit(1).build() + ) + writeToFile(queryTransport.result) // Permission & Roles helper tests writeToFile(Permission.read(Role.any())) diff --git a/tests/e2e/languages/apple/Tests.swift b/tests/e2e/languages/apple/Tests.swift index f3c18c6db5..623b3030fd 100644 --- a/tests/e2e/languages/apple/Tests.swift +++ b/tests/e2e/languages/apple/Tests.swift @@ -431,6 +431,44 @@ class Tests: XCTestCase { Query.equal("name", value: "Alice"), Query.greaterThan("age", value: 18) ])) + let transportQueries: [String] = [ + Query.count("*", alias: "total"), + Query.join("orders", left: "$id", right: "customerId"), + Query.groupBy(["status"]), + Query.distinct(), + Query.covers("location", values: [1, 2]), + Query.countDistinct("year", alias: "uniqueYears"), + Query.sum("price", alias: "total"), + Query.avg("price", alias: "avgPrice"), + Query.min("price", alias: "lowest"), + Query.max("price", alias: "highest"), + Query.stddev("price", alias: "sd"), + Query.stddevPop("price", alias: "sdp"), + Query.stddevSamp("price", alias: "sds"), + Query.variance("price", alias: "var"), + Query.varPop("price", alias: "vp"), + Query.varSamp("price", alias: "vs"), + Query.bitAnd("flags", alias: "band"), + Query.bitOr("flags", alias: "bor"), + Query.bitXor("flags", alias: "bxor"), + Query.having([Query.greaterThan("total", value: 1)]), + Query.leftJoin("orders", left: "$id", right: "customerId", operator: "=", alias: "ord"), + Query.rightJoin("orders", left: "$id", right: "customerId"), + Query.fullOuterJoin("orders", left: "$id", right: "customerId"), + Query.crossJoin("orders", alias: "ord"), + Query.on("$id", "customerId"), + Query.leftJoin("orders", alias: "ord", on: [ + Query.on("$id", "customerId"), + Query.equal("ord.status", value: "paid"), + ]), + Query.notCovers("location", values: [1, 2]), + Query.spatialEquals("location", values: [1, 2]), + Query.notSpatialEquals("location", values: [1, 2]), + ] + let queryTransport = try await general.listRows( + queries: transportQueries + Query.page(2, perPage: 10) + Query.builder().limit(1).build() + ) + print(queryTransport.result) // Permission & Role helper tests print(Permission.read(Role.any())) diff --git a/tests/e2e/languages/dart/tests.dart b/tests/e2e/languages/dart/tests.dart index 5d4d2941e0..717e490450 100644 --- a/tests/e2e/languages/dart/tests.dart +++ b/tests/e2e/languages/dart/tests.dart @@ -279,7 +279,44 @@ void main() async { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ])); - + final queryTransport = await general.listRows(queries: [ + Query.count('*', 'total'), + Query.join('orders', '\$id', 'customerId'), + Query.groupBy(['status']), + Query.distinct(), + Query.covers('location', [1, 2]), + Query.countDistinct('year', 'uniqueYears'), + Query.sum('price', 'total'), + Query.avg('price', 'avgPrice'), + Query.min('price', 'lowest'), + Query.max('price', 'highest'), + Query.stddev('price', 'sd'), + Query.stddevPop('price', 'sdp'), + Query.stddevSamp('price', 'sds'), + Query.variance('price', 'var'), + Query.varPop('price', 'vp'), + Query.varSamp('price', 'vs'), + Query.bitAnd('flags', 'band'), + Query.bitOr('flags', 'bor'), + Query.bitXor('flags', 'bxor'), + Query.having([Query.greaterThan('total', 1)]), + Query.leftJoin('orders', '\$id', 'customerId', '=', 'ord'), + Query.rightJoin('orders', '\$id', 'customerId'), + Query.fullOuterJoin('orders', '\$id', 'customerId'), + Query.crossJoin('orders', 'ord'), + Query.on('\$id', 'customerId'), + Query.leftJoin('orders', 'ord', [ + Query.on('\$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ]), + Query.notCovers('location', [1, 2]), + Query.spatialEquals('location', [1, 2]), + Query.notSpatialEquals('location', [1, 2]), + ...Query.page(2, 10), + ...Query.builder().limit(1).build(), + ]); + print(queryTransport.result); + // Permission & Role helper tests print(Permission.read(Role.any())); print(Permission.write(Role.user(ID.custom('userid')))); diff --git a/tests/e2e/languages/deno/tests.ts b/tests/e2e/languages/deno/tests.ts index c6e5ac37de..bbf7492bb8 100644 --- a/tests/e2e/languages/deno/tests.ts +++ b/tests/e2e/languages/deno/tests.ts @@ -305,6 +305,43 @@ async function start() { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ])); + const queryTransport = await general.listRows([ + Query.count("*", "total"), + Query.join("orders", "$id", "customerId"), + Query.groupBy(["status"]), + Query.distinct(), + Query.covers("location", [1, 2]), + Query.countDistinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddevPop("price", "sdp"), + Query.stddevSamp("price", "sds"), + Query.variance("price", "var"), + Query.varPop("price", "vp"), + Query.varSamp("price", "vs"), + Query.bitAnd("flags", "band"), + Query.bitOr("flags", "bor"), + Query.bitXor("flags", "bxor"), + Query.having([Query.greaterThan("total", 1)]), + Query.leftJoin("orders", "$id", "customerId", "=", "ord"), + Query.rightJoin("orders", "$id", "customerId"), + Query.fullOuterJoin("orders", "$id", "customerId"), + Query.crossJoin("orders", "ord"), + Query.on("$id", "customerId"), + Query.leftJoin("orders", "ord", [ + Query.on("$id", "customerId"), + Query.equal("ord.status", "paid"), + ]), + Query.notCovers("location", [1, 2]), + Query.spatialEquals("location", [1, 2]), + Query.notSpatialEquals("location", [1, 2]), + ...Query.page(2, 10), + Query.builder().limit(1), + ]); + console.log(queryTransport.result); // Permission & Role helper tests console.log(Permission.read(Role.any())); diff --git a/tests/e2e/languages/dotnet/Tests.cs b/tests/e2e/languages/dotnet/Tests.cs index eb96598037..33cc1e86fc 100644 --- a/tests/e2e/languages/dotnet/Tests.cs +++ b/tests/e2e/languages/dotnet/Tests.cs @@ -287,6 +287,45 @@ public async Task Test1() Query.Equal("name", "Alice"), Query.GreaterThan("age", 18) })); + var transportQueries = new List + { + Query.Count("*", "total"), + Query.Join("orders", "$id", "customerId"), + Query.GroupBy(new List { "status" }), + Query.Distinct(), + Query.Covers("location", new List { 1, 2 }), + Query.CountDistinct("year", "uniqueYears"), + Query.Sum("price", "total"), + Query.Avg("price", "avgPrice"), + Query.Min("price", "lowest"), + Query.Max("price", "highest"), + Query.Stddev("price", "sd"), + Query.StddevPop("price", "sdp"), + Query.StddevSamp("price", "sds"), + Query.Variance("price", "var"), + Query.VarPop("price", "vp"), + Query.VarSamp("price", "vs"), + Query.BitAnd("flags", "band"), + Query.BitOr("flags", "bor"), + Query.BitXor("flags", "bxor"), + Query.Having(new List { Query.GreaterThan("total", 1) }), + Query.LeftJoin("orders", "$id", "customerId", "=", "ord"), + Query.RightJoin("orders", "$id", "customerId"), + Query.FullOuterJoin("orders", "$id", "customerId"), + Query.CrossJoin("orders", "ord"), + Query.On("$id", "customerId"), + Query.LeftJoin("orders", "ord", new List { + Query.On("$id", "customerId"), + Query.Equal("ord.status", "paid"), + }), + Query.NotCovers("location", new List { 1, 2 }), + Query.SpatialEquals("location", new List { 1, 2 }), + Query.NotSpatialEquals("location", new List { 1, 2 }), + }; + transportQueries.AddRange(Query.Page(2, 10)); + transportQueries.AddRange(new Query.Builder().Limit(1).Build()); + var queryTransport = await general.ListRows(transportQueries); + TestContext.WriteLine(queryTransport.Result); // Permission & Roles helper tests TestContext.WriteLine(Permission.Read(Role.Any())); diff --git a/tests/e2e/languages/flutter/tests.dart b/tests/e2e/languages/flutter/tests.dart index 33f2b47974..5789113685 100644 --- a/tests/e2e/languages/flutter/tests.dart +++ b/tests/e2e/languages/flutter/tests.dart @@ -427,6 +427,43 @@ void main() async { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ])); + final queryTransport = await general.listRows(queries: [ + Query.count('*', 'total'), + Query.join('orders', '\$id', 'customerId'), + Query.groupBy(['status']), + Query.distinct(), + Query.covers('location', [1, 2]), + Query.countDistinct('year', 'uniqueYears'), + Query.sum('price', 'total'), + Query.avg('price', 'avgPrice'), + Query.min('price', 'lowest'), + Query.max('price', 'highest'), + Query.stddev('price', 'sd'), + Query.stddevPop('price', 'sdp'), + Query.stddevSamp('price', 'sds'), + Query.variance('price', 'var'), + Query.varPop('price', 'vp'), + Query.varSamp('price', 'vs'), + Query.bitAnd('flags', 'band'), + Query.bitOr('flags', 'bor'), + Query.bitXor('flags', 'bxor'), + Query.having([Query.greaterThan('total', 1)]), + Query.leftJoin('orders', '\$id', 'customerId', '=', 'ord'), + Query.rightJoin('orders', '\$id', 'customerId'), + Query.fullOuterJoin('orders', '\$id', 'customerId'), + Query.crossJoin('orders', 'ord'), + Query.on('\$id', 'customerId'), + Query.leftJoin('orders', 'ord', [ + Query.on('\$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ]), + Query.notCovers('location', [1, 2]), + Query.spatialEquals('location', [1, 2]), + Query.notSpatialEquals('location', [1, 2]), + ...Query.page(2, 10), + ...Query.builder().limit(1).build(), + ]); + print(queryTransport.result); // Permission & Role helper tests print(Permission.read(Role.any())); diff --git a/tests/e2e/languages/go-v2/tests.go b/tests/e2e/languages/go-v2/tests.go index 953d77916a..1295739f06 100644 --- a/tests/e2e/languages/go-v2/tests.go +++ b/tests/e2e/languages/go-v2/tests.go @@ -248,7 +248,7 @@ func testGeneralService(client client.Client, stringInArray []string) { general.Empty() // Test Queries - testQueries() + testQueries(client) // Test Permission Helpers testPermissionHelpers() @@ -320,7 +320,7 @@ func testGenericUpload(client client.Client) { fmt.Printf("%s\n", profile.Prefs["result"]) } -func testQueries() { +func testQueries(client client.Client) { fmt.Println(query.Equal("released", true)) fmt.Println(query.Equal("title", []interface{}{"Spiderman", "Dr. Strange"})) fmt.Println(query.NotEqual("title", "Spiderman")) @@ -404,6 +404,48 @@ func testQueries() { query.Equal("name", "Alice"), query.GreaterThan("age", 18), })) + general := appwrite.NewGeneral(client) + queries := []string{ + query.Count("*", "total"), + query.Join("orders", "$id", "customerId", "=", ""), + query.GroupBy([]interface{}{"status"}), + query.Distinct(), + query.Covers("location", []interface{}{1, 2}), + query.CountDistinct("year", "uniqueYears"), + query.Sum("price", "total"), + query.Avg("price", "avgPrice"), + query.Min("price", "lowest"), + query.Max("price", "highest"), + query.Stddev("price", "sd"), + query.StddevPop("price", "sdp"), + query.StddevSamp("price", "sds"), + query.Variance("price", "var"), + query.VarPop("price", "vp"), + query.VarSamp("price", "vs"), + query.BitAnd("flags", "band"), + query.BitOr("flags", "bor"), + query.BitXor("flags", "bxor"), + query.Having([]string{query.GreaterThan("total", 1)}), + query.LeftJoin("orders", "$id", "customerId", "=", "ord"), + query.RightJoin("orders", "$id", "customerId", "=", ""), + query.FullOuterJoin("orders", "$id", "customerId", "=", ""), + query.CrossJoin("orders", "ord"), + query.On("$id", "customerId", ""), + query.LeftJoinOn("orders", "ord", []string{ + query.On("$id", "customerId", ""), + query.Equal("ord.status", "paid"), + }), + query.NotCovers("location", []interface{}{1, 2}), + query.SpatialEquals("location", []interface{}{1, 2}), + query.NotSpatialEquals("location", []interface{}{1, 2}), + } + queries = append(queries, query.Page(2, 10)...) + queries = append(queries, query.NewBuilder().Limit(1).Build()...) + queryTransport, err := general.ListRows(general.WithListRowsQueries(queries)) + if err != nil { + fmt.Printf("general.ListRows => error %v", err) + } + fmt.Println(queryTransport.Result) } func testPermissionHelpers() { diff --git a/tests/e2e/languages/go/tests.go b/tests/e2e/languages/go/tests.go index 0e50bf4ef8..2406898e8c 100644 --- a/tests/e2e/languages/go/tests.go +++ b/tests/e2e/languages/go/tests.go @@ -248,7 +248,7 @@ func testGeneralService(client client.Client, stringInArray []string) { general.Empty() // Test Queries - testQueries() + testQueries(client) // Test Permission Helpers testPermissionHelpers() @@ -320,7 +320,7 @@ func testGenericUpload(client client.Client) { fmt.Printf("%s\n", profile.Prefs["result"]) } -func testQueries() { +func testQueries(client client.Client) { fmt.Println(query.Equal("released", true)) fmt.Println(query.Equal("title", []interface{}{"Spiderman", "Dr. Strange"})) fmt.Println(query.NotEqual("title", "Spiderman")) @@ -404,6 +404,48 @@ func testQueries() { query.Equal("name", "Alice"), query.GreaterThan("age", 18), })) + general := appwrite.NewGeneral(client) + queries := []string{ + query.Count("*", "total"), + query.Join("orders", "$id", "customerId", "=", ""), + query.GroupBy([]interface{}{"status"}), + query.Distinct(), + query.Covers("location", []interface{}{1, 2}), + query.CountDistinct("year", "uniqueYears"), + query.Sum("price", "total"), + query.Avg("price", "avgPrice"), + query.Min("price", "lowest"), + query.Max("price", "highest"), + query.Stddev("price", "sd"), + query.StddevPop("price", "sdp"), + query.StddevSamp("price", "sds"), + query.Variance("price", "var"), + query.VarPop("price", "vp"), + query.VarSamp("price", "vs"), + query.BitAnd("flags", "band"), + query.BitOr("flags", "bor"), + query.BitXor("flags", "bxor"), + query.Having([]string{query.GreaterThan("total", 1)}), + query.LeftJoin("orders", "$id", "customerId", "=", "ord"), + query.RightJoin("orders", "$id", "customerId", "=", ""), + query.FullOuterJoin("orders", "$id", "customerId", "=", ""), + query.CrossJoin("orders", "ord"), + query.On("$id", "customerId", ""), + query.LeftJoinOn("orders", "ord", []string{ + query.On("$id", "customerId", ""), + query.Equal("ord.status", "paid"), + }), + query.NotCovers("location", []interface{}{1, 2}), + query.SpatialEquals("location", []interface{}{1, 2}), + query.NotSpatialEquals("location", []interface{}{1, 2}), + } + queries = append(queries, query.Page(2, 10)...) + queries = append(queries, query.NewBuilder().Limit(1).Build()...) + queryTransport, err := general.ListRows(general.WithListRowsQueries(queries)) + if err != nil { + fmt.Printf("general.ListRows => error %v", err) + } + fmt.Println(queryTransport.Result) } func testPermissionHelpers() { diff --git a/tests/e2e/languages/kotlin/Tests.kt b/tests/e2e/languages/kotlin/Tests.kt index 29afe0e462..53d60a872b 100644 --- a/tests/e2e/languages/kotlin/Tests.kt +++ b/tests/e2e/languages/kotlin/Tests.kt @@ -303,6 +303,43 @@ class ServiceTest { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ))) + val queryTransport = general.listRows( + listOf( + Query.count("*", "total"), + Query.join("orders", "\$id", "customerId"), + Query.groupBy(listOf("status")), + Query.distinct(), + Query.covers("location", listOf(1, 2)), + Query.countDistinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddevPop("price", "sdp"), + Query.stddevSamp("price", "sds"), + Query.variance("price", "var"), + Query.varPop("price", "vp"), + Query.varSamp("price", "vs"), + Query.bitAnd("flags", "band"), + Query.bitOr("flags", "bor"), + Query.bitXor("flags", "bxor"), + Query.having(listOf(Query.greaterThan("total", 1))), + Query.leftJoin("orders", "\$id", "customerId", "=", "ord"), + Query.rightJoin("orders", "\$id", "customerId"), + Query.fullOuterJoin("orders", "\$id", "customerId"), + Query.crossJoin("orders", "ord"), + Query.on("\$id", "customerId"), + Query.leftJoin("orders", "ord", listOf( + Query.on("\$id", "customerId"), + Query.equal("ord.status", "paid"), + )), + Query.notCovers("location", listOf(1, 2)), + Query.spatialEquals("location", listOf(1, 2)), + Query.notSpatialEquals("location", listOf(1, 2)), + ) + Query.page(2, 10) + Query.builder().limit(1).build() + ) + writeToFile(queryTransport.result) // Permission & Roles helper tests writeToFile(Permission.read(Role.any())) diff --git a/tests/e2e/languages/node/test.js b/tests/e2e/languages/node/test.js index 568b9ddf6d..07a7947d46 100644 --- a/tests/e2e/languages/node/test.js +++ b/tests/e2e/languages/node/test.js @@ -413,6 +413,43 @@ async function start() { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ])); + const queryTransport = await general.listRows([ + Query.count("*", "total"), + Query.join("orders", "$id", "customerId"), + Query.groupBy(["status"]), + Query.distinct(), + Query.covers("location", [1, 2]), + Query.countDistinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddevPop("price", "sdp"), + Query.stddevSamp("price", "sds"), + Query.variance("price", "var"), + Query.varPop("price", "vp"), + Query.varSamp("price", "vs"), + Query.bitAnd("flags", "band"), + Query.bitOr("flags", "bor"), + Query.bitXor("flags", "bxor"), + Query.having([Query.greaterThan("total", 1)]), + Query.leftJoin("orders", "$id", "customerId", "=", "ord"), + Query.rightJoin("orders", "$id", "customerId"), + Query.fullOuterJoin("orders", "$id", "customerId"), + Query.crossJoin("orders", "ord"), + Query.on("$id", "customerId"), + Query.leftJoin("orders", "ord", [ + Query.on("$id", "customerId"), + Query.equal("ord.status", "paid"), + ]), + Query.notCovers("location", [1, 2]), + Query.spatialEquals("location", [1, 2]), + Query.notSpatialEquals("location", [1, 2]), + ...Query.page(2, 10), + Query.builder().limit(1), + ]); + console.log(queryTransport.result); // Permission & Role helper tests console.log(Permission.read(Role.any())); diff --git a/tests/e2e/languages/php/test.php b/tests/e2e/languages/php/test.php index 986054082d..1ee2bb0fc8 100644 --- a/tests/e2e/languages/php/test.php +++ b/tests/e2e/languages/php/test.php @@ -393,6 +393,43 @@ public function toArray(): array Query::equal('name', ['Alice']), Query::greaterThan('age', 18) ]) . "\n"; +$queryTransport = $general->listRows([ + Query::count('*', 'total'), + Query::join('orders', '$id', 'customerId'), + Query::groupBy(['status']), + Query::distinct(), + Query::covers('location', [1, 2]), + Query::countDistinct('year', 'uniqueYears'), + Query::sum('price', 'total'), + Query::avg('price', 'avgPrice'), + Query::min('price', 'lowest'), + Query::max('price', 'highest'), + Query::stddev('price', 'sd'), + Query::stddevPop('price', 'sdp'), + Query::stddevSamp('price', 'sds'), + Query::variance('price', 'var'), + Query::varPop('price', 'vp'), + Query::varSamp('price', 'vs'), + Query::bitAnd('flags', 'band'), + Query::bitOr('flags', 'bor'), + Query::bitXor('flags', 'bxor'), + Query::having([Query::greaterThan('total', 1)]), + Query::leftJoin('orders', '$id', 'customerId', '=', 'ord'), + Query::rightJoin('orders', '$id', 'customerId'), + Query::fullOuterJoin('orders', '$id', 'customerId'), + Query::crossJoin('orders', 'ord'), + Query::on('$id', 'customerId'), + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + Query::equal('ord.status', 'paid'), + ]), + Query::notCovers('location', [1, 2]), + Query::spatialEquals('location', [1, 2]), + Query::notSpatialEquals('location', [1, 2]), + ...Query::page(2, 10), + Query::builder()->limit(1), +]); +echo $queryTransport->result . "\n"; // Permission & Role helper tests echo Permission::read(Role::any()) . "\n"; diff --git a/tests/e2e/languages/python/tests.py b/tests/e2e/languages/python/tests.py index 604e7bdedd..dafa69e5cb 100644 --- a/tests/e2e/languages/python/tests.py +++ b/tests/e2e/languages/python/tests.py @@ -251,6 +251,43 @@ Query.equal("name", "Alice"), Query.greater_than("age", 18) ])) +query_transport = general.list_rows([ + Query.count("*", "total"), + Query.join("orders", "$id", "customerId"), + Query.group_by(["status"]), + Query.distinct(), + Query.covers("location", [1, 2]), + Query.count_distinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddev_pop("price", "sdp"), + Query.stddev_samp("price", "sds"), + Query.variance("price", "var"), + Query.var_pop("price", "vp"), + Query.var_samp("price", "vs"), + Query.bit_and("flags", "band"), + Query.bit_or("flags", "bor"), + Query.bit_xor("flags", "bxor"), + Query.having([Query.greater_than("total", 1)]), + Query.left_join("orders", "$id", "customerId", "=", "ord"), + Query.right_join("orders", "$id", "customerId"), + Query.full_outer_join("orders", "$id", "customerId"), + Query.cross_join("orders", "ord"), + Query.on("$id", "customerId"), + Query.left_join("orders", "ord", [ + Query.on("$id", "customerId"), + Query.equal("ord.status", "paid"), + ]), + Query.not_covers("location", [1, 2]), + Query.spatial_equals("location", [1, 2]), + Query.not_spatial_equals("location", [1, 2]), + *Query.page(2, 10), + Query.builder().limit(1), +]) +print(query_transport.result) # Permission & Role helper tests print(Permission.read(Role.any())) diff --git a/tests/e2e/languages/react-native/browser.js b/tests/e2e/languages/react-native/browser.js index b1d96c2a96..362941caad 100644 --- a/tests/e2e/languages/react-native/browser.js +++ b/tests/e2e/languages/react-native/browser.js @@ -391,6 +391,43 @@ import { Query.equal('name', 'Alice'), Query.greaterThan('age', 18), ])); + const queryTransport = await general.listRows([ + Query.count('*', 'total'), + Query.join('orders', '$id', 'customerId'), + Query.groupBy(['status']), + Query.distinct(), + Query.covers('location', [1, 2]), + Query.countDistinct('year', 'uniqueYears'), + Query.sum('price', 'total'), + Query.avg('price', 'avgPrice'), + Query.min('price', 'lowest'), + Query.max('price', 'highest'), + Query.stddev('price', 'sd'), + Query.stddevPop('price', 'sdp'), + Query.stddevSamp('price', 'sds'), + Query.variance('price', 'var'), + Query.varPop('price', 'vp'), + Query.varSamp('price', 'vs'), + Query.bitAnd('flags', 'band'), + Query.bitOr('flags', 'bor'), + Query.bitXor('flags', 'bxor'), + Query.having([Query.greaterThan('total', 1)]), + Query.leftJoin('orders', '$id', 'customerId', '=', 'ord'), + Query.rightJoin('orders', '$id', 'customerId'), + Query.fullOuterJoin('orders', '$id', 'customerId'), + Query.crossJoin('orders', 'ord'), + Query.on('$id', 'customerId'), + Query.leftJoin('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ]), + Query.notCovers('location', [1, 2]), + Query.spatialEquals('location', [1, 2]), + Query.notSpatialEquals('location', [1, 2]), + ...Query.page(2, 10), + Query.builder().limit(1), + ]); + console.log(queryTransport.result); // Permission & Role helper tests console.log(Permission.read(Role.any())); diff --git a/tests/e2e/languages/ruby/tests.rb b/tests/e2e/languages/ruby/tests.rb index 94e3e7605f..cf0fd9cddb 100644 --- a/tests/e2e/languages/ruby/tests.rb +++ b/tests/e2e/languages/ruby/tests.rb @@ -264,6 +264,43 @@ Query.equal("name", "Alice"), Query.greater_than("age", 18) ]) +query_transport = general.list_rows(queries: [ + Query.count('*', 'total'), + Query.join('orders', '$id', 'customerId'), + Query.group_by(['status']), + Query.distinct(), + Query.covers('location', [1, 2]), + Query.count_distinct('year', 'uniqueYears'), + Query.sum('price', 'total'), + Query.avg('price', 'avgPrice'), + Query.min('price', 'lowest'), + Query.max('price', 'highest'), + Query.stddev('price', 'sd'), + Query.stddev_pop('price', 'sdp'), + Query.stddev_samp('price', 'sds'), + Query.variance('price', 'var'), + Query.var_pop('price', 'vp'), + Query.var_samp('price', 'vs'), + Query.bit_and('flags', 'band'), + Query.bit_or('flags', 'bor'), + Query.bit_xor('flags', 'bxor'), + Query.having([Query.greater_than('total', 1)]), + Query.left_join('orders', '$id', 'customerId', '=', 'ord'), + Query.right_join('orders', '$id', 'customerId'), + Query.full_outer_join('orders', '$id', 'customerId'), + Query.cross_join('orders', 'ord'), + Query.on('$id', 'customerId'), + Query.left_join('orders', 'ord', [ + Query.on('$id', 'customerId'), + Query.equal('ord.status', 'paid'), + ]), + Query.not_covers('location', [1, 2]), + Query.spatial_equals('location', [1, 2]), + Query.not_spatial_equals('location', [1, 2]), + *Query.page(2, 10), + Query.builder.limit(1), +]) +puts query_transport.result # Permission & Role helper tests puts Permission.read(Role.any()) diff --git a/tests/e2e/languages/swift/Tests.swift b/tests/e2e/languages/swift/Tests.swift index ab370a4031..4fef1d8d09 100644 --- a/tests/e2e/languages/swift/Tests.swift +++ b/tests/e2e/languages/swift/Tests.swift @@ -310,6 +310,44 @@ class Tests: XCTestCase { Query.equal("name", value: "Alice"), Query.greaterThan("age", value: 18) ])) + let transportQueries: [String] = [ + Query.count("*", alias: "total"), + Query.join("orders", left: "$id", right: "customerId"), + Query.groupBy(["status"]), + Query.distinct(), + Query.covers("location", values: [1, 2]), + Query.countDistinct("year", alias: "uniqueYears"), + Query.sum("price", alias: "total"), + Query.avg("price", alias: "avgPrice"), + Query.min("price", alias: "lowest"), + Query.max("price", alias: "highest"), + Query.stddev("price", alias: "sd"), + Query.stddevPop("price", alias: "sdp"), + Query.stddevSamp("price", alias: "sds"), + Query.variance("price", alias: "var"), + Query.varPop("price", alias: "vp"), + Query.varSamp("price", alias: "vs"), + Query.bitAnd("flags", alias: "band"), + Query.bitOr("flags", alias: "bor"), + Query.bitXor("flags", alias: "bxor"), + Query.having([Query.greaterThan("total", value: 1)]), + Query.leftJoin("orders", left: "$id", right: "customerId", operator: "=", alias: "ord"), + Query.rightJoin("orders", left: "$id", right: "customerId"), + Query.fullOuterJoin("orders", left: "$id", right: "customerId"), + Query.crossJoin("orders", alias: "ord"), + Query.on("$id", "customerId"), + Query.leftJoin("orders", alias: "ord", on: [ + Query.on("$id", "customerId"), + Query.equal("ord.status", value: "paid"), + ]), + Query.notCovers("location", values: [1, 2]), + Query.spatialEquals("location", values: [1, 2]), + Query.notSpatialEquals("location", values: [1, 2]), + ] + let queryTransport = try await general.listRows( + queries: transportQueries + Query.page(2, perPage: 10) + Query.builder().limit(1).build() + ) + print(queryTransport.result) // Permission & Role helper tests print(Permission.read(Role.any())) diff --git a/tests/e2e/languages/unity/Tests.cs b/tests/e2e/languages/unity/Tests.cs index c99dcaaae5..976811a852 100644 --- a/tests/e2e/languages/unity/Tests.cs +++ b/tests/e2e/languages/unity/Tests.cs @@ -421,6 +421,45 @@ private async Task RunAsyncTest() Query.Equal("name", "Alice"), Query.GreaterThan("age", 18) })); + var transportQueries = new List + { + Query.Count("*", "total"), + Query.Join("orders", "$id", "customerId"), + Query.GroupBy(new List { "status" }), + Query.Distinct(), + Query.Covers("location", new List { 1, 2 }), + Query.CountDistinct("year", "uniqueYears"), + Query.Sum("price", "total"), + Query.Avg("price", "avgPrice"), + Query.Min("price", "lowest"), + Query.Max("price", "highest"), + Query.Stddev("price", "sd"), + Query.StddevPop("price", "sdp"), + Query.StddevSamp("price", "sds"), + Query.Variance("price", "var"), + Query.VarPop("price", "vp"), + Query.VarSamp("price", "vs"), + Query.BitAnd("flags", "band"), + Query.BitOr("flags", "bor"), + Query.BitXor("flags", "bxor"), + Query.Having(new List { Query.GreaterThan("total", 1) }), + Query.LeftJoin("orders", "$id", "customerId", "=", "ord"), + Query.RightJoin("orders", "$id", "customerId"), + Query.FullOuterJoin("orders", "$id", "customerId"), + Query.CrossJoin("orders", "ord"), + Query.On("$id", "customerId"), + Query.LeftJoin("orders", "ord", new List { + Query.On("$id", "customerId"), + Query.Equal("ord.status", "paid"), + }), + Query.NotCovers("location", new List { 1, 2 }), + Query.SpatialEquals("location", new List { 1, 2 }), + Query.NotSpatialEquals("location", new List { 1, 2 }), + }; + transportQueries.AddRange(Query.Page(2, 10)); + transportQueries.AddRange(new Query.Builder().Limit(1).Build()); + var queryTransport = await general.ListRows(transportQueries); + LogResult(queryTransport.Result); // Permission & Roles helper tests LogResult(Permission.Read(Role.Any())); LogResult(Permission.Write(Role.User(ID.Custom("userid")))); diff --git a/tests/e2e/languages/web/index.html b/tests/e2e/languages/web/index.html index 86cec02417..b96258e899 100644 --- a/tests/e2e/languages/web/index.html +++ b/tests/e2e/languages/web/index.html @@ -509,6 +509,43 @@ Query.equal("name", "Alice"), Query.greaterThan("age", 18) ])); + const queryTransport = await general.listRows([ + Query.count("*", "total"), + Query.join("orders", "$id", "customerId"), + Query.groupBy(["status"]), + Query.distinct(), + Query.covers("location", [1, 2]), + Query.countDistinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddevPop("price", "sdp"), + Query.stddevSamp("price", "sds"), + Query.variance("price", "var"), + Query.varPop("price", "vp"), + Query.varSamp("price", "vs"), + Query.bitAnd("flags", "band"), + Query.bitOr("flags", "bor"), + Query.bitXor("flags", "bxor"), + Query.having([Query.greaterThan("total", 1)]), + Query.leftJoin("orders", "$id", "customerId", "=", "ord"), + Query.rightJoin("orders", "$id", "customerId"), + Query.fullOuterJoin("orders", "$id", "customerId"), + Query.crossJoin("orders", "ord"), + Query.on("$id", "customerId"), + Query.leftJoin("orders", "ord", [ + Query.on("$id", "customerId"), + Query.equal("ord.status", "paid"), + ]), + Query.notCovers("location", [1, 2]), + Query.spatialEquals("location", [1, 2]), + Query.notSpatialEquals("location", [1, 2]), + ...Query.page(2, 10), + Query.builder().limit(1), + ]); + console.log(queryTransport.result); // Permission & Role helper tests console.log(Permission.read(Role.any())); diff --git a/tests/e2e/languages/web/node.js b/tests/e2e/languages/web/node.js index 4518b2ab05..31d7cdf787 100644 --- a/tests/e2e/languages/web/node.js +++ b/tests/e2e/languages/web/node.js @@ -316,6 +316,43 @@ async function start() { Query.equal("name", "Alice"), Query.greaterThan("age", 18) ])); + const queryTransport = await general.listRows([ + Query.count("*", "total"), + Query.join("orders", "$id", "customerId"), + Query.groupBy(["status"]), + Query.distinct(), + Query.covers("location", [1, 2]), + Query.countDistinct("year", "uniqueYears"), + Query.sum("price", "total"), + Query.avg("price", "avgPrice"), + Query.min("price", "lowest"), + Query.max("price", "highest"), + Query.stddev("price", "sd"), + Query.stddevPop("price", "sdp"), + Query.stddevSamp("price", "sds"), + Query.variance("price", "var"), + Query.varPop("price", "vp"), + Query.varSamp("price", "vs"), + Query.bitAnd("flags", "band"), + Query.bitOr("flags", "bor"), + Query.bitXor("flags", "bxor"), + Query.having([Query.greaterThan("total", 1)]), + Query.leftJoin("orders", "$id", "customerId", "=", "ord"), + Query.rightJoin("orders", "$id", "customerId"), + Query.fullOuterJoin("orders", "$id", "customerId"), + Query.crossJoin("orders", "ord"), + Query.on("$id", "customerId"), + Query.leftJoin("orders", "ord", [ + Query.on("$id", "customerId"), + Query.equal("ord.status", "paid"), + ]), + Query.notCovers("location", [1, 2]), + Query.spatialEquals("location", [1, 2]), + Query.notSpatialEquals("location", [1, 2]), + ...Query.page(2, 10), + Query.builder().limit(1), + ]); + console.log(queryTransport.result); // Permission & Role helper tests console.log(Permission.read(Role.any())); diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index 6c1298a661..15711e8876 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -540,6 +540,34 @@ public function testRequestModelsComeFromBodyOnlySchemas(): void $this->assertStringNotContainsString('request model', $go['models/mock.go']); } + /** + * A released CLI has to build from its own module, so only a CLI pinned + * to a local Go SDK may replace the published one. + */ + public function testCliReplacesGoSdkOnlyWhenPinned(): void + { + $this->assertStringNotContainsString('replace ', $this->generateCliModule(new CLI(), 'release')); + $this->assertStringContainsString( + 'replace ' . CLI::SDK_MODULE . ' => ../go', + $this->generateCliModule(new CLI()->setSdkPath('../go'), 'pinned'), + ); + } + + private function generateCliModule(CLI $language, string $key): string + { + $dir = self::OUTPUT . '/cli-module/' . $key; + $this->removeDirectory($dir); + + new SDK($language, Parser::parse((string) \file_get_contents(self::FIXTURE))) + ->setName('test') + ->setVersion('0.0.1') + ->setPlatform('server') + ->setNamespace('appwrite') + ->generate($dir); + + return (string) \file_get_contents($dir . '/go.mod'); + } + /** * The generated tree for an inline document, for a shape the shared * fixture does not carry. diff --git a/tests/resources/spec-openapi3.json b/tests/resources/spec-openapi3.json index 7d37988e0b..a4b599b35b 100644 --- a/tests/resources/spec-openapi3.json +++ b/tests/resources/spec-openapi3.json @@ -1649,6 +1649,28 @@ "type": "string" } ] + }, + "kind": { + "description": "Mock kind encoded as a oneOf of string enums.", + "example": "alpha", + "title": "MockKind", + "type": "string", + "oneOf": [ + { + "type": "string", + "enum": [ + "alpha" + ], + "title": "alpha" + }, + { + "type": "string", + "enum": [ + "beta" + ], + "title": "beta" + } + ] } }, "required": [ @@ -3525,6 +3547,16 @@ {"type": "string", "enum": ["cancelled"], "title": "cancelled"} ] }, + "kind": { + "description": "Mock kind encoded as a oneOf of string enums.", + "example": "alpha", + "title": "MockKind", + "type": "string", + "oneOf": [ + {"type": "string", "enum": ["alpha"], "title": "alpha"}, + {"type": "string", "enum": ["beta"], "title": "beta"} + ] + }, "localizedStatus": { "title": "LocalizedStatus", "type": "string",