From ea7e34c3359749df8987e0de0efb71be9bb63c08 Mon Sep 17 00:00:00 2001 From: Aleksey Myasnikov Date: Wed, 23 Sep 2026 12:57:27 +0300 Subject: [PATCH 1/4] Support shared YQL expressions and implicit result names --- .agents/architecture.md | 2 +- .agents/context.md | 1 + .agents/decisions.md | 2 +- .agents/development.md | 4 +- .agents/sdk-evidence.md | 2 + .agents/yql-evidence.md | 14 + .github/workflows/ci.yml | 9 +- CHANGELOG.md | 6 + docs/compatibility.md | 24 +- docs/provenance.md | 2 + docs/yql-builtins.md | 272 ++++++++++++++++++ examples/authors/README.md | 4 + examples/authors/cpp/native/models.hpp | 26 ++ examples/authors/cpp/native/queries.cpp | 159 ++++++++++ examples/authors/cpp/native/queries.hpp | 3 + examples/authors/cpp/userver/models.hpp | 28 ++ examples/authors/cpp/userver/queries.cpp | 97 +++++++ examples/authors/cpp/userver/queries.hpp | 4 + examples/authors/csharp/adonet/Models.cs | 25 ++ examples/authors/csharp/adonet/Queries.cs | 87 ++++++ examples/authors/csharp/dapper/Models.cs | 25 ++ examples/authors/csharp/dapper/Queries.cs | 113 ++++++++ examples/authors/go/database/sql/models.go | 27 ++ .../authors/go/database/sql/queries.sql.go | 87 ++++++ examples/authors/go/native/models.go | 29 ++ examples/authors/go/native/queries.sql.go | 128 +++++++++ .../jdbc/FindAuthorsByNamePrefixRow.java | 4 + .../jdbc/GetAuthorExportMetadataRow.java | 4 + .../authors/jdbc/GetAuthorStatisticsRow.java | 4 + .../src/main/java/authors/jdbc/Queries.java | 78 +++++ .../java/jooq/FindAuthorsByNamePrefixRow.java | 6 + .../java/jooq/GetAuthorExportMetadataRow.java | 9 + .../java/jooq/GetAuthorStatisticsRow.java | 6 + examples/authors/java/jooq/Queries.java | 67 +++++ .../nativeapi/FindAuthorsByNamePrefixRow.java | 4 + .../nativeapi/GetAuthorExportMetadataRow.java | 4 + .../nativeapi/GetAuthorStatisticsRow.java | 4 + .../main/java/authors/nativeapi/Queries.java | 80 ++++++ .../exposed/FindAuthorsByNamePrefixRow.kt | 9 + .../exposed/GetAuthorExportMetadataRow.kt | 13 + .../kotlin/exposed/GetAuthorStatisticsRow.kt | 9 + examples/authors/kotlin/exposed/Queries.kt | 77 +++++ .../kotlin/jdbc/FindAuthorsByNamePrefixRow.kt | 9 + .../kotlin/jdbc/GetAuthorExportMetadataRow.kt | 13 + .../kotlin/jdbc/GetAuthorStatisticsRow.kt | 9 + examples/authors/kotlin/jdbc/Queries.kt | 74 +++++ .../nativeapi/FindAuthorsByNamePrefixRow.kt | 9 + .../nativeapi/GetAuthorExportMetadataRow.kt | 13 + .../nativeapi/GetAuthorStatisticsRow.kt | 9 + examples/authors/kotlin/nativeapi/Queries.kt | 115 ++++++++ .../php/native/FindAuthorsByNamePrefixRow.php | 15 + .../php/native/GetAuthorExportMetadataRow.php | 19 ++ .../php/native/GetAuthorStatisticsRow.php | 15 + examples/authors/php/native/Queries.php | 175 +++++++++++ examples/authors/php/native/YdbRuntime.php | 51 ++++ examples/authors/python/dbapi/models.py | 25 ++ examples/authors/python/dbapi/queries.py | 90 ++++++ examples/authors/python/native/models.py | 29 ++ examples/authors/python/native/queries.py | 86 ++++++ examples/authors/python/sqlalchemy/models.py | 25 ++ examples/authors/python/sqlalchemy/queries.py | 93 ++++++ examples/authors/queries.sql | 29 ++ examples/authors/rust/native/models.rs | 28 ++ examples/authors/rust/native/queries.rs | 86 ++++++ examples/authors/typescript/native/queries.ts | 87 ++++++ examples/batch/java/jooq/Queries.java | 3 + examples/booktest/java/jooq/Queries.java | 4 + examples/jets/java/jooq/Queries.java | 2 + examples/ondeck/java/jooq/Queries.java | 5 + internal/analyzer/analyzer_test.go | 27 +- internal/analyzer/boolean.go | 67 +++++ internal/analyzer/dml_expression.go | 1 - internal/analyzer/dml_test.go | 1 - internal/analyzer/expression.go | 65 +++-- internal/analyzer/insert_select.go | 8 +- internal/analyzer/literal.go | 25 +- internal/analyzer/named_insert_select_test.go | 2 +- internal/analyzer/order_by.go | 84 ++++++ internal/analyzer/order_by_test.go | 58 ++++ internal/analyzer/predicate.go | 30 +- internal/analyzer/projection_names.go | 51 ++++ internal/analyzer/projection_names_test.go | 80 ++++++ internal/analyzer/scalar_select_test.go | 12 +- internal/analyzer/select_semantic.go | 4 +- internal/analyzer/semantic.go | 99 ++++--- internal/analyzer/shared_expression_test.go | 259 +++++++++++++++++ internal/analyzer/wildcard.go | 2 +- internal/codegen/java/generator.go | 8 +- internal/codegen/java/jooq.go | 27 +- .../codegen/java/jooq_expressions_test.go | 63 ++++ .../codegen/java/jooq_result_order_test.go | 95 ++++++ internal/codegen/java/jooq_test.go | 6 + .../endtoend/shared_expressions_live_test.go | 229 +++++++++++++++ internal/yql/builtins/README.md | 15 +- internal/yql/builtins/basic_extended_test.go | 72 +++++ internal/yql/builtins/builtins_test.go | 15 +- internal/yql/builtins/cast.go | 15 + internal/yql/builtins/cast_extended_test.go | 46 +++ internal/yql/builtins/coalesce.go | 63 ++++ internal/yql/builtins/coalesce_test.go | 68 +++++ internal/yql/builtins/resolve.go | 100 +++++-- internal/yql/builtins/signatures.go | 9 +- tests/examples/go/authors/expressions_test.go | 91 ++++++ .../src/test/java/GeneratedQueriesTest.java | 4 +- .../java/jooq/src/test/java/LiveTest.java | 35 +++ 105 files changed, 4329 insertions(+), 183 deletions(-) create mode 100644 docs/yql-builtins.md create mode 100644 examples/authors/java/jdbc/src/main/java/authors/jdbc/FindAuthorsByNamePrefixRow.java create mode 100644 examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorExportMetadataRow.java create mode 100644 examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorStatisticsRow.java create mode 100644 examples/authors/java/jooq/FindAuthorsByNamePrefixRow.java create mode 100644 examples/authors/java/jooq/GetAuthorExportMetadataRow.java create mode 100644 examples/authors/java/jooq/GetAuthorStatisticsRow.java create mode 100644 examples/authors/java/native/src/main/java/authors/nativeapi/FindAuthorsByNamePrefixRow.java create mode 100644 examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorExportMetadataRow.java create mode 100644 examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorStatisticsRow.java create mode 100644 examples/authors/kotlin/exposed/FindAuthorsByNamePrefixRow.kt create mode 100644 examples/authors/kotlin/exposed/GetAuthorExportMetadataRow.kt create mode 100644 examples/authors/kotlin/exposed/GetAuthorStatisticsRow.kt create mode 100644 examples/authors/kotlin/jdbc/FindAuthorsByNamePrefixRow.kt create mode 100644 examples/authors/kotlin/jdbc/GetAuthorExportMetadataRow.kt create mode 100644 examples/authors/kotlin/jdbc/GetAuthorStatisticsRow.kt create mode 100644 examples/authors/kotlin/nativeapi/FindAuthorsByNamePrefixRow.kt create mode 100644 examples/authors/kotlin/nativeapi/GetAuthorExportMetadataRow.kt create mode 100644 examples/authors/kotlin/nativeapi/GetAuthorStatisticsRow.kt create mode 100644 examples/authors/php/native/FindAuthorsByNamePrefixRow.php create mode 100644 examples/authors/php/native/GetAuthorExportMetadataRow.php create mode 100644 examples/authors/php/native/GetAuthorStatisticsRow.php create mode 100644 internal/analyzer/boolean.go create mode 100644 internal/analyzer/order_by.go create mode 100644 internal/analyzer/order_by_test.go create mode 100644 internal/analyzer/projection_names.go create mode 100644 internal/analyzer/projection_names_test.go create mode 100644 internal/analyzer/shared_expression_test.go create mode 100644 internal/codegen/java/jooq_expressions_test.go create mode 100644 internal/codegen/java/jooq_result_order_test.go create mode 100644 internal/endtoend/shared_expressions_live_test.go create mode 100644 internal/yql/builtins/basic_extended_test.go create mode 100644 internal/yql/builtins/cast_extended_test.go create mode 100644 internal/yql/builtins/coalesce.go create mode 100644 internal/yql/builtins/coalesce_test.go create mode 100644 tests/examples/go/authors/expressions_test.go diff --git a/.agents/architecture.md b/.agents/architecture.md index 3b3fb1f0..1a2eb278 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -20,7 +20,7 @@ flowchart LR VALUES and SET use the same scalar expression resolver as SELECT. UPDATE supplies the target relation; VALUES supplies only parameter/local bindings. Arithmetic lookup follows only grammar wrappers spanning the complete expression, rather than scanning operand subtrees. ANTLR operator contexts preserve precedence and scalar parentheses are unwrapped only after excluding tuples, lambdas and subqueries. DML values resolve supported comparisons with their nullability. Assignment validation is shared with SELECT-backed DML and permits exact types, optional destinations, contextual NULL and lossless integer widening; it does not insert casts or evaluate SQL. Direct parameter assignment inference remains separate from resolving operands in compound expressions. -`internal/model` is the boundary between analysis and generation: YQL type identity, parameters, result sets and source locations. Nullability is an `Optional` type, compound type metadata is retained, and Struct fields have name-based identity independent of declaration order. `Bytes` normalizes to binary `String`; `Text` normalizes to Unicode `Utf8`. A table catalog and a query projection are distinct: `SELECT name` does not generate the whole table. Projection columns retain their API name and, where different, the exact YDB result name in `WireName`. For example, an unaliased `b.title` in a join is returned as `b.title`. Name-based decoders use `Column.ResultName()`, which selects `WireName` when present and otherwise `Name`; positional decoders retain the analyzed projection order. Generated API fields continue to use `Name`. `AnalyzedQuery.SQL` contains executable YQL with supported wildcard projections expanded into explicit quoted columns; declarations and text outside wildcard spans are retained. The analyzer records explicitly declared parameter names in `DeclaredParameters`. Generators retain user-written declarations in executable SQL. SDK adapters use this metadata to avoid duplicate declarations while binding inferred parameters with their resolved types. Parameter names omit the leading `$`; their types and result column types must be resolved. TypeScript result properties use `Column.ResultName()` verbatim, including qualified names as quoted properties. No target-specific SQL alias rewriting or result-key conversion is needed for this target. `analyzer.Analyze` returns an error whenever its result contains diagnostics. +`internal/model` is the boundary between analysis and generation: YQL type identity, parameters, result sets and source locations. Nullability is an `Optional` type, compound type metadata is retained, and Struct fields have name-based identity independent of declaration order. `Bytes` normalizes to binary `String`; `Text` normalizes to Unicode `Utf8`. A table catalog and a query projection are distinct: `SELECT name` does not generate the whole table. Projection columns retain their API name and, where different, the exact YDB result name in `WireName`. For example, an unaliased `b.title` in a join is returned as `b.title`. Name-based decoders use `Column.ResultName()`, which selects `WireName` when present and otherwise `Name`; positional decoders retain the analyzed projection order. Generated API fields continue to use `Name`. `AnalyzedQuery.SQL` contains executable YQL with supported wildcard projections expanded into explicit quoted columns; unnamed computed expressions combined with a wildcard receive explicit aliases preserving their original YDB names, so expansion cannot change references such as ORDER BY column1. Declarations and text outside those wildcard replacements and alias insertions are retained. The analyzer records explicitly declared parameter names in `DeclaredParameters`. Generators retain user-written declarations in executable SQL. SDK adapters use this metadata to avoid duplicate declarations while binding inferred parameters with their resolved types. Parameter names omit the leading `$`; their types and result column types must be resolved. TypeScript result properties use `Column.ResultName()` verbatim, including qualified names as quoted properties. No target-specific SQL alias rewriting or result-key conversion is needed for this target. `analyzer.Analyze` returns an error whenever its result contains diagnostics. The language packages in `internal/codegen` produce files from that resolved model. They handle naming, runtime-specific parameter binding, result decoding and resource lifetimes. They do not analyze SQL or load external code. jOOQ uses typed JDBC execution for structured batches and explicitly declared queries, with resolved table references rendered through jOOQ to preserve table mappings. For the jOOQ DSL target, `AnalyzedQuery.Syntax` retains ANTLR contexts and analyzer-resolved column/table bindings for the executable SQL, including the positions after wildcard expansion. The renderer walks these contexts directly; it does not reparse text or construct a second AST. Unsupported DSL constructs fail in the target without restricting other generators. Lexical adaptation of parameter placeholders for a driver is separate from semantic query analysis and must preserve strings, comments and identifiers. `internal/codegen/jdbc` implements shared SQL rendering for Java and Kotlin; their SDK binding and result decoding remain in each generator. diff --git a/.agents/context.md b/.agents/context.md index 18b89c16..73c1f440 100644 --- a/.agents/context.md +++ b/.agents/context.md @@ -26,6 +26,7 @@ Read [decisions](decisions.md) before changing the analysis/generation boundary - [README](../README.md): build and first generation. - [Compatibility](../docs/compatibility.md): supported config, queries, schema migrations, intentional exclusions and output ownership. +- [YQL built-ins](../docs/yql-builtins.md): complete upstream reference-section inventory and remaining semantic/type prerequisites, distinct from the implemented function subset. - [Database-assisted analysis](../docs/database-analysis.md): live schema discovery, drift checks and connection settings. - [Targets](../docs/targets.md), [C++](../docs/cpp.md), [C#](../docs/csharp.md), [Java](../docs/java.md), [Kotlin](../docs/kotlin.md), [TypeScript](../docs/typescript.md), [Rust](../docs/rust.md), [PHP](../docs/php.md): generated API and runtime contracts. - [Installation](../docs/installation.md): release artifacts, checksum and version checks. diff --git a/.agents/decisions.md b/.agents/decisions.md index 66a687e8..b0984298 100644 --- a/.agents/decisions.md +++ b/.agents/decisions.md @@ -12,7 +12,7 @@ These choices constrain maintenance; implementation details remain in the linked | SQL-first framework adapters | Named SQL determines generated methods. The jOOQ prototype translates those statements to typed DSL using the analyzed ANTLR contexts. It does not invent entity CRUD operations. ORM contracts for Spring/Hibernate remain deferred in [issue #12](https://github.com/ydb-platform/sqlc-ydb/issues/12). See [Java](../docs/java.md). | | Separate API field names from result-set keys | JOIN result keys can include table qualifiers. Name-based decoders use `Column.ResultName()`; positional decoders retain projection order. See [architecture](architecture.md). | | TypeScript DTOs preserve result column names | Result properties use exact SDK keys, with quoted properties for qualified names. This avoids SQL alias changes and runtime name mapping. Method and parameter names retain their TypeScript naming conventions. | -| Preserve declarations and formatting outside wildcard expansions | Expand supported SELECT/RETURNING wildcards before generators; retain source `DECLARE` statements and all SQL text outside replaced wildcard spans. The analyzer records declared parameter names once; adapters avoid duplicate SDK declarations without deleting source text. See [architecture](architecture.md). | +| Preserve declarations and formatting during projection normalization | Expand supported SELECT/RETURNING wildcards before generators. If a SELECT combines a wildcard and unaliased computed expressions, pin their original YDB result names with explicit AS aliases before expansion; otherwise extra columns would change those names and could break ORDER BY references. Retain source `DECLARE` statements and all text outside the wildcard replacements and these alias insertions. The analyzer records declared parameter names once; adapters avoid duplicate SDK declarations without deleting source text. See [architecture](architecture.md). | | Follow each target's documented SDK value contract | Typed bindings preserve YQL parameter types. The maintainer-approved TypeScript API uses SDK-native Date and parsed JSON results; other targets retain their documented precision guarantees. See [targets](../docs/targets.md) and its language references. | | SQL at its execution site | Keep queries readable where they execute. The two-level indentation and batch expression rules are in [generated code layout](development.md#generated-code-layout). | | Share example dependencies by language | Keep generated outputs inside each example's language/runtime directories and reuse dependency manifests and harnesses across example families. See [development](development.md#generated-runtime-checks). | diff --git a/.agents/development.md b/.agents/development.md index 520d6acc..5c38d430 100644 --- a/.agents/development.md +++ b/.agents/development.md @@ -39,7 +39,7 @@ go tool cover -html=coverage.out -o coverage.html `make coverage` runs the root Go module's tests without caching and writes `coverage.out` in atomic mode. `-coverpkg=./...` includes calls across package boundaries, so CLI and golden tests contribute to analyzer and generator coverage. The final `total` from `go tool cover` is the combined statement coverage; the per-test-package percentages are not independent package coverage figures. The denominator contains only sqlc-ydb runtime code, including the CLI entry point even when untested. Unit and end-to-end tests run and contribute coverage of that code, but their own source is not measured. Examples, the `internal/endtoend` harness, `*_test.go` files, golden outputs, `.github` tooling and the external ANTLR parser dependency are outside this scope. Go already excludes test source and the separate examples module from the profile; `codecov.yml` also explicitly excludes the repository's non-runtime paths. This local profile excludes live YDB tests unless their environment variables are set, and excludes optional SDK checks unless explicitly enabled. -CI saves the offline profile together with `coverage-sdk.out` and `coverage-typescript.out` from compiler/SDK-backed generator tests as the `generator-coverage` artifact and uploads them to Codecov with the `unit` flag on pushes to `main` and pull requests. The `ydb-acceptance` job also instruments the semantic, integer pagination, table-path-prefix, callback, Go generator and Python generator live tests and uploads their six profiles with the `integration` flag, saving them as the `generator-integration-coverage` artifact. Codecov merges these profiles; generated application runtime coverage is not measured. SDK checks contribute coverage of the generator code they execute. The PR comment updates as reports arrive, so the first report can show only offline coverage. [codecov.yml](../codecov.yml) compares project coverage with the base commit (allowing a one percentage point drop) and requires 80% patch coverage. It enables one updated PR comment with the coverage difference and impacted files, including on the first PR without a base report. A successful `main` upload establishes the comparison baseline and populates the README badge. +CI saves the offline profile together with `coverage-sdk.out` and `coverage-typescript.out` from compiler/SDK-backed generator tests as the `generator-coverage` artifact and uploads them to Codecov with the `unit` flag on pushes to `main` and pull requests. The `ydb-acceptance` job also instruments the semantic, integer pagination, table-path-prefix, callback, shared-expression, Go generator and Python generator live tests and uploads their seven profiles with the `integration` flag, saving them as the `generator-integration-coverage` artifact. Codecov merges these profiles; generated application runtime coverage is not measured. SDK checks contribute coverage of the generator code they execute. The PR comment updates as reports arrive, so the first report can show only offline coverage. [codecov.yml](../codecov.yml) compares project coverage with the base commit (allowing a one percentage point drop) and requires 80% patch coverage. It enables one updated PR comment with the coverage difference and impacted files, including on the first PR without a base report. A successful `main` upload establishes the comparison baseline and populates the README badge. Repository administrators must enable `ydb-platform/sqlc-ydb` in Codecov and grant the [Codecov GitHub App](https://github.com/apps/codecov) access so it can post PR comments. Set the repository Actions secret `CODECOV_TOKEN` to the Codecov upload token, as in ydb-go-sdk. Alternatively, the organization can allow tokenless public uploads with **Global Upload Token → Not required** in Codecov; the action accepts an empty secret in that mode. Public fork PR uploads do not need access to the secret. See [Codecov token authentication](https://docs.codecov.com/docs/codecov-tokens) and [PR comments](https://docs.codecov.com/docs/pull-request-comments). An upload failure fails the CI job rather than silently leaving stale coverage. @@ -173,3 +173,5 @@ YDB_CONNECTION_STRING=grpc://localhost:2136/local composer --working-dir=tests/e The [TypeScript](../docs/typescript.md), [Rust](../docs/rust.md) and [PHP](../docs/php.md) pages define their value representations, dependencies and runtime ownership. `TestLiveYDBEach` generates the callback fixture and runs both Go profiles sequentially using unique disposable tables. It checks full and empty results, a 16 MiB streaming result, cancellation, early termination, no replay of retryable callback errors, connection reuse, sessions and transactions. Run `YDB_CONNECTION_STRING=grpc://localhost:2136/local go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBEach$' -v`. Stable and nightly CI run this suite in a separate sequential step with its own 240-second test budget; stable CI includes `coverage-each-live.out` in integration coverage. `TestEachGeneratedGoCompiles` checks the same harness offline; `TestEachRuntime` in the Go generator package covers deterministic error/cleanup paths and retained-memory bounds. + +`TestLiveYDBSharedExpressions` checks Boolean values, conditional aggregates, string concatenation, verified casts, literal-aware COALESCE and implicit result names against server metadata and generated Go native/database/sql execution. Run `YDB_CONNECTION_STRING=grpc://localhost:2136/local go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBSharedExpressions$' -v` sequentially with the other live suites. Stable and nightly CI run the suite in a separate step; stable CI uploads `coverage-shared-expressions-live.out` with the integration coverage. The authors examples additionally execute reporting, prefix search and export metadata through the generated clients. diff --git a/.agents/sdk-evidence.md b/.agents/sdk-evidence.md index e35c8323..1917ea5b 100644 --- a/.agents/sdk-evidence.md +++ b/.agents/sdk-evidence.md @@ -67,6 +67,8 @@ Go parameter binding adapts the historical ParamsBuilder idea against SDK `v3.15 ### jOOQ YDB prototype (2026-09-11) +On 2026-09-23, published jOOQ 3.21.0, YDB dialect 2.0.0 and JDBC 2.4.1 validated shared-expression generation. Typed `DSL.function(systemName("COUNT_IF"), YdbTypes.UINT64, ...)`, `Field.cast(YdbTypes.BOOL)` and null-check conditions compile and execute with their analyzer-resolved result types. Ordinary DSL SELECT queries use `ResultQuery.coerce(Field...)` with the already resolved field names/order, just as the existing prefixed result-query path does. This preserves authored SQL while making record constructor types follow the actual YDB result order when implicit names collide; `SELECT "z"u AS z, 2 AS column2, 3, 4` returns Int32/Int32/Int32/Utf8 fields in lexical name order. `TestJooqImplicitColumnCollisionResultOrder` failed javac before this change and then passed compile/execution through the published SDKs with mock JDBC metadata. Separate execution of the generated `:one` and `:many` methods against local YDB 26.3.1.16 confirmed both field order and values. No generated lifecycle, connection ownership or retry policy was added. + Index-source follow-up on 2026-09-23 verified `DSL.table(String, QueryPart...)` against the same pinned artifacts. Embedding the typed base table and index `Name` as query parts in `{0} VIEW {1}` preserves RenderMapping; aliases are applied after VIEW. For explicitly declared SQL, only the base-name span is mapped and an implicit qualifier, when needed, is inserted after the index selection. The authors generated-method compilation/render tests and mapped-table live test passed on local-ydb 26.3.1.8; quoted-index regressions include escaped backticks at identifier boundaries. Pinned runtime: jooq 3.21.0, jooq-ydb-dialect 2.0.0, ydb-jdbc-driver 2.4.1, Java 21. Inspected the published jars and corresponding ydb-java-dialects sources: [YdbTypes](https://github.com/ydb-platform/ydb-java-dialects/blob/main/jooq-dialect/src/main/java/tech/ydb/jooq/YdbTypes.java), [UpsertTest](https://github.com/ydb-platform/ydb-java-dialects/blob/main/jooq-dialect/src/test/java/tech/ydb/jooq/UpsertTest.java), [YdbDSLContextImpl](https://github.com/ydb-platform/ydb-java-dialects/blob/main/jooq-dialect/src/main/java/tech/ydb/jooq/impl/YdbDSLContextImpl.java). `org.jooq.impl.YdbListener` quotes Name nodes except Name.Quoted.SYSTEM. The DEFAULT DML execution path uses executeUpdate/getGeneratedKeys, incompatible with YDB RETURNING result sets. Generated RETURNING statements therefore execute as ResultQuery query parts with explicit field coercion. The exact compatibility contract and commands are in [Java generation](../docs/java.md#jooq-prototype); `tests/examples/java/jooq` compiles every example method and executes live checks in isolated tables. No upstream source was copied into the generator. diff --git a/.agents/yql-evidence.md b/.agents/yql-evidence.md index 96339315..9c608e23 100644 --- a/.agents/yql-evidence.md +++ b/.agents/yql-evidence.md @@ -69,3 +69,17 @@ The same fixture executed LIMIT and OFFSET with required and optional `Int8`, `I Both EXPLAIN and execution rejected Int64, optional Int64, Double, Bool and Utf8 count expressions and an overflowing Uint64 literal. Positive Int64 literals, including `9223372036854775807l`, executed successfully; `CAST(1 AS Int64)` was rejected. Separate raw probes also accepted a local Int64 literal binding and direct negative integer literals, which remain outside the analyzer's current expression/provenance subset. The offline diagnostics document that narrower subset rather than attributing those restrictions to YDB. A bounded raw probe of the authors-shaped query with an Int32 page size of 1 or 2 and Uint32 offset `4294967295` reached the four-second request deadline; the first generated test attempt reached its 90-second context deadline. The same raw query returned immediately at offset `2147483648` and `1000`, and at maximum Int32 page size with offset zero. This observation does not establish a server root cause or a universal timeout boundary. The executable example tests use an offset above the signed Int32 range that completed successfully; the generator preserves the full Uint32 binding range without a compensating cast or hidden limit. + +## Shared expressions and implicit result names + +The shared-expression implementation was checked on 2026-09-23 against YDB commit `1415fed8104201c5e973dd8bbf12c71c6b1ed8b9`. Its [basic built-ins](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/basic.md), [aggregate functions](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/aggregation.md) and [CAST reference](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/types/cast.md) define the documented forms. `CoalesceWrapper`, `DataGeneratorWrapper` and `CurrentTzWrapper` in [type_ann_core.cpp](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/core/type_ann/type_ann_core.cpp#L6171) supply the value-dependent/common-type and optionality rules. SQL translation [requires at least one dependency argument for Random, RandomNumber and RandomUuid](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/sql/v1/translation/builtin.cpp#L3448), despite the lower-level data-generator wrapper accepting zero arguments. + +For unnamed SELECT outputs, [core type annotation](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/core/type_ann/type_ann_core.cpp#L13084) reserves named outputs before allocating `columnN` from the projection item's zero-based ordinal and incrementing past collisions. [SELECT translation](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/sql/v1/translation/select.cpp#L2351) distinguishes pure column references from computed expressions. The [result provider](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/providers/result/provider/yql_result_provider.cpp#L164) drops an unusable original column-order hint; collision probes then observed lexical struct-member order on the wire. The analyzer implements these rules without copying upstream source. + +Wildcard expansion can change projection ordinals, so shared normalization inserts explicit aliases preserving the original implicit names alongside its existing star replacement. This is necessary for SQL such as `SELECT t.*, 1 FROM t ORDER BY column1`: expanding a two-column table must not silently rename the expression to `column2`. Reanalysis of normalized SQL provides the final output order. Queries without wildcard expansion retain their authored SQL, including unnamed expressions. + +On the same server, `SELECT id > 0ul AS id ... ORDER BY id = $p` compared the projected Bool with `$p`, rather than the physical Uint64 source column. Using only source-column bindings therefore emits an incorrect inferred declaration or a wrong jOOQ field. ORDER BY resolution distinguishes direct physical/renamed projections from computed outputs using the existing parser contexts and token bindings; this small origin check is necessary even when names and types happen to match. Compound expressions over renamed/computed output aliases receive an actionable diagnostic until inference can represent their scope; direct output ordering remains supported without a new intermediate model or runtime scope framework. + +`TestLiveYDBSharedExpressions` passed against the already-running `ydbplatform/local-ydb:26.3.1.16` (`sha256:32687d3bc4b7a3e4200e2142800e5fc2e91d48ba46160ea094e9f1ce56794c12`) using Go SDK v3.151.1 through generated native Query SDK and database/sql clients. Metadata comparisons and actual execution covered optional Boolean/string expressions, empty/grouped/NULL COUNT_IF, integer/Bool casts and Uint32 narrowing boundaries, invalid JSON, timestamp conversions, fitting COALESCE fallbacks, UTC/Random/Version/NANVL types, implicit names and mixed-type collisions, and wildcard normalization with ORDER BY. Additional direct server probes covered all integer/Bool CAST widths, nonliteral COALESCE fallbacks, optional Tz result types and rejection of Utf8 zone arguments; those probes do not establish Tz decoding support in every output runtime. The test only created and removed its own tables and did not restart the shared service. + +The checked-in authors scenarios also passed on that server in both Go profiles and jOOQ 3.21.0 with dialect 2.0.0 and JDBC 2.4.1. They exercise prefix filtering, nullable/empty/nonempty bios, empty/nonempty conditional statistics, unaliased CAST results, and export metadata with a fitting Uint32 fallback. Date and Datetime are explicitly cast to String in the SQL example because not every output runtime supports those native result types. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74f7d24..49000528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,6 +210,9 @@ jobs: - name: Verify streaming callbacks with YDB timeout-minutes: 5 run: go test -p 1 -count=1 -timeout=240s -covermode=atomic -coverpkg=./... -coverprofile=coverage-each-live.out ./internal/endtoend -run '^TestLiveYDBEach$' -v + - name: Verify shared expressions and implicit result names with YDB + timeout-minutes: 5 + run: go test -p 1 -count=1 -timeout=240s -covermode=atomic -coverpkg=./... -coverprofile=coverage-shared-expressions-live.out ./internal/endtoend -run '^TestLiveYDBSharedExpressions$' -v - name: Verify public query metadata and EXPLAIN contracts run: go test -p 1 -count=1 -timeout=120s ./internal/database -run '^TestLiveYDBQueryMetadata$' -v - name: Run Go generator runtime tests @@ -263,12 +266,13 @@ jobs: coverage-go-live.out coverage-python-live.out coverage-each-live.out + coverage-shared-expressions-live.out if-no-files-found: error - name: Upload integration coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage-semantic.out,./coverage-integer-limits-live.out,./coverage-table-path-prefix-live.out,./coverage-go-live.out,./coverage-python-live.out,./coverage-each-live.out + files: ./coverage-semantic.out,./coverage-integer-limits-live.out,./coverage-table-path-prefix-live.out,./coverage-go-live.out,./coverage-python-live.out,./coverage-each-live.out,./coverage-shared-expressions-live.out flags: integration disable_search: true fail_ci_if_error: true @@ -319,6 +323,9 @@ jobs: - name: Verify streaming callbacks with nightly YDB timeout-minutes: 5 run: go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBEach$' -v + - name: Verify shared expressions and implicit result names with nightly YDB + timeout-minutes: 5 + run: go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBSharedExpressions$' -v cpp-acceptance: runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index abc9dcc6..e38b053e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Added + +- Resolve Boolean expressions and string concatenation consistently in projections, function arguments, local bindings and supported DML values. Add conditional `COUNT_IF` aggregates, fitting integer-literal fallbacks in COALESCE/NVL, and verified Boolean, timestamp and JSON casts. +- Support the UTC clock family and additional scalar built-ins, with a documented inventory of the upstream YQL builtin catalog and explicit prerequisites for remaining families. +- Infer YDB result names for computed SELECT columns without AS. Add authors reporting, prefix-search and export-metadata examples for every language/runtime profile. + ## v0.2.5 ### Added diff --git a/docs/compatibility.md b/docs/compatibility.md index 14d9c055..d87b07a5 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -54,7 +54,7 @@ This check covers regular files directly in directories the current generation w The analyzer supports explicit `CREATE TABLE` catalogs and the schema migration operations listed above, table column and wildcard projections, table/column aliases, supported joins and their optional sides, supported scalar/aggregate functions, `DECLARE`, direct comparison parameter inference, scalar local bindings, `INSERT`/`UPSERT ... VALUES`, typed `INSERT`/`UPSERT ... SELECT`, `UPDATE ... SET`, `UPDATE ... ON SELECT`, `DELETE`, `DELETE ... ON SELECT`, and `RETURNING`. SELECT expression, source, predicate and grouping analysis is shared between read queries and DML SELECT sources. It validates names outside the projection and conflicting parameter constraints. Diagnostics include source file, line and column. Table, column, alias and parameter names are case-sensitive, as in YQL. -Supported `SELECT *`, `SELECT alias.*` and `RETURNING *` projections are expanded into explicit quoted columns once in shared analysis before code generation. Offline analysis uses the local schema catalog; connected analysis uses the same catalog when supplied, or discovers columns from YDB. The executable SQL and result metadata agree on column order. Adding unrelated database columns after generation leaves the selected result shape unchanged; removing or changing selected columns remains incompatible. Wildcard expansion preserves source text outside the replaced projection spans, including declarations, comments, string literals and `COUNT(*)`. +Supported `SELECT *`, `SELECT alias.*` and `RETURNING *` projections are expanded into explicit quoted columns once in shared analysis before code generation. Offline analysis uses the local schema catalog; connected analysis uses the same catalog when supplied, or discovers columns from YDB. The executable SQL and result metadata agree on column order. Adding unrelated database columns after generation leaves the selected result shape unchanged; removing or changing selected columns remains incompatible. When a SELECT combines a qualified wildcard with unnamed computed expressions, normalization also inserts explicit aliases preserving their original YDB result names. This prevents additional expanded columns from changing references such as `ORDER BY column1`. Other source text is retained, including declarations, comments, string literals and `COUNT(*)`. Schema and declared `Decimal(P,S)` types require `1 <= P <= 35` and `0 <= S <= P`, including inside containers. Invalid values fail during analysis. @@ -64,19 +64,23 @@ Integer type aliases are canonicalized in schema columns, parameter declarations YDB serial columns are represented by their public integer value type while the catalog retains sequence-generation metadata. `SmallSerial` and `Serial2` map to `Int16`; `Serial` and `Serial4` map to `Int32`; `Serial8` and `BigSerial` map to `Int64`. A serial column must participate in the table's `PRIMARY KEY`. `INSERT` and `UPSERT` may omit it to allocate the next sequence value, or bind an explicit integer value without advancing that sequence. Avoid using a serial column as the primary key of a high-write table: monotonically increasing keys can create hot partitions. See the YDB [serial type documentation](https://ydb.tech/docs/en/yql/reference/types/serial). -Direct scalar literal projections retain their YQL types, including integer width/signedness, `Float` versus `Double`, and `String` versus `Utf8`. Integer suffixes and ranges follow the [YQL lexical rules](https://ydb.tech/docs/en/yql/reference/syntax/lexer). Non-column projections need an explicit `AS` name when the consumer requires named results; explicit-target INSERT/UPSERT SELECT does not. A compound expression such as `$value = 1ul` is rejected instead of inheriting the parameter's type. +Computed SELECT expressions without `AS` use YDB's implicit `columnN` result names. The zero-based projection position supplies the initial number; names already reserved by other outputs are skipped. The analyzed result order also follows YDB's ordering rule when implicit names collide. Explicit aliases remain useful for stable application-facing names when the projection changes. -Declared root `Struct` parameters are accessed with `$record.member`; nested members use the same form (for example, `$record.address.city`). Member names are resolved by declaration name, including quoted names, independently of field order. Named query results require an alias, for example `SELECT $key.id AS id`. Struct values returned by configured functions can also be accessed by member name. An `Optional>` base is rejected because optional-struct unwrapping is not implemented. Struct members may be scalar or optional scalar, but generated bindings currently reject nested Struct/List fields; this limitation is independent of offline member resolution. +ORDER BY accepts direct output aliases, including computed result names. Compound ORDER BY expressions referencing computed or renamed outputs remain unsupported; project the complete sorting expression with AS and order by that alias directly instead. Source-column ordering retains its existing parameter inference. -Scalar `SELECT` queries can omit `FROM`. String concatenation with `||` is supported for direct string literals and declared parameters of the same string family (`String` or `Utf8`); an optional operand makes the result optional. Mixed families, nested expressions and computed operands remain unsupported. The [booktest greeting](../examples/booktest/queries.sql) exercises this path. +Direct scalar literal projections retain their YQL types, including integer width/signedness, `Float` versus `Double`, and `String` versus `Utf8`. Integer suffixes and ranges follow the [YQL lexical rules](https://ydb.tech/docs/en/yql/reference/syntax/lexer). Supported comparisons and Boolean expressions produce their own resolved Bool or Optional type, independently of where the expression is used. -CASE expressions with an explicit ELSE branch, the supported CAST matrix and nested calls to [supported built-ins](../internal/yql/builtins/README.md) have resolved result types. CASE/IF conditions and HAVING support the implemented comparison predicates; standalone comparison projections remain unsupported. Binary numeric `+`, `-`, and `*` and parentheses around supported expressions use the same resolver in projections, predicates and DML values. NULL can participate in typed branches and can use an optional DML destination as its contextual type, but cannot otherwise escape as an unresolved result. +Declared root `Struct` parameters are accessed with `$record.member`; nested members use the same form (for example, `$record.address.city`). Member names are resolved by declaration name, including quoted names, independently of field order. Use an explicit alias such as `SELECT $key.id AS id` for a stable application-facing name; otherwise the expression receives an implicit `columnN` name. Struct values returned by configured functions can also be accessed by member name. An `Optional>` base is rejected because optional-struct unwrapping is not implemented. Struct members may be scalar or optional scalar, but generated bindings currently reject nested Struct/List fields; this limitation is independent of offline member resolution. + +Scalar `SELECT` queries can omit `FROM`. String concatenation with `||` accepts supported operands of the same string family (`String` or `Utf8`), including columns, nested functions and declared parameters; an optional operand makes the result optional. Mixed string families remain unsupported. Concatenations use the shared scalar resolver, including inside `LIKE` predicates. The [booktest greeting](../examples/booktest/queries.sql) and [authors prefix search](../examples/authors/queries.sql) exercise these paths. + +CASE expressions with an explicit ELSE branch, the supported CAST matrix and nested calls to [supported built-ins](../internal/yql/builtins/README.md) have resolved result types. Supported comparisons, null checks and `AND`/`OR`/`NOT` combinations work in projections, function arguments, scalar local bindings and DML assignments. Null checks produce required Bool; nullable comparisons and logical operands preserve YQL's three-valued logic. Binary numeric `+`, `-`, and `*` and parentheses around supported expressions use the same resolver in projections, predicates and DML values. NULL can participate in typed branches and can use an optional DML destination as its contextual type, but cannot otherwise escape as an unresolved result. The shipped function catalog is bounded. `sql[].analyzer.functions` adds exact concrete signatures using supported model types, including named and omittable arguments and declared AutoMap propagation, without loading or executing code. Polymorphic and resource-valued signatures remain unsupported. Unknown functions remain errors; configured contracts do not prove that a UDF is installed on the destination server and do not imply coverage of the complete YDB UDF registry. -COALESCE/NVL require matching base argument types; use explicit CASTs for mixed types. Core SUBSTRING accepts byte strings, while Unicode::Substring handles Utf8. Core SUBSTRING/FIND/RFIND positions accept unsigned integers up to Uint32, including optional values. Literal-dependent conversions outside this subset require an explicit CAST. See the built-in resolver's coverage notes for the separate library signatures. +COALESCE/NVL accept a fitting integer literal as a fallback for a concrete integer type, such as `COALESCE(nullable_uint32, 0)`. The analyzer checks the literal's value; it does not narrow parameters or computed values merely because their runtime value might fit. Other numeric arguments use the supported YQL common numeric type. Mixed String/Utf8 arguments produce String; other nonnumeric base types must match. Argument order matters: a fitting fallback can retain the accumulated left-hand integer type, while a wider first argument retains its wider type. Core SUBSTRING accepts byte strings, while Unicode::Substring handles Utf8. Core SUBSTRING/FIND/RFIND positions accept unsigned integers up to Uint32, including optional values. Literal-dependent conversions outside this subset require an explicit CAST. See the built-in resolver's coverage notes for the separate library signatures. -UNION and UNION ALL reconcile columns by YQL result name, preserve server column ordering, reconcile supported common types and make missing columns optional. Direct-column GROUP BY and HAVING validate grouped references and supported aggregate calls. Aggregate result nullability accounts for empty global input versus nonempty groups and nullable arguments. Grouping expressions, windows and advanced grouping constructs remain unsupported. +UNION and UNION ALL reconcile columns by YQL result name, preserve server column ordering, reconcile supported common types and make missing columns optional. Direct-column GROUP BY and HAVING validate grouped references and supported aggregate calls. `COUNT_IF` accepts Bool, Optional and contextual NULL, counts only true predicates and returns required Uint64, including zero on empty input. Aggregate result nullability accounts for empty global input versus nonempty groups and nullable arguments. Grouping expressions, windows and advanced grouping constructs remain unsupported. LIMIT/OFFSET retain an explicitly declared parameter's type. Supported types are `Int8`, `Int16`, `Int32`, `Uint8`, `Uint16`, `Uint32`, `Uint64` and their optional forms. YDB rejects bound or computed `Int64` counts, including the `BigInt` alias; floating-point, Boolean and string counts are also rejected. Nonnegative `Int64` literals such as `LIMIT 1l` are accepted without converting them to parameters. Local bindings that retain only an `Int64` type, such as `$n = 1l; ... LIMIT $n`, remain outside offline analysis even though YDB accepts them. @@ -90,7 +94,7 @@ Predicate comparison uses the supported common-type resolver. Integer kinds reco This remains a deliberately limited semantic implementation. General computed projections, the full CAST matrix, CTEs/subqueries, multiple result sets, FLATTEN, full function/type inference and the full YQL grammar semantics are subsequent work. Unary numeric expressions in projections or local assignments and backslash escapes in quoted identifiers are also explicitly rejected until their YQL semantics are implemented. `EXPLAIN` cannot be used as a named data query. Accepted syntax is not a claim of full equivalence to the YDB server's type checker. -INSERT/UPSERT VALUES and individual UPDATE SET assignments accept direct parameters, literals, contextual NULL, parentheses around supported expressions, supported comparisons, scalar functions and CAST/CASE expressions. UPDATE expressions can read the target row's columns, so `SET value = value + $delta` executes the calculation in YDB. VALUES expressions cannot read the destination table. Boolean assignments such as `SET enabled = (value > 1)` are supported; nullable comparison results require an optional Bool destination. Duplicate SET targets are rejected. Logical AND/OR/NOT combinations and IN predicates in assignments remain unsupported. Binary `+`, `-`, and `*` support primitive integer, Float and Double operands; mixed numeric types use YQL's common numeric type, and an optional operand makes the result optional. Arithmetic preserves server overflow behavior; generated code does not calculate, saturate or split an assignment into a read and a write. Division, remainder, unary numeric operators, Decimal/temporal arithmetic, unresolved NULL operands, aggregates, windows, tuples and subqueries remain unsupported in these assignments. +INSERT/UPSERT VALUES and individual UPDATE SET assignments accept direct parameters, literals, contextual NULL, parentheses around supported expressions, supported comparisons, scalar functions and CAST/CASE expressions. UPDATE expressions can read the target row's columns, so `SET value = value + $delta` executes the calculation in YDB. VALUES expressions cannot read the destination table. Boolean assignments such as `SET enabled = (value > 1)` are supported; nullable comparison results require an optional Bool destination. Duplicate SET targets are rejected. IN predicates in assignments remain unsupported. Binary `+`, `-`, and `*` support primitive integer, Float and Double operands; mixed numeric types use YQL's common numeric type, and an optional operand makes the result optional. Arithmetic preserves server overflow behavior; generated code does not calculate, saturate or split an assignment into a read and a write. Division, remainder, unary numeric operators, Decimal/temporal arithmetic, unresolved NULL operands, aggregates, windows, tuples and subqueries remain unsupported in these assignments. Computed values must match the destination type, optionally lifting a required value into an optional destination. NULL is accepted only for an optional destination. All these DML assignment forms, including INSERT/UPSERT SELECT and UPDATE/DELETE ON SELECT, share the same compatibility check. Lossless integer widening is also accepted: same-signedness widening and unsigned-to-signed conversion into a strictly wider type. Narrowing, signed-to-unsigned and integer/float assignment conversions require an explicit compatible CAST; a CAST whose result can be NULL still requires an optional destination. Value-dependent literal narrowing is not inferred. Direct parameter assignments retain destination-based inference and its existing type constraints, including when parenthesized. Parameters inside computed expressions must already have a resolved type, normally through DECLARE; the destination type is not propagated through arithmetic. @@ -104,7 +108,7 @@ RETURNING value; The [computed DML fixture](../internal/endtoend/testdata/computed_dml/queries.sql) covers constants, counters, nullable fields and UPSERT arithmetic. These operations work with the existing generated transaction APIs: callers own transaction boundaries and retry policy. The jOOQ DSL does not translate arithmetic; use explicitly declared queries (its typed JDBC path), `runtime: jdbc`, or `runtime: ydb`. -INSERT/UPSERT SELECT supports two mapping contracts. With an explicit target column list, explicit source expressions match the targets positionally; source aliases are optional and do not retarget values by name, and wildcard source projections remain unsupported. Without a target column list, source result names select the destination columns; supported expressions need aliases, while column projections keep their names. This named form accepts `*`, `alias.*` and computed columns combined with wildcards. Duplicate names, unknown destination columns and incompatible types fail analysis. Both forms reject missing required NOT NULL columns and missing non-generated primary-key columns before generation. Omitted generated serial keys use the sequence; other primary-key columns must be supplied. UPSERT preserves omitted nullable columns on an existing row, but all NOT NULL columns without generated defaults must still be supplied, even for an empty source or when the row already exists. UPDATE/DELETE ON SELECT instead match source result names to target columns and require every primary-key column with a compatible declared type. Shared query-file declarations must currently be moved into each named query. These are temporary coverage limits, separate from the permanent decision to exclude plugins. Comments and whitespace may precede the first query annotation; comment-only query files are ignored. +INSERT/UPSERT SELECT supports two mapping contracts. With an explicit target column list, explicit source expressions match the targets positionally; source aliases are optional and do not retarget values by name, and wildcard source projections remain unsupported. Without a target column list, source result names select the destination columns; column projections keep their names and unnamed computed expressions receive implicit `columnN` names. Explicit aliases should name the intended destination columns. This named form accepts `*`, `alias.*` and computed columns combined with wildcards. Duplicate names, unknown destination columns and incompatible types fail analysis. Both forms reject missing required NOT NULL columns and missing non-generated primary-key columns before generation. Omitted generated serial keys use the sequence; other primary-key columns must be supplied. UPSERT preserves omitted nullable columns on an existing row, but all NOT NULL columns without generated defaults must still be supplied, even for an empty source or when the row already exists. UPDATE/DELETE ON SELECT instead match source result names to target columns and require every primary-key column with a compatible declared type. Shared query-file declarations must currently be moved into each named query. These are temporary coverage limits, separate from the permanent decision to exclude plugins. Comments and whitespace may precede the first query annotation; comment-only query files are ignored. ## Table path resolution @@ -121,7 +125,7 @@ WHERE u.id = $id; The offline catalog must describe the same resolved table path: use this prefix in its schema source or declare the table as `/database/staging/users`. Each named query and each schema source has its own prefix scope. Put the pragma before local assignments such as `$key = $id` and before data or schema statements, and repeat it in every source that needs it. External parameter declarations such as `DECLARE $id AS Uint64` may precede or follow the pragma. Identical repeated prefixes are accepted; prefix changes within a named query or schema source are not supported by the compiler. Query-file preambles before `-- name:` remain unsupported. -Path resolution is shared by SELECT sources, joins, secondary-index VIEW selection, supported writes and schema migrations. Tables with the same basename in different directories remain distinct catalog entries. Aliases, column names, strings and comments retain their original meaning. Executable SQL retains the pragma and authored table references; wildcard expansion continues to replace only the projection spans. The jOOQ target renders its resolved table identities through the dialect, preserving the pragma's execution context and table mappings. +Path resolution is shared by SELECT sources, joins, secondary-index VIEW selection, supported writes and schema migrations. Tables with the same basename in different directories remain distinct catalog entries. Aliases, column names, strings and comments retain their original meaning. Executable SQL retains the pragma and authored table references; projection normalization follows the wildcard and implicit-name rules above. The jOOQ target renders its resolved table identities through the dialect, preserving the pragma's execution context and table mappings. An explicit source alias hides the table's original qualifier: `FROM records AS r` permits `r.id` and `r.*`, while `records.id` and `records.*` are rejected. This also applies without TablePathPrefix and corrects queries accepted by v0.2.4 that YDB rejects; replace the original qualifier with the alias when upgrading. Without an explicit alias, use the authored table name, including its directory components when present. Qualifiers are case-sensitive: `r` and `R` refer to distinct aliases. diff --git a/docs/provenance.md b/docs/provenance.md index 050f7aa6..008356a1 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -39,3 +39,5 @@ The converter's grammar paths informed direct-context CASE, CAST, UNION and grou Go parameter binding adapts the historical ParamsBuilder idea against SDK `v3.151.1`, pinned in [examples/go.mod](../examples/go.mod). The generators also reference their published SDKs and framework APIs without copying those implementations. Exact source snapshots and non-obvious API choices are kept in the maintainer [SDK evidence](../.agents/sdk-evidence.md); public language guides define the generated API, dependency and runtime ownership contracts. Database-assisted analysis is an independent implementation against the public `TableService.DescribeTable` and `QueryService.ExecuteQuery` protobuf contracts from [`ydb-platform/ydb-go-genproto@65bfd5c4b705`](https://github.com/ydb-platform/ydb-go-genproto/tree/65bfd5c4b705). The compiler uses EXPLAIN for non-executing query compilation; no SDK client implementation or upstream sqlc database analyzer was copied. Exact server-source and live verification evidence is recorded in [SDK evidence](../.agents/sdk-evidence.md#database-assisted-analysis-2026-09-22). + +Shared Boolean expressions, conditional aggregates, additional scalar built-ins, value-aware COALESCE typing and implicit SELECT result names were independently implemented using YDB source and documentation at [`1415fed8104201c5e973dd8bbf12c71c6b1ed8b9`](https://github.com/ydb-platform/ydb/tree/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9). The implementation references SQL translation, core type annotations and the result provider; no upstream implementation or test code was copied. The [reference inventory](yql-builtins.md) indexes all ten built-in reference pages without claiming complete function support. Exact source locations and server observations are recorded in [YQL evidence](../.agents/yql-evidence.md#shared-expressions-and-implicit-result-names). diff --git a/docs/yql-builtins.md b/docs/yql-builtins.md new file mode 100644 index 00000000..6b8e7dd8 --- /dev/null +++ b/docs/yql-builtins.md @@ -0,0 +1,272 @@ +# YQL built-in coverage inventory + +This inventory is pinned to upstream YDB commit `1415fed8104201c5e973dd8bbf12c71c6b1ed8b9`. It records all 193 level-two reference sections across the ten built-in reference pages. A section can describe several functions, aliases, or non-function syntax; 193 is not a count of supported functions. The separately linked C++ UDF libraries are outside this count. + +A resolved subset means that the offline analyzer has explicit type rules for the stated forms. It does not imply every overload, SQL context, or output SDK is supported. Argument resolution, callable/type/resource values, aggregate/window context, and selected-runtime binding/decoding remain independent requirements. Unknown names or unsupported forms fail with diagnostics; catalog presence is never a fallback return type. + +The current implementation keeps one shared semantic pass before code generation. Full reference coverage requires further work on scoped lambdas, type-valued expressions, variants/resources, window frames, collection/struct overloads, SQL/JSON clauses, and compile-time code generation. Those prerequisites must be implemented and verified before the corresponding entries can be marked supported. + +Prerequisite labels below are work still required, not supported overloads: **S** scalar overload/type rule; **C** collection/struct construction or reconciliation; **L** scoped lambda/callable analysis; **T** type-valued expressions; **V** Variant/Enum/Tagged value types; **R** resource/code/world values; **M** table/provider metadata context; **W** window/frame context; **A** aggregate state, arguments and empty/grouped semantics; **K** literal-name/option metadata; **J** SQL/JSON grammar and ON EMPTY/ERROR rules; **F** provider feature or external-file context. + +## Basic + +[55 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/basic.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| COALESCE | Resolved subset: Pairwise numeric reconciliation; fitting right-hand integer literals preserve the left integer type; optionality follows fallback availability. | +| LENGTH | Resolved subset: LENGTH/LEN; byte count. | +| SUBSTRING | Resolved subset: String input; bounded unsigned positions through Uint32. | +| FIND | Resolved subset: String/Utf8 input; bounded unsigned position. | +| RFIND | Resolved subset: String/Utf8 input; bounded unsigned position. | +| StartsWith, EndsWith | Resolved subset: Matching String/Utf8 types and Optional propagation. | +| IF | Resolved subset: Two/three arguments with resolved branch types. | +| NANVL | Resolved subset: Float/Double inputs and Optional propagation. | +| Random... | Resolved subset: Random, RandomNumber, RandomUuid; at least one typed dependency argument. | +| CurrentUtc... | Resolved subset: CurrentUtcDate, CurrentUtcDatetime, CurrentUtcTimestamp; typed dependencies. | +| CurrentTz... | Resolved subset: CurrentTzDate/Datetime/Timestamp; String/Optional/NULL zone, typed dependencies, Optional result. | +| AddTimezone | Not implemented — S: date-family/zone overloads and invalid-zone nullability. | +| RemoveTimezone | Not implemented — S: timezone-family conversion. | +| Version | Resolved subset: Zero arguments; String result. | +| MAX_OF, MIN_OF, GREATEST, and LEAST | Not implemented — S: common-type and NULL behavior. | +| AsTuple, AsStruct, AsList, AsDict, AsSet, AsListStrict, AsDictStrict and AsSetStrict | Not implemented — C, K: constructor fields, empty-container identity and strict/common-type rules. | +| Container literals | Parser syntax is not general typed container-expression support. | +| Variant | Not implemented — T, V, K: variant alternatives and selected member. | +| AsVariant | Not implemented — V, K: alternative identity. | +| Visit, VisitOrDefault | Not implemented — V, L: branch parameter scopes and result reconciliation. | +| VariantItem | Not implemented — V: alternative payload reconciliation. | +| Way | Not implemented — V: alternative identity/result type. | +| DynamicVariant | Not implemented — T, V: runtime-selected alternative. | +| Enum | Not implemented — T, V, K: enum alternatives. | +| AsEnum | Not implemented — V, K: literal alternative identity. | +| AsTagged, Untag | Not implemented — V, K: retained tag identity. | +| TablePath | Not implemented — M: current row/provider path. | +| TableName | Not implemented — M, S: provider path/name conventions. | +| TableRecordIndex | Not implemented — M: provider row index. | +| TableRow, JoinTableRow | Not implemented — M, C: relation/join row structure. | +| FileContent and FilePath | Not implemented — F: query file dependencies. | +| FolderPath | Not implemented — F: provider folder context. | +| ParseFile | Not implemented — F, T: external content and element type. | +| WeakField | Not implemented — M, T: weakly typed row fields. | +| Ensure... | Not implemented — S, T: assertion forms and error/nullability contract. | +| EvaluateExpr, EvaluateAtom | Not implemented — R, K: compile-time evaluation. | +| Literals of primitive types | Not implemented — S, K: constructor-form literal validation beyond existing SQL literals. | +| Access to the metadata of the current operation | Not implemented — M: provider operation metadata. | +| ToBytes and FromBytes | Not implemented — S, T: serialization and requested target type. | +| ByteAt | Not implemented — S: byte/index types and out-of-range nullability. | +| ...Bit | Not implemented — S: integer widths, shifts and rotations. | +| Abs | Resolved subset: Supported primitive numeric/Decimal input. | +| Just | Not implemented — S: Optional construction including nested levels. | +| Unwrap | Not implemented — S: Optional removal and error-message argument. | +| Nothing | Not implemented — T: target Optional type. | +| Callable | Not implemented — T, L: callable signature and scoped parameters. | +| Pickle, Unpickle | Not implemented — T, R: serialization and requested target type. | +| StaticMap | Not implemented — C, L: compile-time element mapping. | +| StaticZip | Not implemented — C: structural static zip. | +| StaticFold, StaticFold1 | Not implemented — C, L: accumulator and element scopes. | +| AggregationFactory | Not implemented — A, R, K: named aggregate factory state. | +| AggregateTransformInput | Not implemented — A, R, L: typed input transformation. | +| AggregateTransformOutput | Not implemented — A, R, L: typed result transformation. | +| AggregateFlatten | Not implemented — A, R: nested aggregate state. | +| YQL::, s-expressions | Not implemented — R: lower-level expression language. | + +## Aggregation + +[23 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/aggregation.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| COUNT | Resolved subset: Uint64; COUNT(*) and typed argument; DISTINCT not yet supported. | +| MIN and MAX | Resolved subset: Supported comparable scalar subset; empty/grouped nullability. | +| SUM | Resolved subset: Supported numeric subset; widening and empty/grouped nullability. | +| AVG | Resolved subset: Supported numeric/Interval subset; empty/grouped nullability. | +| COUNT_IF | Resolved subset: Bool/Optional/NULL input; Uint64 result, including empty input. | +| SUM_IF and AVG_IF | Not implemented — A: argument/result types and empty/grouped behavior. | +| SOME | Not implemented — A: argument/result types and empty/grouped behavior. | +| CountDistinctEstimate, HyperLogLog, and HLL | Not implemented — A: argument/result types and empty/grouped behavior. | +| AGGREGATE_LIST | Not implemented — A, C: aggregate collection/result structure and empty/grouped behavior. | +| MAX_BY and MIN_BY | Not implemented — A, C: aggregate collection/result structure and empty/grouped behavior. | +| TOP and BOTTOM | Not implemented — A, C: aggregate collection/result structure and empty/grouped behavior. | +| TOP_BY and BOTTOM_BY | Not implemented — A, C: aggregate collection/result structure and empty/grouped behavior. | +| TOPFREQ and MODE | Not implemented — A, C: aggregate collection/result structure and empty/grouped behavior. | +| STDDEV and VARIANCE | Not implemented — A: argument/result types and empty/grouped behavior. | +| CORRELATION and COVARIANCE | Not implemented — A: argument/result types and empty/grouped behavior. | +| PERCENTILE and MEDIAN | Not implemented — A: argument/result types and empty/grouped behavior. | +| HISTOGRAM | Not implemented — A, R: histogram state/resource and result operations. | +| LinearHistogram, LogarithmicHistogram, and LogHistogram | Not implemented — A, R: histogram state/resource and result operations. | +| CDF (cumulative distribution function) | Not implemented — A, R: histogram state/resource and result operations. | +| BOOL_AND, BOOL_OR and BOOL_XOR | Not implemented — A: argument/result types and empty/grouped behavior. | +| BIT_AND, BIT_OR and BIT_XOR | Not implemented — A: argument/result types and empty/grouped behavior. | +| SessionStart | Not implemented — A, W: session grouping context. | +| AGGREGATE_BY and MULTI_AGGREGATE_BY | Not implemented — A, R, L: aggregate factory and input/output contracts. | + +## List + +[35 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/list.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| ListCreate | Not implemented — T, C: requested element type. | +| AsList and AsListStrict | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListLength | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListHasItems | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListCollect | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListSort, ListSortAsc, and ListSortDesc | Not implemented — C, L: comparable elements and optional key-selector lambda. | +| ListExtend and ListExtendStrict | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListUnionAll | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListZip and ListZipAll | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListEnumerate | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListReverse | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListSkip | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListTake | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListSample and ListSampleN | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListShuffle | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListIndexOf | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListMap, ListFilter, and ListFlatMap | Not implemented — C, L: scoped element/accumulator types and callback result. | +| ListNotNull | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListFlatten | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListUniq | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListAny and ListAll | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListHas | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListHead, ListLast | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListMin, ListMax, ListSum and ListAvg | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListFold, ListFold1 | Not implemented — C, L: scoped element/accumulator types and callback result. | +| ListFoldMap, ListFold1Map | Not implemented — C, L: scoped element/accumulator types and callback result. | +| ListFromRange | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListReplicate | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListConcat | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ListExtract | Not implemented — C, K: literal Struct member and output element type. | +| ListTakeWhile, ListSkipWhile | Not implemented — C, L: scoped element/accumulator types and callback result. | +| ListAggregate | Not implemented — C, A, R: typed aggregation factory. | +| ToDict and ToMultiDict | Not implemented — C: element types, Optional/empty behavior and operation-specific arguments. | +| ToSet | Resolved subset: Concrete list keys; supported dictionary-key subset. | +| ListTop, ListTopAsc, ListTopDesc, ListTopSort, ListTopSortAsc и ListTopSortDesc | Not implemented — C, L: comparable elements and optional key-selector lambda. | + +## Dict + +[16 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/dict.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| DictCreate | Not implemented — T, C: requested key/payload types. | +| SetCreate | Not implemented — T, C: requested key/payload types. | +| DictLength | Not implemented — C: key/payload types and lookup/set nullability. | +| DictHasItems | Not implemented — C: key/payload types and lookup/set nullability. | +| DictItems | Not implemented — C: key/payload types and lookup/set nullability. | +| DictKeys | Not implemented — C: key/payload types and lookup/set nullability. | +| DictPayloads | Not implemented — C: key/payload types and lookup/set nullability. | +| DictLookup | Not implemented — C: key/payload types and lookup/set nullability. | +| DictContains | Not implemented — C: key/payload types and lookup/set nullability. | +| DictAggregate | Not implemented — C, A, L: aggregation callbacks and output payload. | +| SetIsDisjoint | Resolved subset: Resolved matching key types. | +| SetIntersection | Not implemented — C: key/payload types and lookup/set nullability. | +| SetIncludes | Not implemented — C: key/payload types and lookup/set nullability. | +| SetUnion | Not implemented — C: key/payload types and lookup/set nullability. | +| SetDifference | Not implemented — C: key/payload types and lookup/set nullability. | +| SetSymmetricDifference | Not implemented — C: key/payload types and lookup/set nullability. | + +## Struct + +[17 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/struct.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| TryMember | Not implemented — C, K: literal member names and structural result reconciliation. | +| ExpandStruct | Not implemented — C, K: literal member names and structural result reconciliation. | +| AddMember | Not implemented — C, K: literal member names and structural result reconciliation. | +| RemoveMember | Not implemented — C, K: literal member names and structural result reconciliation. | +| ForceRemoveMember | Not implemented — C, K: literal member names and structural result reconciliation. | +| ChooseMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| RemoveMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| ForceRemoveMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| CombineMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| FlattenMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| StructMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| RenameMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| ForceRenameMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| GatherMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| SpreadMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| ForceSpreadMembers | Not implemented — C, K: literal member names and structural result reconciliation. | +| StructUnion, StructIntersection, StructDifference, StructSymmetricDifference | Not implemented — C, L: structural reconciliation and optional member-merge lambda. | + +## Types + +[21 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/types.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| FormatType | Not implemented — T: compile-time type values and type introspection. | +| ParseType | Not implemented — T: compile-time type values and type introspection. | +| TypeOf | Not implemented — T: compile-time type values and type introspection. | +| InstanceOf | Not implemented — T: compile-time type values and type introspection. | +| DataType | Not implemented — T: compile-time type values and type introspection. | +| OptionalType | Not implemented — T: compile-time type values and type introspection. | +| ListType and StreamType | Not implemented — T: compile-time type values and type introspection. | +| DictType | Not implemented — T: compile-time type values and type introspection. | +| TupleType | Not implemented — T: compile-time type values and type introspection. | +| StructType | Not implemented — T: compile-time type values and type introspection. | +| VariantType | Not implemented — T: compile-time type values and type introspection. | +| ResourceType | Not implemented — T: compile-time type values and type introspection. | +| CallableType | Not implemented — T: compile-time type values and type introspection. | +| GenericType, UnitType, and VoidType | Not implemented — T: compile-time type values and type introspection. | +| OptionalItemType, ListItemType and StreamItemType | Not implemented — T: compile-time type values and type introspection. | +| DictKeyType and DictPayloadType | Not implemented — T: compile-time type values and type introspection. | +| TupleElementType | Not implemented — T: compile-time type values and type introspection. | +| StructMemberType | Not implemented — T: compile-time type values and type introspection. | +| CallableResultType and CallableArgumentType | Not implemented — T: compile-time type values and type introspection. | +| VariantUnderlyingType | Not implemented — T: compile-time type values and type introspection. | +| Functions for data types during calculations | Not implemented — T: compile-time type values and type introspection. | + +## Window + +[9 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/window.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| Aggregate functions | Existing aggregates do not imply OVER/window support. | +| ROW_NUMBER | Not implemented — W: partition/frame context and operation-specific nullability. | +| LAG / LEAD | Not implemented — W: partition/frame context and operation-specific nullability. | +| FIRST_VALUE / LAST_VALUE | Not implemented — W: partition/frame context and operation-specific nullability. | +| NTH_VALUE | Not implemented — W: partition/frame context and operation-specific nullability. | +| RANK / DENSE_RANK / PERCENT_RANK | Not implemented — W: partition/frame context and operation-specific nullability. | +| NTILE | Not implemented — W: partition/frame context and operation-specific nullability. | +| CUME_DIST | Not implemented — W: partition/frame context and operation-specific nullability. | +| SessionState() | Not implemented — W: partition/frame context and operation-specific nullability. | + +## Codegen + +[9 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/codegen.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| FormatCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| WorldCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| AtomCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| ListCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| FuncCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| LambdaCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| EvaluateCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| ReprCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | +| QuoteCode | Not implemented — R, L: compile-time code values and scoped code construction/evaluation. | + +## Json + +[6 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/json.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| JsonPath | Reference context, not a standalone function; SQL/JSON support is not implemented. | +| Common arguments | Reference context, not a standalone function; SQL/JSON support is not implemented. | +| JSON_EXISTS | Not implemented — J: path/RETURNING/PASSING and ON EMPTY/ERROR semantics. | +| JSON_VALUE | Not implemented — J: path/RETURNING/PASSING and ON EMPTY/ERROR semantics. | +| JSON_QUERY | Not implemented — J: path/RETURNING/PASSING and ON EMPTY/ERROR semantics. | +| See also | Reference context, not a standalone function; SQL/JSON support is not implemented. | + +## Fulltext + +[2 reference sections](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/fulltext.md). + +| Reference section | Current coverage / prerequisite | +| --- | --- | +| FulltextMatch | Not implemented — F, S: server full-text capability and argument/result rules. | +| FulltextScore | Not implemented — F, S: server full-text capability and argument/result rules. | diff --git a/examples/authors/README.md b/examples/authors/README.md index 092fa297..9b6b3fa3 100644 --- a/examples/authors/README.md +++ b/examples/authors/README.md @@ -8,4 +8,8 @@ Compared with [upstream](../README.md), IDs are explicit `Uint64` inputs rather `ListAuthorsPage` declares `$page_size AS Int` and `$offset AS Uint32`. `Int` resolves to YDB `Int32`, and the generated bindings preserve the declared `Int32` and `Uint32` parameter types. The query orders by the primary key for deterministic pages. Use nonnegative page sizes for ordinary pagination. Signed values are bound unchanged, leaving `LIMIT`/`OFFSET` evaluation to YDB. +`FindAuthorsByNamePrefix` constructs a `LIKE` pattern in YQL and returns a Boolean `has_bio` projection. `GetAuthorStatistics` counts all authors, authors with a biography, and authors whose biography is nonempty. `COUNT_IF` ignores NULL predicates; its counts remain zero on empty input. The final `CAST(COUNT(*) AS Bool)` deliberately omits `AS` to demonstrate YDB's generated result-column name. + +`GetAuthorExportMetadata` obtains the current UTC date, datetime and timestamp in YDB, exports the date/datetime as text and the timestamp as native, text and integer-microsecond values, and returns JSON export metadata. Its unaliased `COALESCE(CAST(id AS Uint32), 0)` demonstrates a fitting integer fallback: an ID outside the Uint32 range produces zero. These values are computed by YDB inside the request, without client-side clock substitution or conversion. The explicit date/datetime text conversion keeps this example within the scalar result types supported by every runtime profile. + This example covers every built-in language/runtime. Each language's build files and executable smoke tests live in its own directory. See [development](../../.agents/development.md) for generation and sequential live acceptance commands. diff --git a/examples/authors/cpp/native/models.hpp b/examples/authors/cpp/native/models.hpp index 83d832a6..5ef2208e 100644 --- a/examples/authors/cpp/native/models.hpp +++ b/examples/authors/cpp/native/models.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace authors::native { @@ -47,4 +48,29 @@ struct FindAuthorsByNameCoveringRow final { std::optional bio; }; +struct FindAuthorsByNamePrefixRow final { + std::uint64_t id; + std::string name; + std::optional bio; + bool has_bio; +}; + +struct GetAuthorStatisticsRow final { + std::uint64_t total; + std::uint64_t with_bio; + std::uint64_t with_nonempty_bio; + bool column3; +}; + +struct GetAuthorExportMetadataRow final { + std::uint64_t id; + std::string export_date; + std::string export_datetime; + TInstant export_timestamp; + std::string export_timestamp_text; + std::uint64_t export_timestamp_micros; + std::uint32_t column6; + std::optional export_metadata; +}; + } // namespace authors::native diff --git a/examples/authors/cpp/native/queries.cpp b/examples/authors/cpp/native/queries.cpp index 3969a9a0..b5c6470e 100644 --- a/examples/authors/cpp/native/queries.cpp +++ b/examples/authors/cpp/native/queries.cpp @@ -385,4 +385,163 @@ std::vector Queries::FindAuthorsByNameCovering(con return sqlc_rows; } +// -- name: FindAuthorsByNamePrefix :many +std::vector Queries::FindAuthorsByNamePrefix(const std::string& prefix) const { + std::optional sqlc_result_set; + const auto sqlc_execute = [&](NYdb::NQuery::TSession sqlc_session, const NYdb::NQuery::TTxControl& sqlc_tx) -> NYdb::TStatus { + auto sqlc_params = NYdb::TParamsBuilder() + .AddParam("$prefix").Utf8(prefix).Build() + .Build(); + auto sqlc_result = sqlc_session.ExecuteQuery( + "DECLARE $prefix AS Utf8;\n" + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + "FROM authors\n" + "WHERE name LIKE $prefix || \"%\"u\n" + "ORDER BY id;", + sqlc_tx, + sqlc_params, + this->execute_settings_ + ).GetValueSync(); + if (sqlc_result.IsSuccess()) { + if (sqlc_result.GetResultSets().size() != 1) { + throw std::runtime_error("expected exactly one result set"); + } + sqlc_result_set = sqlc_result.GetResultSet(0); + } + return sqlc_result; + }; + const auto sqlc_status = this->transaction_ != nullptr + ? sqlc_execute(this->transaction_->GetSession(), NYdb::NQuery::TTxControl::Tx(*this->transaction_)) + : this->client_->RetryQuerySync([&](NYdb::NQuery::TSession sqlc_session) -> NYdb::TStatus { + return sqlc_execute( + std::move(sqlc_session), + NYdb::NQuery::TTxControl::BeginTx(this->tx_settings_).CommitTx() + ); + }, this->retry_settings_); + NYdb::NStatusHelpers::ThrowOnError(sqlc_status); + if (!sqlc_result_set) { + throw std::runtime_error("FindAuthorsByNamePrefix: successful query returned no result set"); + } + NYdb::TResultSetParser sqlc_parser(*sqlc_result_set); + std::vector sqlc_rows; + sqlc_rows.reserve(sqlc_result_set->RowsCount()); + while (sqlc_parser.TryNextRow()) { + sqlc_rows.push_back(FindAuthorsByNamePrefixRow{ + sqlc_parser.ColumnParser("id").GetUint64(), + sqlc_parser.ColumnParser("name").GetUtf8(), + sqlc_parser.ColumnParser("bio").GetOptionalUtf8(), + sqlc_parser.ColumnParser("has_bio").GetBool(), + }); + } + return sqlc_rows; +} + +// -- name: GetAuthorStatistics :one +std::optional Queries::GetAuthorStatistics() const { + std::optional sqlc_result_set; + const auto sqlc_execute = [&](NYdb::NQuery::TSession sqlc_session, const NYdb::NQuery::TTxControl& sqlc_tx) -> NYdb::TStatus { + auto sqlc_result = sqlc_session.ExecuteQuery( + "SELECT\n" + " COUNT(*) AS total,\n" + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + " CAST(COUNT(*) AS Bool)\n" + "FROM authors;", + sqlc_tx, + this->execute_settings_ + ).GetValueSync(); + if (sqlc_result.IsSuccess()) { + if (sqlc_result.GetResultSets().size() != 1) { + throw std::runtime_error("expected exactly one result set"); + } + sqlc_result_set = sqlc_result.GetResultSet(0); + } + return sqlc_result; + }; + const auto sqlc_status = this->transaction_ != nullptr + ? sqlc_execute(this->transaction_->GetSession(), NYdb::NQuery::TTxControl::Tx(*this->transaction_)) + : this->client_->RetryQuerySync([&](NYdb::NQuery::TSession sqlc_session) -> NYdb::TStatus { + return sqlc_execute( + std::move(sqlc_session), + NYdb::NQuery::TTxControl::BeginTx(this->tx_settings_).CommitTx() + ); + }, this->retry_settings_); + NYdb::NStatusHelpers::ThrowOnError(sqlc_status); + if (!sqlc_result_set) { + throw std::runtime_error("GetAuthorStatistics: successful query returned no result set"); + } + NYdb::TResultSetParser sqlc_parser(*sqlc_result_set); + if (!sqlc_parser.TryNextRow()) { + return std::nullopt; + } + GetAuthorStatisticsRow sqlc_row{ + sqlc_parser.ColumnParser("total").GetUint64(), + sqlc_parser.ColumnParser("with_bio").GetUint64(), + sqlc_parser.ColumnParser("with_nonempty_bio").GetUint64(), + sqlc_parser.ColumnParser("column3").GetBool(), + }; + return sqlc_row; +} + +// -- name: GetAuthorExportMetadata :one +std::optional Queries::GetAuthorExportMetadata(std::uint64_t author_id) const { + std::optional sqlc_result_set; + const auto sqlc_execute = [&](NYdb::NQuery::TSession sqlc_session, const NYdb::NQuery::TTxControl& sqlc_tx) -> NYdb::TStatus { + auto sqlc_params = NYdb::TParamsBuilder() + .AddParam("$author_id").Uint64(author_id).Build() + .Build(); + auto sqlc_result = sqlc_session.ExecuteQuery( + "DECLARE $author_id AS Uint64;\n" + "SELECT\n" + " id,\n" + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + " CurrentUtcTimestamp() AS export_timestamp,\n" + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + " COALESCE(CAST(id AS Uint32), 0),\n" + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + "FROM authors\n" + "WHERE id = $author_id;", + sqlc_tx, + sqlc_params, + this->execute_settings_ + ).GetValueSync(); + if (sqlc_result.IsSuccess()) { + if (sqlc_result.GetResultSets().size() != 1) { + throw std::runtime_error("expected exactly one result set"); + } + sqlc_result_set = sqlc_result.GetResultSet(0); + } + return sqlc_result; + }; + const auto sqlc_status = this->transaction_ != nullptr + ? sqlc_execute(this->transaction_->GetSession(), NYdb::NQuery::TTxControl::Tx(*this->transaction_)) + : this->client_->RetryQuerySync([&](NYdb::NQuery::TSession sqlc_session) -> NYdb::TStatus { + return sqlc_execute( + std::move(sqlc_session), + NYdb::NQuery::TTxControl::BeginTx(this->tx_settings_).CommitTx() + ); + }, this->retry_settings_); + NYdb::NStatusHelpers::ThrowOnError(sqlc_status); + if (!sqlc_result_set) { + throw std::runtime_error("GetAuthorExportMetadata: successful query returned no result set"); + } + NYdb::TResultSetParser sqlc_parser(*sqlc_result_set); + if (!sqlc_parser.TryNextRow()) { + return std::nullopt; + } + GetAuthorExportMetadataRow sqlc_row{ + sqlc_parser.ColumnParser("id").GetUint64(), + sqlc_parser.ColumnParser("export_date").GetString(), + sqlc_parser.ColumnParser("export_datetime").GetString(), + sqlc_parser.ColumnParser("export_timestamp").GetTimestamp(), + sqlc_parser.ColumnParser("export_timestamp_text").GetString(), + sqlc_parser.ColumnParser("export_timestamp_micros").GetUint64(), + sqlc_parser.ColumnParser("column6").GetUint32(), + sqlc_parser.ColumnParser("export_metadata").GetOptionalJson(), + }; + return sqlc_row; +} + } // namespace authors::native diff --git a/examples/authors/cpp/native/queries.hpp b/examples/authors/cpp/native/queries.hpp index 081cfe7c..69b16ba5 100644 --- a/examples/authors/cpp/native/queries.hpp +++ b/examples/authors/cpp/native/queries.hpp @@ -33,6 +33,9 @@ class Queries final { void DeleteAuthor(std::uint64_t author_id) const; std::vector FindAuthorsByName(const std::string& name) const; std::vector FindAuthorsByNameCovering(const std::string& name) const; + std::vector FindAuthorsByNamePrefix(const std::string& prefix) const; + std::optional GetAuthorStatistics() const; + std::optional GetAuthorExportMetadata(std::uint64_t author_id) const; private: NYdb::NQuery::TQueryClient* client_; diff --git a/examples/authors/cpp/userver/models.hpp b/examples/authors/cpp/userver/models.hpp index edd600e5..047f4ed6 100644 --- a/examples/authors/cpp/userver/models.hpp +++ b/examples/authors/cpp/userver/models.hpp @@ -3,6 +3,9 @@ #include #include +#include +#include +#include #include @@ -48,4 +51,29 @@ struct FindAuthorsByNameCoveringRow final { std::optional<::userver::ydb::Utf8> bio; }; +struct FindAuthorsByNamePrefixRow final { + std::uint64_t id; + ::userver::ydb::Utf8 name; + std::optional<::userver::ydb::Utf8> bio; + bool has_bio; +}; + +struct GetAuthorStatisticsRow final { + std::uint64_t total; + std::uint64_t with_bio; + std::uint64_t with_nonempty_bio; + bool column3; +}; + +struct GetAuthorExportMetadataRow final { + std::uint64_t id; + std::string export_date; + std::string export_datetime; + std::chrono::system_clock::time_point export_timestamp; + std::string export_timestamp_text; + std::uint64_t export_timestamp_micros; + std::uint32_t column6; + std::optional<::userver::formats::json::Value> export_metadata; +}; + } // namespace authors::userver diff --git a/examples/authors/cpp/userver/queries.cpp b/examples/authors/cpp/userver/queries.cpp index a523060d..58378db2 100644 --- a/examples/authors/cpp/userver/queries.cpp +++ b/examples/authors/cpp/userver/queries.cpp @@ -201,4 +201,101 @@ std::vector Queries::FindAuthorsByNameCovering(con return sqlc_rows; } +// -- name: FindAuthorsByNamePrefix :many +std::vector Queries::FindAuthorsByNamePrefix(const ::userver::ydb::Utf8& prefix) const { + const auto sqlc_query = ::userver::ydb::Query{ + "DECLARE $prefix AS Utf8;\n" + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + "FROM authors\n" + "WHERE name LIKE $prefix || \"%\"u\n" + "ORDER BY id;", + ::userver::ydb::Query::Name{"FindAuthorsByNamePrefix"}, + ::userver::ydb::Query::LogMode::kNameOnly, + }; + auto sqlc_response = + this->transaction_ != nullptr + ? this->transaction_->Execute(this->execute_settings_, sqlc_query, "$prefix", prefix) + : this->client_->ExecuteQuery(this->operation_settings_, sqlc_query, "$prefix", prefix); + auto sqlc_cursor = sqlc_response.GetSingleCursor(); + std::vector sqlc_rows; + sqlc_rows.reserve(sqlc_cursor.size()); + for (auto sqlc_row : sqlc_cursor) { + sqlc_rows.push_back(FindAuthorsByNamePrefixRow{ + sqlc_row.Get("id"), + sqlc_row.Get<::userver::ydb::Utf8>("name"), + sqlc_row.Get>("bio"), + sqlc_row.Get("has_bio"), + }); + } + return sqlc_rows; +} + +// -- name: GetAuthorStatistics :one +std::optional Queries::GetAuthorStatistics() const { + const auto sqlc_query = ::userver::ydb::Query{ + "SELECT\n" + " COUNT(*) AS total,\n" + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + " CAST(COUNT(*) AS Bool)\n" + "FROM authors;", + ::userver::ydb::Query::Name{"GetAuthorStatistics"}, + ::userver::ydb::Query::LogMode::kNameOnly, + }; + auto sqlc_response = + this->transaction_ != nullptr + ? this->transaction_->Execute(this->execute_settings_, sqlc_query) + : this->client_->ExecuteQuery(this->operation_settings_, sqlc_query); + auto sqlc_cursor = sqlc_response.GetSingleCursor(); + if (sqlc_cursor.empty()) { + return std::nullopt; + } + auto sqlc_row = sqlc_cursor.GetFirstRow(); + return GetAuthorStatisticsRow{ + sqlc_row.Get("total"), + sqlc_row.Get("with_bio"), + sqlc_row.Get("with_nonempty_bio"), + sqlc_row.Get("column3"), + }; +} + +// -- name: GetAuthorExportMetadata :one +std::optional Queries::GetAuthorExportMetadata(std::uint64_t author_id) const { + const auto sqlc_query = ::userver::ydb::Query{ + "DECLARE $author_id AS Uint64;\n" + "SELECT\n" + " id,\n" + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + " CurrentUtcTimestamp() AS export_timestamp,\n" + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + " COALESCE(CAST(id AS Uint32), 0),\n" + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + "FROM authors\n" + "WHERE id = $author_id;", + ::userver::ydb::Query::Name{"GetAuthorExportMetadata"}, + ::userver::ydb::Query::LogMode::kNameOnly, + }; + auto sqlc_response = + this->transaction_ != nullptr + ? this->transaction_->Execute(this->execute_settings_, sqlc_query, "$author_id", author_id) + : this->client_->ExecuteQuery(this->operation_settings_, sqlc_query, "$author_id", author_id); + auto sqlc_cursor = sqlc_response.GetSingleCursor(); + if (sqlc_cursor.empty()) { + return std::nullopt; + } + auto sqlc_row = sqlc_cursor.GetFirstRow(); + return GetAuthorExportMetadataRow{ + sqlc_row.Get("id"), + sqlc_row.Get("export_date"), + sqlc_row.Get("export_datetime"), + sqlc_row.Get("export_timestamp"), + sqlc_row.Get("export_timestamp_text"), + sqlc_row.Get("export_timestamp_micros"), + sqlc_row.Get("column6"), + sqlc_row.Get>("export_metadata"), + }; +} + } // namespace authors::userver diff --git a/examples/authors/cpp/userver/queries.hpp b/examples/authors/cpp/userver/queries.hpp index 83cb6017..47c80f40 100644 --- a/examples/authors/cpp/userver/queries.hpp +++ b/examples/authors/cpp/userver/queries.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -30,6 +31,9 @@ class Queries final { void DeleteAuthor(std::uint64_t author_id) const; std::vector FindAuthorsByName(const ::userver::ydb::Utf8& name) const; std::vector FindAuthorsByNameCovering(const ::userver::ydb::Utf8& name) const; + std::vector FindAuthorsByNamePrefix(const ::userver::ydb::Utf8& prefix) const; + std::optional GetAuthorStatistics() const; + std::optional GetAuthorExportMetadata(std::uint64_t author_id) const; private: ::userver::ydb::TableClient* client_; diff --git a/examples/authors/csharp/adonet/Models.cs b/examples/authors/csharp/adonet/Models.cs index df30b744..56fd5eea 100644 --- a/examples/authors/csharp/adonet/Models.cs +++ b/examples/authors/csharp/adonet/Models.cs @@ -65,3 +65,28 @@ public sealed record FindAuthorsByNameCoveringRow( string Name, string? Bio ); + +public sealed record FindAuthorsByNamePrefixRow( + ulong ID, + string Name, + string? Bio, + bool HasBio +); + +public sealed record GetAuthorStatisticsRow( + ulong Total, + ulong WithBio, + ulong WithNonemptyBio, + bool Column3 +); + +public sealed record GetAuthorExportMetadataRow( + ulong ID, + byte[] ExportDate, + byte[] ExportDatetime, + DateTime ExportTimestamp, + byte[] ExportTimestampText, + ulong ExportTimestampMicros, + uint Column6, + string? ExportMetadata +); diff --git a/examples/authors/csharp/adonet/Queries.cs b/examples/authors/csharp/adonet/Queries.cs index 6b48a04b..4a4c3875 100644 --- a/examples/authors/csharp/adonet/Queries.cs +++ b/examples/authors/csharp/adonet/Queries.cs @@ -199,4 +199,91 @@ public async Task> FindAuthorsByName reader.GetFieldValue(1), reader.IsDBNull(2) ? null : reader.GetFieldValue(2) ); + + // -- name: FindAuthorsByNamePrefix :many + public async Task> FindAuthorsByNamePrefixAsync(string prefix, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DECLARE $prefix AS Utf8;\n" + + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + + "FROM authors\n" + + "WHERE name LIKE $prefix || \"%\"u\n" + + "ORDER BY id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$prefix", DbType.String, prefix)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + rows.Add(FindAuthorsByNamePrefixRowFrom(reader)); + } + return rows; + } + + private static FindAuthorsByNamePrefixRow FindAuthorsByNamePrefixRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1), + reader.IsDBNull(2) ? null : reader.GetFieldValue(2), + reader.GetFieldValue(3) + ); + + // -- name: GetAuthorStatistics :one + public async Task GetAuthorStatisticsAsync(CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "SELECT\n" + + " COUNT(*) AS total,\n" + + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + + " CAST(COUNT(*) AS Bool)\n" + + "FROM authors;", _connection) { Transaction = _transaction }; + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("query returned no rows"); + } + return GetAuthorStatisticsRowFrom(reader); + } + + private static GetAuthorStatisticsRow GetAuthorStatisticsRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1), + reader.GetFieldValue(2), + reader.GetFieldValue(3) + ); + + // -- name: GetAuthorExportMetadata :one + public async Task GetAuthorExportMetadataAsync(ulong authorId, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DECLARE $author_id AS Uint64;\n" + + "SELECT\n" + + " id,\n" + + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + + " CurrentUtcTimestamp() AS export_timestamp,\n" + + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + + " COALESCE(CAST(id AS Uint32), 0),\n" + + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + + "FROM authors\n" + + "WHERE id = $author_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$author_id", DbType.UInt64, authorId)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("query returned no rows"); + } + return GetAuthorExportMetadataRowFrom(reader); + } + + private static GetAuthorExportMetadataRow GetAuthorExportMetadataRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1), + reader.GetFieldValue(2), + reader.GetFieldValue(3), + reader.GetFieldValue(4), + reader.GetFieldValue(5), + reader.GetFieldValue(6), + reader.IsDBNull(7) ? null : reader.GetFieldValue(7) + ); } diff --git a/examples/authors/csharp/dapper/Models.cs b/examples/authors/csharp/dapper/Models.cs index 06d1cb3e..85a7604a 100644 --- a/examples/authors/csharp/dapper/Models.cs +++ b/examples/authors/csharp/dapper/Models.cs @@ -65,3 +65,28 @@ public sealed record FindAuthorsByNameCoveringRow( string Name, string? Bio ); + +public sealed record FindAuthorsByNamePrefixRow( + ulong ID, + string Name, + string? Bio, + bool HasBio +); + +public sealed record GetAuthorStatisticsRow( + ulong Total, + ulong WithBio, + ulong WithNonemptyBio, + bool Column3 +); + +public sealed record GetAuthorExportMetadataRow( + ulong ID, + byte[] ExportDate, + byte[] ExportDatetime, + DateTime ExportTimestamp, + byte[] ExportTimestampText, + ulong ExportTimestampMicros, + uint Column6, + string? ExportMetadata +); diff --git a/examples/authors/csharp/dapper/Queries.cs b/examples/authors/csharp/dapper/Queries.cs index 4c12f1f1..2a54a014 100644 --- a/examples/authors/csharp/dapper/Queries.cs +++ b/examples/authors/csharp/dapper/Queries.cs @@ -30,6 +30,46 @@ public Queries(YdbConnection connection, YdbTransaction? transaction = null) public Queries WithTransaction(YdbTransaction transaction) => new(_connection, transaction ?? throw new ArgumentNullException(nameof(transaction))); + static Queries() + { + SqlMapper.SetTypeMap(typeof(FindAuthorsByNamePrefixRow), new ColumnTypeMap(typeof(FindAuthorsByNamePrefixRow), new Dictionary + { + ["has_bio"] = nameof(FindAuthorsByNamePrefixRow.HasBio), + })); + SqlMapper.SetTypeMap(typeof(GetAuthorStatisticsRow), new ColumnTypeMap(typeof(GetAuthorStatisticsRow), new Dictionary + { + ["with_bio"] = nameof(GetAuthorStatisticsRow.WithBio), + ["with_nonempty_bio"] = nameof(GetAuthorStatisticsRow.WithNonemptyBio), + })); + SqlMapper.SetTypeMap(typeof(GetAuthorExportMetadataRow), new ColumnTypeMap(typeof(GetAuthorExportMetadataRow), new Dictionary + { + ["export_date"] = nameof(GetAuthorExportMetadataRow.ExportDate), + ["export_datetime"] = nameof(GetAuthorExportMetadataRow.ExportDatetime), + ["export_timestamp"] = nameof(GetAuthorExportMetadataRow.ExportTimestamp), + ["export_timestamp_text"] = nameof(GetAuthorExportMetadataRow.ExportTimestampText), + ["export_timestamp_micros"] = nameof(GetAuthorExportMetadataRow.ExportTimestampMicros), + ["export_metadata"] = nameof(GetAuthorExportMetadataRow.ExportMetadata), + })); + } + + private sealed class ColumnTypeMap : SqlMapper.ITypeMap + { + private readonly DefaultTypeMap _default; + private readonly IReadOnlyDictionary _columns; + + public ColumnTypeMap(Type type, IReadOnlyDictionary columns) + { + _default = new DefaultTypeMap(type); + _columns = columns; + } + + private string MemberName(string column) => _columns.TryGetValue(column, out var member) ? member : column; + public ConstructorInfo? FindConstructor(string[] names, Type[] types) => _default.FindConstructor(names.Select(MemberName).ToArray(), types); + public ConstructorInfo? FindExplicitConstructor() => _default.FindExplicitConstructor(); + public SqlMapper.IMemberMap? GetConstructorParameter(ConstructorInfo constructor, string columnName) => _default.GetConstructorParameter(constructor, MemberName(columnName)); + public SqlMapper.IMemberMap? GetMember(string columnName) => _default.GetMember(MemberName(columnName)); + } + // -- name: GetAuthor :one public async Task GetAuthorAsync(ulong authorId, CancellationToken cancellationToken = default, int? commandTimeout = null) { @@ -209,6 +249,79 @@ public async Task> FindAuthorsByName return (await _connection.QueryAsync(command).ConfigureAwait(false)).AsList(); } + // -- name: FindAuthorsByNamePrefix :many + public async Task> FindAuthorsByNamePrefixAsync(string prefix, CancellationToken cancellationToken = default, int? commandTimeout = null) + { + var parameters = new YdbParameters( + new YdbParameter("$prefix", DbType.String, prefix) + ); + + var command = new CommandDefinition( + commandText: """ + DECLARE $prefix AS Utf8; + SELECT id, name, bio, bio IS NOT NULL AS has_bio + FROM authors + WHERE name LIKE $prefix || "%"u + ORDER BY id; + """, + parameters: parameters, + transaction: _transaction, + commandTimeout: commandTimeout, + cancellationToken: cancellationToken); + + return (await _connection.QueryAsync(command).ConfigureAwait(false)).AsList(); + } + + // -- name: GetAuthorStatistics :one + public async Task GetAuthorStatisticsAsync(CancellationToken cancellationToken = default, int? commandTimeout = null) + { + var command = new CommandDefinition( + commandText: """ + SELECT + COUNT(*) AS total, + COUNT_IF(bio IS NOT NULL) AS with_bio, + COUNT_IF(bio != ""u) AS with_nonempty_bio, + CAST(COUNT(*) AS Bool) + FROM authors; + """, + parameters: null, + transaction: _transaction, + commandTimeout: commandTimeout, + cancellationToken: cancellationToken); + + return await _connection.QueryFirstAsync(command).ConfigureAwait(false); + } + + // -- name: GetAuthorExportMetadata :one + public async Task GetAuthorExportMetadataAsync(ulong authorId, CancellationToken cancellationToken = default, int? commandTimeout = null) + { + var parameters = new YdbParameters( + new YdbParameter("$author_id", DbType.UInt64, authorId) + ); + + var command = new CommandDefinition( + commandText: """ + DECLARE $author_id AS Uint64; + SELECT + id, + CAST(CurrentUtcDate() AS String) AS export_date, + CAST(CurrentUtcDatetime() AS String) AS export_datetime, + CurrentUtcTimestamp() AS export_timestamp, + CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros, + COALESCE(CAST(id AS Uint32), 0), + CAST('{"source":"authors"}' AS Json) AS export_metadata + FROM authors + WHERE id = $author_id; + """, + parameters: parameters, + transaction: _transaction, + commandTimeout: commandTimeout, + cancellationToken: cancellationToken); + + return await _connection.QueryFirstAsync(command).ConfigureAwait(false); + } + private sealed class YdbParameters : SqlMapper.IDynamicParameters { private readonly YdbParameter[] _parameters; diff --git a/examples/authors/go/database/sql/models.go b/examples/authors/go/database/sql/models.go index 6fd947d5..588a9fff 100644 --- a/examples/authors/go/database/sql/models.go +++ b/examples/authors/go/database/sql/models.go @@ -1,6 +1,8 @@ // Code generated by sqlc-ydb. DO NOT EDIT. package authors +import "time" + type GetAuthorRow struct { ID uint64 Name string @@ -57,3 +59,28 @@ type FindAuthorsByNameCoveringRow struct { Name string Bio *string } + +type FindAuthorsByNamePrefixRow struct { + ID uint64 + Name string + Bio *string + HasBio bool +} + +type GetAuthorStatisticsRow struct { + Total uint64 + WithBio uint64 + WithNonemptyBio uint64 + Column3 bool +} + +type GetAuthorExportMetadataRow struct { + ID uint64 + ExportDate []byte + ExportDatetime []byte + ExportTimestamp time.Time + ExportTimestampText []byte + ExportTimestampMicros uint64 + Column6 uint32 + ExportMetadata *string +} diff --git a/examples/authors/go/database/sql/queries.sql.go b/examples/authors/go/database/sql/queries.sql.go index 8b8d8fab..c1a53e2d 100644 --- a/examples/authors/go/database/sql/queries.sql.go +++ b/examples/authors/go/database/sql/queries.sql.go @@ -204,3 +204,90 @@ func (q *Queries) FindAuthorsByNameCovering(ctx context.Context, arg string) ([] return items, nil } + +// -- name: FindAuthorsByNamePrefix :many +func (q *Queries) FindAuthorsByNamePrefix(ctx context.Context, arg string) ([]FindAuthorsByNamePrefixRow, error) { + rows, err := q.db.QueryContext(ctx, ""+ + "DECLARE $prefix AS Utf8;\n"+ + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n"+ + "FROM authors\n"+ + "WHERE name LIKE $prefix || \"%\"u\n"+ + "ORDER BY id;", + sql.Named("prefix", arg), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []FindAuthorsByNamePrefixRow(nil) + for rows.Next() { + var row FindAuthorsByNamePrefixRow + if err := rows.Scan( + &row.ID, + &row.Name, + &row.Bio, + &row.HasBio, + ); err != nil { + return nil, err + } + items = append(items, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return items, nil +} + +// -- name: GetAuthorStatistics :one +func (q *Queries) GetAuthorStatistics(ctx context.Context) (GetAuthorStatisticsRow, error) { + var row GetAuthorStatisticsRow + err := q.db.QueryRowContext(ctx, ""+ + "SELECT\n"+ + " COUNT(*) AS total,\n"+ + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n"+ + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n"+ + " CAST(COUNT(*) AS Bool)\n"+ + "FROM authors;", + ).Scan( + &row.Total, + &row.WithBio, + &row.WithNonemptyBio, + &row.Column3, + ) + + return row, err +} + +// -- name: GetAuthorExportMetadata :one +func (q *Queries) GetAuthorExportMetadata(ctx context.Context, arg uint64) (GetAuthorExportMetadataRow, error) { + var row GetAuthorExportMetadataRow + err := q.db.QueryRowContext(ctx, ""+ + "DECLARE $author_id AS Uint64;\n"+ + "SELECT\n"+ + " id,\n"+ + " CAST(CurrentUtcDate() AS String) AS export_date,\n"+ + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n"+ + " CurrentUtcTimestamp() AS export_timestamp,\n"+ + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n"+ + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n"+ + " COALESCE(CAST(id AS Uint32), 0),\n"+ + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n"+ + "FROM authors\n"+ + "WHERE id = $author_id;", + sql.Named("author_id", arg), + ).Scan( + &row.ID, + &row.ExportDate, + &row.ExportDatetime, + &row.ExportTimestamp, + &row.ExportTimestampText, + &row.ExportTimestampMicros, + &row.Column6, + &row.ExportMetadata, + ) + + return row, err +} diff --git a/examples/authors/go/native/models.go b/examples/authors/go/native/models.go index c675ce5a..364bb1bd 100644 --- a/examples/authors/go/native/models.go +++ b/examples/authors/go/native/models.go @@ -4,6 +4,7 @@ package authors import ( "context" "github.com/ydb-platform/ydb-go-sdk/v3/query" + "time" ) type GetAuthorRow struct { @@ -63,6 +64,31 @@ type FindAuthorsByNameCoveringRow struct { Bio *string `json:"bio"` } +type FindAuthorsByNamePrefixRow struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Bio *string `json:"bio"` + HasBio bool `json:"has_bio"` +} + +type GetAuthorStatisticsRow struct { + Total uint64 `json:"total"` + WithBio uint64 `json:"with_bio"` + WithNonemptyBio uint64 `json:"with_nonempty_bio"` + Column3 bool `json:"column3"` +} + +type GetAuthorExportMetadataRow struct { + ID uint64 `json:"id"` + ExportDate []byte `json:"export_date"` + ExportDatetime []byte `json:"export_datetime"` + ExportTimestamp time.Time `json:"export_timestamp"` + ExportTimestampText []byte `json:"export_timestamp_text"` + ExportTimestampMicros uint64 `json:"export_timestamp_micros"` + Column6 uint32 `json:"column6"` + ExportMetadata *string `json:"export_metadata"` +} + type Querier interface { GetAuthor(ctx context.Context, arg uint64, opts ...query.ExecuteOption) (GetAuthorRow, error) ListAuthors(ctx context.Context, opts ...query.ExecuteOption) ([]ListAuthorsRow, error) @@ -73,4 +99,7 @@ type Querier interface { DeleteAuthor(ctx context.Context, arg uint64, opts ...query.ExecuteOption) error FindAuthorsByName(ctx context.Context, arg string, opts ...query.ExecuteOption) ([]FindAuthorsByNameRow, error) FindAuthorsByNameCovering(ctx context.Context, arg string, opts ...query.ExecuteOption) ([]FindAuthorsByNameCoveringRow, error) + FindAuthorsByNamePrefix(ctx context.Context, arg string, opts ...query.ExecuteOption) ([]FindAuthorsByNamePrefixRow, error) + GetAuthorStatistics(ctx context.Context, opts ...query.ExecuteOption) (GetAuthorStatisticsRow, error) + GetAuthorExportMetadata(ctx context.Context, arg uint64, opts ...query.ExecuteOption) (GetAuthorExportMetadataRow, error) } diff --git a/examples/authors/go/native/queries.sql.go b/examples/authors/go/native/queries.sql.go index 6fea227d..9f1f4ad2 100644 --- a/examples/authors/go/native/queries.sql.go +++ b/examples/authors/go/native/queries.sql.go @@ -335,3 +335,131 @@ func (q *Queries) FindAuthorsByNameCovering(ctx context.Context, arg string, opt return items, nil } + +// -- name: FindAuthorsByNamePrefix :many +func (q *Queries) FindAuthorsByNamePrefix(ctx context.Context, arg string, opts ...query.ExecuteOption) ([]FindAuthorsByNamePrefixRow, error) { + parameters := ydb.ParamsBuilder() + parameters = parameters.Param("$prefix").Text(arg) + + callOptions := append([]query.ExecuteOption(nil), opts...) + callOptions = append(callOptions, query.WithParameters(parameters.Build())) + + result, err := q.db.Query(ctx, ""+ + "DECLARE $prefix AS Utf8;\n"+ + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n"+ + "FROM authors\n"+ + "WHERE name LIKE $prefix || \"%\"u\n"+ + "ORDER BY id;", + callOptions..., + ) + if err != nil { + return nil, xerrors.WithStackTrace(err) + } + defer result.Close(ctx) + + resultSet, err := result.NextResultSet(ctx) + if errors.Is(err, io.EOF) { + return nil, xerrors.WithStackTrace(query.ErrNoResultSets) + } + if err != nil { + return nil, xerrors.WithStackTrace(err) + } + + items := make([]FindAuthorsByNamePrefixRow, 0) + for r, err := range resultSet.Rows(ctx) { + if err != nil { + return nil, xerrors.WithStackTrace(err) + } + var row FindAuthorsByNamePrefixRow + if err := r.ScanNamed( + query.Named("id", &row.ID), + query.Named("name", &row.Name), + query.Named("bio", &row.Bio), + query.Named("has_bio", &row.HasBio), + ); err != nil { + return nil, xerrors.WithStackTrace(err) + } + items = append(items, row) + } + + _, err = result.NextResultSet(ctx) + if err == nil { + return nil, xerrors.WithStackTrace(query.ErrMoreThanOneResultSet) + } else if !errors.Is(err, io.EOF) { + return nil, xerrors.WithStackTrace(err) + } + + return items, nil +} + +// -- name: GetAuthorStatistics :one +func (q *Queries) GetAuthorStatistics(ctx context.Context, opts ...query.ExecuteOption) (GetAuthorStatisticsRow, error) { + result, err := q.db.QueryRow(ctx, ""+ + "SELECT\n"+ + " COUNT(*) AS total,\n"+ + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n"+ + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n"+ + " CAST(COUNT(*) AS Bool)\n"+ + "FROM authors;", + opts..., + ) + if err != nil { + return GetAuthorStatisticsRow{}, xerrors.WithStackTrace(err) + } + + var row GetAuthorStatisticsRow + if err := result.ScanNamed( + query.Named("total", &row.Total), + query.Named("with_bio", &row.WithBio), + query.Named("with_nonempty_bio", &row.WithNonemptyBio), + query.Named("column3", &row.Column3), + ); err != nil { + return GetAuthorStatisticsRow{}, xerrors.WithStackTrace(err) + } + + return row, nil +} + +// -- name: GetAuthorExportMetadata :one +func (q *Queries) GetAuthorExportMetadata(ctx context.Context, arg uint64, opts ...query.ExecuteOption) (GetAuthorExportMetadataRow, error) { + parameters := ydb.ParamsBuilder() + parameters = parameters.Param("$author_id").Uint64(arg) + + callOptions := append([]query.ExecuteOption(nil), opts...) + callOptions = append(callOptions, query.WithParameters(parameters.Build())) + + result, err := q.db.QueryRow(ctx, ""+ + "DECLARE $author_id AS Uint64;\n"+ + "SELECT\n"+ + " id,\n"+ + " CAST(CurrentUtcDate() AS String) AS export_date,\n"+ + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n"+ + " CurrentUtcTimestamp() AS export_timestamp,\n"+ + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n"+ + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n"+ + " COALESCE(CAST(id AS Uint32), 0),\n"+ + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n"+ + "FROM authors\n"+ + "WHERE id = $author_id;", + callOptions..., + ) + if err != nil { + return GetAuthorExportMetadataRow{}, xerrors.WithStackTrace(err) + } + + var row GetAuthorExportMetadataRow + if err := result.ScanNamed( + query.Named("id", &row.ID), + query.Named("export_date", &row.ExportDate), + query.Named("export_datetime", &row.ExportDatetime), + query.Named("export_timestamp", &row.ExportTimestamp), + query.Named("export_timestamp_text", &row.ExportTimestampText), + query.Named("export_timestamp_micros", &row.ExportTimestampMicros), + query.Named("column6", &row.Column6), + query.Named("export_metadata", &row.ExportMetadata), + ); err != nil { + return GetAuthorExportMetadataRow{}, xerrors.WithStackTrace(err) + } + + return row, nil +} diff --git a/examples/authors/java/jdbc/src/main/java/authors/jdbc/FindAuthorsByNamePrefixRow.java b/examples/authors/java/jdbc/src/main/java/authors/jdbc/FindAuthorsByNamePrefixRow.java new file mode 100644 index 00000000..3996a0ec --- /dev/null +++ b/examples/authors/java/jdbc/src/main/java/authors/jdbc/FindAuthorsByNamePrefixRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jdbc; + +public record FindAuthorsByNamePrefixRow(long id, String name, String bio, boolean hasBio) {} diff --git a/examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorExportMetadataRow.java b/examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorExportMetadataRow.java new file mode 100644 index 00000000..fe2f2125 --- /dev/null +++ b/examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorExportMetadataRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jdbc; + +public record GetAuthorExportMetadataRow(long id, byte[] exportDate, byte[] exportDatetime, java.time.Instant exportTimestamp, byte[] exportTimestampText, long exportTimestampMicros, long column6, String exportMetadata) {} diff --git a/examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorStatisticsRow.java b/examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorStatisticsRow.java new file mode 100644 index 00000000..4119c635 --- /dev/null +++ b/examples/authors/java/jdbc/src/main/java/authors/jdbc/GetAuthorStatisticsRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jdbc; + +public record GetAuthorStatisticsRow(long total, long withBio, long withNonemptyBio, boolean column3) {} diff --git a/examples/authors/java/jdbc/src/main/java/authors/jdbc/Queries.java b/examples/authors/java/jdbc/src/main/java/authors/jdbc/Queries.java index 7d1843a7..799e09c1 100644 --- a/examples/authors/java/jdbc/src/main/java/authors/jdbc/Queries.java +++ b/examples/authors/java/jdbc/src/main/java/authors/jdbc/Queries.java @@ -164,4 +164,82 @@ public java.util.List findAuthorsByNameCovering(St } } } + + // -- name: FindAuthorsByNamePrefix :many + public java.util.List findAuthorsByNamePrefix(String prefix) throws java.sql.SQLException { + try (var _prepared = client.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $prefix AS Utf8; + SELECT id, name, bio, bio IS NOT NULL AS has_bio + FROM authors + WHERE name LIKE $prefix || \"%\"u + ORDER BY id;\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setString("prefix", prefix); + try (var _rows = _prepared.executeQuery()) { + var _items = new java.util.ArrayList(); + while (_rows.next()) { + long _value0 = _rows.getLong(1); + String _value1 = _rows.getString(2); + String _value2 = _rows.getString(3); + boolean _value3 = _rows.getBoolean(4); + _items.add(new FindAuthorsByNamePrefixRow(_value0, _value1, _value2, _value3)); + } + return _items; + } + } + } + + // -- name: GetAuthorStatistics :one + public java.util.Optional getAuthorStatistics() throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + SELECT + COUNT(*) AS total, + COUNT_IF(bio IS NOT NULL) AS with_bio, + COUNT_IF(bio != \"\"u) AS with_nonempty_bio, + CAST(COUNT(*) AS Bool) + FROM authors;\ + """)) { + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getLong(1); + long _value1 = _rows.getLong(2); + long _value2 = _rows.getLong(3); + boolean _value3 = _rows.getBoolean(4); + return java.util.Optional.of(new GetAuthorStatisticsRow(_value0, _value1, _value2, _value3)); + } + } + } + + // -- name: GetAuthorExportMetadata :one + public java.util.Optional getAuthorExportMetadata(long authorId) throws java.sql.SQLException { + try (var _prepared = client.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $author_id AS Uint64; + SELECT + id, + CAST(CurrentUtcDate() AS String) AS export_date, + CAST(CurrentUtcDatetime() AS String) AS export_datetime, + CurrentUtcTimestamp() AS export_timestamp, + CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros, + COALESCE(CAST(id AS Uint32), 0), + CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata + FROM authors + WHERE id = $author_id;\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setObject("author_id", PrimitiveValue.newUint64(authorId)); + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getLong(1); + byte[] _value1 = _rows.getBytes(2); + byte[] _value2 = _rows.getBytes(3); + var _value3Raw = _rows.getTimestamp(4); + java.time.Instant _value3 = _value3Raw == null ? null : _value3Raw.toInstant(); + byte[] _value4 = _rows.getBytes(5); + long _value5 = _rows.getLong(6); + long _value6 = _rows.getLong(7); + String _value7 = _rows.getString(8); + return java.util.Optional.of(new GetAuthorExportMetadataRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + } + } } diff --git a/examples/authors/java/jooq/FindAuthorsByNamePrefixRow.java b/examples/authors/java/jooq/FindAuthorsByNamePrefixRow.java new file mode 100644 index 00000000..4d7662cf --- /dev/null +++ b/examples/authors/java/jooq/FindAuthorsByNamePrefixRow.java @@ -0,0 +1,6 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jooq; + +import org.jooq.types.ULong; + +public record FindAuthorsByNamePrefixRow(ULong id, String name, String bio, Boolean hasBio) {} diff --git a/examples/authors/java/jooq/GetAuthorExportMetadataRow.java b/examples/authors/java/jooq/GetAuthorExportMetadataRow.java new file mode 100644 index 00000000..a25e8ff7 --- /dev/null +++ b/examples/authors/java/jooq/GetAuthorExportMetadataRow.java @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jooq; + +import java.time.Instant; +import org.jooq.JSON; +import org.jooq.types.UInteger; +import org.jooq.types.ULong; + +public record GetAuthorExportMetadataRow(ULong id, byte[] exportDate, byte[] exportDatetime, Instant exportTimestamp, byte[] exportTimestampText, ULong exportTimestampMicros, UInteger column6, JSON exportMetadata) {} diff --git a/examples/authors/java/jooq/GetAuthorStatisticsRow.java b/examples/authors/java/jooq/GetAuthorStatisticsRow.java new file mode 100644 index 00000000..feaf5709 --- /dev/null +++ b/examples/authors/java/jooq/GetAuthorStatisticsRow.java @@ -0,0 +1,6 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jooq; + +import org.jooq.types.ULong; + +public record GetAuthorStatisticsRow(ULong total, ULong withBio, ULong withNonemptyBio, Boolean column3) {} diff --git a/examples/authors/java/jooq/Queries.java b/examples/authors/java/jooq/Queries.java index 9a6175a1..ec12887e 100644 --- a/examples/authors/java/jooq/Queries.java +++ b/examples/authors/java/jooq/Queries.java @@ -28,6 +28,7 @@ public Optional getAuthor(ULong authorId) { return dsl.select(AUTHORS.ID, AUTHORS.NAME, AUTHORS.BIO) .from(AUTHORS) .where(AUTHORS.ID.eq(val(authorId, YdbTypes.UINT64))) + .coerce(field(name("id"), YdbTypes.UINT64), field(name("name"), YdbTypes.UTF8), field(name("bio"), YdbTypes.UTF8)) .fetchOptional(mapping(GetAuthorRow::new)); } @@ -36,6 +37,7 @@ public List listAuthors() { return dsl.select(AUTHORS.ID, AUTHORS.NAME, AUTHORS.BIO) .from(AUTHORS) .orderBy(AUTHORS.NAME) + .coerce(field(name("id"), YdbTypes.UINT64), field(name("name"), YdbTypes.UTF8), field(name("bio"), YdbTypes.UTF8)) .fetch(mapping(ListAuthorsRow::new)); } @@ -64,6 +66,7 @@ public Optional getAuthorName(ULong authorId) { return dsl.select(AUTHORS.NAME) .from(AUTHORS) .where(AUTHORS.ID.eq(val(authorId, YdbTypes.UINT64))) + .coerce(field(name("name"), YdbTypes.UTF8)) .fetchOptional(mapping(GetAuthorNameRow::new)); } @@ -112,6 +115,7 @@ public List findAuthorsByName(String name) { .from(table("{0} VIEW {1}", AUTHORS, name("by_name")).as("a")) .where(a.NAME.eq(val(name, YdbTypes.UTF8))) .orderBy(a.ID) + .coerce(field(name("id"), YdbTypes.UINT64), field(name("name"), YdbTypes.UTF8), field(name("bio"), YdbTypes.UTF8)) .fetch(mapping(FindAuthorsByNameRow::new)); } @@ -134,4 +138,67 @@ public List findAuthorsByNameCovering(String name) } }); } + + // -- name: FindAuthorsByNamePrefix :many + public List findAuthorsByNamePrefix(String prefix) { + return dsl.connectionResult(_connection -> { + try (var _prepared = _connection.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $prefix AS Utf8; + SELECT id, name, bio, bio IS NOT NULL AS has_bio + FROM\s\ + """ + dsl.render(AUTHORS) + " AS `authors`" + """ + + WHERE name LIKE $prefix || \"%\"u + ORDER BY id;\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setString("prefix", prefix); + try (var _rows = _prepared.executeQuery()) { + var _result = dsl.fetch(_rows, YdbTypes.UINT64, YdbTypes.UTF8, YdbTypes.UTF8, YdbTypes.BOOL).map(_row -> new FindAuthorsByNamePrefixRow(_row.get(0, org.jooq.types.ULong.class), _row.get(1, String.class), _row.get(2, String.class), _row.get(3, Boolean.class))); + return _result; + } + } + }); + } + + // -- name: GetAuthorStatistics :one + public Optional getAuthorStatistics() { + return dsl.select( + count().coerce(YdbTypes.UINT64).as("total"), + function(systemName("COUNT_IF"), YdbTypes.UINT64, AUTHORS.BIO.isNotNull()).as("with_bio"), + function(systemName("COUNT_IF"), YdbTypes.UINT64, AUTHORS.BIO.ne(inline("", YdbTypes.UTF8))).as("with_nonempty_bio"), + count().coerce(YdbTypes.UINT64).cast(YdbTypes.BOOL) + ) + .from(AUTHORS) + .coerce(field(name("total"), YdbTypes.UINT64), field(name("with_bio"), YdbTypes.UINT64), field(name("with_nonempty_bio"), YdbTypes.UINT64), field(name("column3"), YdbTypes.BOOL)) + .fetchOptional(mapping(GetAuthorStatisticsRow::new)); + } + + // -- name: GetAuthorExportMetadata :one + public Optional getAuthorExportMetadata(ULong authorId) { + return dsl.connectionResult(_connection -> { + try (var _prepared = _connection.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $author_id AS Uint64; + SELECT + id, + CAST(CurrentUtcDate() AS String) AS export_date, + CAST(CurrentUtcDatetime() AS String) AS export_datetime, + CurrentUtcTimestamp() AS export_timestamp, + CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros, + COALESCE(CAST(id AS Uint32), 0), + CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata + FROM\s\ + """ + dsl.render(AUTHORS) + " AS `authors`" + """ + + WHERE id = $author_id;\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setObject("author_id", tech.ydb.table.values.PrimitiveValue.newUint64(authorId.longValue())); + try (var _rows = _prepared.executeQuery()) { + var _result = dsl.fetch(_rows, YdbTypes.UINT64, YdbTypes.STRING, YdbTypes.STRING, YdbTypes.TIMESTAMP, YdbTypes.STRING, YdbTypes.UINT64, YdbTypes.UINT32, YdbTypes.JSON).map(_row -> new GetAuthorExportMetadataRow(_row.get(0, org.jooq.types.ULong.class), _row.get(1, byte[].class), _row.get(2, byte[].class), _row.get(3, java.time.Instant.class), _row.get(4, byte[].class), _row.get(5, org.jooq.types.ULong.class), _row.get(6, org.jooq.types.UInteger.class), _row.get(7, org.jooq.JSON.class))); + if (_result.size() > 1) throw new org.jooq.exception.TooManyRowsException("Expected at most one row"); + return _result.stream().findFirst(); + } + } + }); + } } diff --git a/examples/authors/java/native/src/main/java/authors/nativeapi/FindAuthorsByNamePrefixRow.java b/examples/authors/java/native/src/main/java/authors/nativeapi/FindAuthorsByNamePrefixRow.java new file mode 100644 index 00000000..09f57ca3 --- /dev/null +++ b/examples/authors/java/native/src/main/java/authors/nativeapi/FindAuthorsByNamePrefixRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.nativeapi; + +public record FindAuthorsByNamePrefixRow(long id, String name, String bio, boolean hasBio) {} diff --git a/examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorExportMetadataRow.java b/examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorExportMetadataRow.java new file mode 100644 index 00000000..210a0235 --- /dev/null +++ b/examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorExportMetadataRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.nativeapi; + +public record GetAuthorExportMetadataRow(long id, byte[] exportDate, byte[] exportDatetime, java.time.Instant exportTimestamp, byte[] exportTimestampText, long exportTimestampMicros, long column6, String exportMetadata) {} diff --git a/examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorStatisticsRow.java b/examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorStatisticsRow.java new file mode 100644 index 00000000..35f373d2 --- /dev/null +++ b/examples/authors/java/native/src/main/java/authors/nativeapi/GetAuthorStatisticsRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.nativeapi; + +public record GetAuthorStatisticsRow(long total, long withBio, long withNonemptyBio, boolean column3) {} diff --git a/examples/authors/java/native/src/main/java/authors/nativeapi/Queries.java b/examples/authors/java/native/src/main/java/authors/nativeapi/Queries.java index e589449e..9289b41d 100644 --- a/examples/authors/java/native/src/main/java/authors/nativeapi/Queries.java +++ b/examples/authors/java/native/src/main/java/authors/nativeapi/Queries.java @@ -174,4 +174,84 @@ public java.util.List findAuthorsByNameCovering(St } return _items; } + + // -- name: FindAuthorsByNamePrefix :many + public java.util.List findAuthorsByNamePrefix(String prefix) { + var _params = Params.create(); + _params.put("$prefix", PrimitiveValue.newText(prefix)); + var _query = QueryReader.readFrom( + client.createQuery(""" + DECLARE $prefix AS Utf8; + SELECT id, name, bio, bio IS NOT NULL AS has_bio + FROM authors + WHERE name LIKE $prefix || \"%\"u + ORDER BY id;\ + """, _params)).join().getValue(); + if (_query.getResultSetCount() != 1) throw new IllegalStateException("Expected one result set"); + var _rows = _query.getResultSet(0); + var _items = new java.util.ArrayList(); + while (_rows.next()) { + long _value0 = _rows.getColumn(0).getUint64(); + String _value1 = _rows.getColumn(1).getText(); + String _value2 = _rows.getColumn(2).getText(); + boolean _value3 = _rows.getColumn(3).getBool(); + _items.add(new FindAuthorsByNamePrefixRow(_value0, _value1, _value2, _value3)); + } + return _items; + } + + // -- name: GetAuthorStatistics :one + public java.util.Optional getAuthorStatistics() { + var _params = Params.create(); + var _query = QueryReader.readFrom( + client.createQuery(""" + SELECT + COUNT(*) AS total, + COUNT_IF(bio IS NOT NULL) AS with_bio, + COUNT_IF(bio != \"\"u) AS with_nonempty_bio, + CAST(COUNT(*) AS Bool) + FROM authors;\ + """, _params)).join().getValue(); + if (_query.getResultSetCount() != 1) throw new IllegalStateException("Expected one result set"); + var _rows = _query.getResultSet(0); + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getColumn(0).getUint64(); + long _value1 = _rows.getColumn(1).getUint64(); + long _value2 = _rows.getColumn(2).getUint64(); + boolean _value3 = _rows.getColumn(3).getBool(); + return java.util.Optional.of(new GetAuthorStatisticsRow(_value0, _value1, _value2, _value3)); + } + + // -- name: GetAuthorExportMetadata :one + public java.util.Optional getAuthorExportMetadata(long authorId) { + var _params = Params.create(); + _params.put("$author_id", PrimitiveValue.newUint64(authorId)); + var _query = QueryReader.readFrom( + client.createQuery(""" + DECLARE $author_id AS Uint64; + SELECT + id, + CAST(CurrentUtcDate() AS String) AS export_date, + CAST(CurrentUtcDatetime() AS String) AS export_datetime, + CurrentUtcTimestamp() AS export_timestamp, + CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros, + COALESCE(CAST(id AS Uint32), 0), + CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata + FROM authors + WHERE id = $author_id;\ + """, _params)).join().getValue(); + if (_query.getResultSetCount() != 1) throw new IllegalStateException("Expected one result set"); + var _rows = _query.getResultSet(0); + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getColumn(0).getUint64(); + byte[] _value1 = _rows.getColumn(1).getBytes(); + byte[] _value2 = _rows.getColumn(2).getBytes(); + java.time.Instant _value3 = _rows.getColumn(3).getTimestamp(); + byte[] _value4 = _rows.getColumn(4).getBytes(); + long _value5 = _rows.getColumn(5).getUint64(); + long _value6 = _rows.getColumn(6).getUint32(); + String _value7 = _rows.getColumn(7).getJson(); + return java.util.Optional.of(new GetAuthorExportMetadataRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } } diff --git a/examples/authors/kotlin/exposed/FindAuthorsByNamePrefixRow.kt b/examples/authors/kotlin/exposed/FindAuthorsByNamePrefixRow.kt new file mode 100644 index 00000000..487d6ab2 --- /dev/null +++ b/examples/authors/kotlin/exposed/FindAuthorsByNamePrefixRow.kt @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.exposed + +data class FindAuthorsByNamePrefixRow( + val id: Long, + val name: String, + val bio: String?, + val hasBio: Boolean +) diff --git a/examples/authors/kotlin/exposed/GetAuthorExportMetadataRow.kt b/examples/authors/kotlin/exposed/GetAuthorExportMetadataRow.kt new file mode 100644 index 00000000..a956fa3c --- /dev/null +++ b/examples/authors/kotlin/exposed/GetAuthorExportMetadataRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.exposed + +data class GetAuthorExportMetadataRow( + val id: Long, + val exportDate: ByteArray, + val exportDatetime: ByteArray, + val exportTimestamp: java.time.Instant, + val exportTimestampText: ByteArray, + val exportTimestampMicros: Long, + val column6: Long, + val exportMetadata: String? +) diff --git a/examples/authors/kotlin/exposed/GetAuthorStatisticsRow.kt b/examples/authors/kotlin/exposed/GetAuthorStatisticsRow.kt new file mode 100644 index 00000000..1b2fae18 --- /dev/null +++ b/examples/authors/kotlin/exposed/GetAuthorStatisticsRow.kt @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.exposed + +data class GetAuthorStatisticsRow( + val total: Long, + val withBio: Long, + val withNonemptyBio: Long, + val column3: Boolean +) diff --git a/examples/authors/kotlin/exposed/Queries.kt b/examples/authors/kotlin/exposed/Queries.kt index 89b42a44..94641054 100644 --- a/examples/authors/kotlin/exposed/Queries.kt +++ b/examples/authors/kotlin/exposed/Queries.kt @@ -161,4 +161,81 @@ class Queries(private val client: org.jetbrains.exposed.v1.jdbc.JdbcTransaction) } } } + + // -- name: FindAuthorsByNamePrefix :many + fun findAuthorsByNamePrefix(prefix: String): List { + val _connection = client.connection.connection as java.sql.Connection + _connection.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$prefix AS Utf8;\n" + + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + + "FROM authors\n" + + "WHERE name LIKE \$prefix || \"%\"u\n" + + "ORDER BY id;", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setString("prefix", prefix) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + val _value2: String? = _rows.getString(3) + val _value3: Boolean = _rows.getBoolean(4) + _items.add(FindAuthorsByNamePrefixRow(_value0, _value1, _value2, _value3)) + } + return _items + } + } + } + + // -- name: GetAuthorStatistics :one + fun getAuthorStatistics(): GetAuthorStatisticsRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "SELECT\n" + + " COUNT(*) AS total,\n" + + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + + " CAST(COUNT(*) AS Bool)\n" + + "FROM authors;").use { _prepared -> + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: Long = _rows.getLong(3) + val _value3: Boolean = _rows.getBoolean(4) + return GetAuthorStatisticsRow(_value0, _value1, _value2, _value3) + } + } + } + + // -- name: GetAuthorExportMetadata :one + fun getAuthorExportMetadata(authorId: Long): GetAuthorExportMetadataRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$author_id AS Uint64;\n" + + "SELECT\n" + + " id,\n" + + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + + " CurrentUtcTimestamp() AS export_timestamp,\n" + + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + + " COALESCE(CAST(id AS Uint32), 0),\n" + + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + + "FROM authors\n" + + "WHERE id = \$author_id;", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setObject("author_id", PrimitiveValue.newUint64(authorId)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: ByteArray = _rows.getBytes(2) + val _value2: ByteArray = _rows.getBytes(3) + val _value3: java.time.Instant = _rows.getTimestamp(4).toInstant() + val _value4: ByteArray = _rows.getBytes(5) + val _value5: Long = _rows.getLong(6) + val _value6: Long = _rows.getLong(7) + val _value7: String? = _rows.getString(8) + return GetAuthorExportMetadataRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } + } + } } diff --git a/examples/authors/kotlin/jdbc/FindAuthorsByNamePrefixRow.kt b/examples/authors/kotlin/jdbc/FindAuthorsByNamePrefixRow.kt new file mode 100644 index 00000000..a47e0035 --- /dev/null +++ b/examples/authors/kotlin/jdbc/FindAuthorsByNamePrefixRow.kt @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jdbc + +data class FindAuthorsByNamePrefixRow( + val id: Long, + val name: String, + val bio: String?, + val hasBio: Boolean +) diff --git a/examples/authors/kotlin/jdbc/GetAuthorExportMetadataRow.kt b/examples/authors/kotlin/jdbc/GetAuthorExportMetadataRow.kt new file mode 100644 index 00000000..335e302a --- /dev/null +++ b/examples/authors/kotlin/jdbc/GetAuthorExportMetadataRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jdbc + +data class GetAuthorExportMetadataRow( + val id: Long, + val exportDate: ByteArray, + val exportDatetime: ByteArray, + val exportTimestamp: java.time.Instant, + val exportTimestampText: ByteArray, + val exportTimestampMicros: Long, + val column6: Long, + val exportMetadata: String? +) diff --git a/examples/authors/kotlin/jdbc/GetAuthorStatisticsRow.kt b/examples/authors/kotlin/jdbc/GetAuthorStatisticsRow.kt new file mode 100644 index 00000000..e2930925 --- /dev/null +++ b/examples/authors/kotlin/jdbc/GetAuthorStatisticsRow.kt @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.jdbc + +data class GetAuthorStatisticsRow( + val total: Long, + val withBio: Long, + val withNonemptyBio: Long, + val column3: Boolean +) diff --git a/examples/authors/kotlin/jdbc/Queries.kt b/examples/authors/kotlin/jdbc/Queries.kt index 528f9834..97ea657f 100644 --- a/examples/authors/kotlin/jdbc/Queries.kt +++ b/examples/authors/kotlin/jdbc/Queries.kt @@ -152,4 +152,78 @@ class Queries(private val client: java.sql.Connection) { } } } + + // -- name: FindAuthorsByNamePrefix :many + fun findAuthorsByNamePrefix(prefix: String): List { + client.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$prefix AS Utf8;\n" + + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + + "FROM authors\n" + + "WHERE name LIKE \$prefix || \"%\"u\n" + + "ORDER BY id;", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setString("prefix", prefix) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + val _value2: String? = _rows.getString(3) + val _value3: Boolean = _rows.getBoolean(4) + _items.add(FindAuthorsByNamePrefixRow(_value0, _value1, _value2, _value3)) + } + return _items + } + } + } + + // -- name: GetAuthorStatistics :one + fun getAuthorStatistics(): GetAuthorStatisticsRow? { + client.prepareStatement( + "SELECT\n" + + " COUNT(*) AS total,\n" + + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + + " CAST(COUNT(*) AS Bool)\n" + + "FROM authors;").use { _prepared -> + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: Long = _rows.getLong(3) + val _value3: Boolean = _rows.getBoolean(4) + return GetAuthorStatisticsRow(_value0, _value1, _value2, _value3) + } + } + } + + // -- name: GetAuthorExportMetadata :one + fun getAuthorExportMetadata(authorId: Long): GetAuthorExportMetadataRow? { + client.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$author_id AS Uint64;\n" + + "SELECT\n" + + " id,\n" + + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + + " CurrentUtcTimestamp() AS export_timestamp,\n" + + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + + " COALESCE(CAST(id AS Uint32), 0),\n" + + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + + "FROM authors\n" + + "WHERE id = \$author_id;", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setObject("author_id", PrimitiveValue.newUint64(authorId)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: ByteArray = _rows.getBytes(2) + val _value2: ByteArray = _rows.getBytes(3) + val _value3: java.time.Instant = _rows.getTimestamp(4).toInstant() + val _value4: ByteArray = _rows.getBytes(5) + val _value5: Long = _rows.getLong(6) + val _value6: Long = _rows.getLong(7) + val _value7: String? = _rows.getString(8) + return GetAuthorExportMetadataRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } + } + } } diff --git a/examples/authors/kotlin/nativeapi/FindAuthorsByNamePrefixRow.kt b/examples/authors/kotlin/nativeapi/FindAuthorsByNamePrefixRow.kt new file mode 100644 index 00000000..7f61feca --- /dev/null +++ b/examples/authors/kotlin/nativeapi/FindAuthorsByNamePrefixRow.kt @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.nativeapi + +data class FindAuthorsByNamePrefixRow( + val id: Long, + val name: String, + val bio: String?, + val hasBio: Boolean +) diff --git a/examples/authors/kotlin/nativeapi/GetAuthorExportMetadataRow.kt b/examples/authors/kotlin/nativeapi/GetAuthorExportMetadataRow.kt new file mode 100644 index 00000000..4899c5bc --- /dev/null +++ b/examples/authors/kotlin/nativeapi/GetAuthorExportMetadataRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.nativeapi + +data class GetAuthorExportMetadataRow( + val id: Long, + val exportDate: ByteArray, + val exportDatetime: ByteArray, + val exportTimestamp: java.time.Instant, + val exportTimestampText: ByteArray, + val exportTimestampMicros: Long, + val column6: Long, + val exportMetadata: String? +) diff --git a/examples/authors/kotlin/nativeapi/GetAuthorStatisticsRow.kt b/examples/authors/kotlin/nativeapi/GetAuthorStatisticsRow.kt new file mode 100644 index 00000000..81acadcc --- /dev/null +++ b/examples/authors/kotlin/nativeapi/GetAuthorStatisticsRow.kt @@ -0,0 +1,9 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package authors.nativeapi + +data class GetAuthorStatisticsRow( + val total: Long, + val withBio: Long, + val withNonemptyBio: Long, + val column3: Boolean +) diff --git a/examples/authors/kotlin/nativeapi/Queries.kt b/examples/authors/kotlin/nativeapi/Queries.kt index c6454baa..c5de3f67 100644 --- a/examples/authors/kotlin/nativeapi/Queries.kt +++ b/examples/authors/kotlin/nativeapi/Queries.kt @@ -237,4 +237,119 @@ class Queries { } return _items } + + // -- name: FindAuthorsByNamePrefix :many + fun findAuthorsByNamePrefix(prefix: String): List { + val _params = Params.create() + _params.put("\$prefix", PrimitiveValue.newText(prefix)) + val _query = if (transaction != null) { + QueryReader.readFrom(transaction.createQuery( + "DECLARE \$prefix AS Utf8;\n" + + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + + "FROM authors\n" + + "WHERE name LIKE \$prefix || \"%\"u\n" + + "ORDER BY id;", _params)).join().getValue() + } else { + client!!.supplyResult { _session -> + QueryReader.readFrom(_session.createQuery( + "DECLARE \$prefix AS Utf8;\n" + + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + + "FROM authors\n" + + "WHERE name LIKE \$prefix || \"%\"u\n" + + "ORDER BY id;", TxMode.SERIALIZABLE_RW, _params)) + }.join().getValue() + } + kotlin.check(_query.getResultSetCount() == 1) { "Expected one result set" } + val _rows = _query.getResultSet(0) + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getColumn(0).getUint64() + val _value1: String = _rows.getColumn(1).getText() + val _value2: String? = _rows.getColumn(2).getText() + val _value3: Boolean = _rows.getColumn(3).getBool() + _items.add(FindAuthorsByNamePrefixRow(_value0, _value1, _value2, _value3)) + } + return _items + } + + // -- name: GetAuthorStatistics :one + fun getAuthorStatistics(): GetAuthorStatisticsRow? { + val _params = Params.create() + val _query = if (transaction != null) { + QueryReader.readFrom(transaction.createQuery( + "SELECT\n" + + " COUNT(*) AS total,\n" + + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + + " CAST(COUNT(*) AS Bool)\n" + + "FROM authors;", _params)).join().getValue() + } else { + client!!.supplyResult { _session -> + QueryReader.readFrom(_session.createQuery( + "SELECT\n" + + " COUNT(*) AS total,\n" + + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + + " CAST(COUNT(*) AS Bool)\n" + + "FROM authors;", TxMode.SERIALIZABLE_RW, _params)) + }.join().getValue() + } + kotlin.check(_query.getResultSetCount() == 1) { "Expected one result set" } + val _rows = _query.getResultSet(0) + if (!_rows.next()) return null + val _value0: Long = _rows.getColumn(0).getUint64() + val _value1: Long = _rows.getColumn(1).getUint64() + val _value2: Long = _rows.getColumn(2).getUint64() + val _value3: Boolean = _rows.getColumn(3).getBool() + return GetAuthorStatisticsRow(_value0, _value1, _value2, _value3) + } + + // -- name: GetAuthorExportMetadata :one + fun getAuthorExportMetadata(authorId: Long): GetAuthorExportMetadataRow? { + val _params = Params.create() + _params.put("\$author_id", PrimitiveValue.newUint64(authorId)) + val _query = if (transaction != null) { + QueryReader.readFrom(transaction.createQuery( + "DECLARE \$author_id AS Uint64;\n" + + "SELECT\n" + + " id,\n" + + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + + " CurrentUtcTimestamp() AS export_timestamp,\n" + + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + + " COALESCE(CAST(id AS Uint32), 0),\n" + + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + + "FROM authors\n" + + "WHERE id = \$author_id;", _params)).join().getValue() + } else { + client!!.supplyResult { _session -> + QueryReader.readFrom(_session.createQuery( + "DECLARE \$author_id AS Uint64;\n" + + "SELECT\n" + + " id,\n" + + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + + " CurrentUtcTimestamp() AS export_timestamp,\n" + + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + + " COALESCE(CAST(id AS Uint32), 0),\n" + + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + + "FROM authors\n" + + "WHERE id = \$author_id;", TxMode.SERIALIZABLE_RW, _params)) + }.join().getValue() + } + kotlin.check(_query.getResultSetCount() == 1) { "Expected one result set" } + val _rows = _query.getResultSet(0) + if (!_rows.next()) return null + val _value0: Long = _rows.getColumn(0).getUint64() + val _value1: ByteArray = _rows.getColumn(1).getBytes() + val _value2: ByteArray = _rows.getColumn(2).getBytes() + val _value3: java.time.Instant = _rows.getColumn(3).getTimestamp() + val _value4: ByteArray = _rows.getColumn(4).getBytes() + val _value5: Long = _rows.getColumn(5).getUint64() + val _value6: Long = _rows.getColumn(6).getUint32() + val _value7: String? = _rows.getColumn(7).getJson() + return GetAuthorExportMetadataRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } } diff --git a/examples/authors/php/native/FindAuthorsByNamePrefixRow.php b/examples/authors/php/native/FindAuthorsByNamePrefixRow.php new file mode 100644 index 00000000..00b09de2 --- /dev/null +++ b/examples/authors/php/native/FindAuthorsByNamePrefixRow.php @@ -0,0 +1,15 @@ + */ + // -- name: FindAuthorsByNamePrefix :many + public function findAuthorsByNamePrefix(string $prefix): array + { + $parameters = [ + '$prefix' => YdbValueCodec::typedUtf8($prefix, 'prefix'), + ]; + + $result = $this->execute(function (Session $session) use ($parameters): ExecuteQueryResult { + $query = $session->newQuery(<<<'SQLC_YDB_YQL' + DECLARE $prefix AS Utf8; + SELECT id, name, bio, bio IS NOT NULL AS has_bio + FROM authors + WHERE name LIKE $prefix || "%"u + ORDER BY id; + SQLC_YDB_YQL) + ->parameters($parameters) + ->keepInCache(count($parameters) > 0); + if ($this->txId !== null) { + $query->txControl(new TransactionControl(['tx_id' => $this->txId])); + $txControl = $query->getRequestData()['tx_control']->serializeToString(); + } else { + $query->beginTx('serializable_read_write'); + } + if ($this->configure !== null) { + ($this->configure)($query); + } + if ($this->txId !== null && $query->getRequestData()['tx_control']->serializeToString() !== $txControl) { + throw new \LogicException('configure must not change transaction control on a transaction-bound Queries'); + } + + return (new YdbRawExecutor($this->table))->execute($session, $query, $this->txId === null); + }); + + $rows = $this->decodeRows( + $result, + 'FindAuthorsByNamePrefix', + [ + ['id', PrimitiveTypeId::UINT64, false], + ['name', PrimitiveTypeId::UTF8, false], + ['bio', PrimitiveTypeId::UTF8, true], + ['has_bio', PrimitiveTypeId::BOOL, false], + ], + static fn($items): FindAuthorsByNamePrefixRow => new FindAuthorsByNamePrefixRow( + YdbValueCodec::uint64($items->offsetGet(0), 'FindAuthorsByNamePrefix.id'), + YdbValueCodec::utf8($items->offsetGet(1), 'FindAuthorsByNamePrefix.name'), + YdbValueCodec::optionalUtf8($items->offsetGet(2), 'FindAuthorsByNamePrefix.bio'), + YdbValueCodec::bool($items->offsetGet(3), 'FindAuthorsByNamePrefix.has_bio'), + ), + ); + + return $rows; + } + + // -- name: GetAuthorStatistics :one + public function getAuthorStatistics(): ?GetAuthorStatisticsRow + { + $parameters = [ + ]; + + $result = $this->execute(function (Session $session) use ($parameters): ExecuteQueryResult { + $query = $session->newQuery(<<<'SQLC_YDB_YQL' + SELECT + COUNT(*) AS total, + COUNT_IF(bio IS NOT NULL) AS with_bio, + COUNT_IF(bio != ""u) AS with_nonempty_bio, + CAST(COUNT(*) AS Bool) + FROM authors; + SQLC_YDB_YQL) + ->parameters($parameters) + ->keepInCache(count($parameters) > 0); + if ($this->txId !== null) { + $query->txControl(new TransactionControl(['tx_id' => $this->txId])); + $txControl = $query->getRequestData()['tx_control']->serializeToString(); + } else { + $query->beginTx('serializable_read_write'); + } + if ($this->configure !== null) { + ($this->configure)($query); + } + if ($this->txId !== null && $query->getRequestData()['tx_control']->serializeToString() !== $txControl) { + throw new \LogicException('configure must not change transaction control on a transaction-bound Queries'); + } + + return (new YdbRawExecutor($this->table))->execute($session, $query, $this->txId === null); + }); + + $rows = $this->decodeRows( + $result, + 'GetAuthorStatistics', + [ + ['total', PrimitiveTypeId::UINT64, false], + ['with_bio', PrimitiveTypeId::UINT64, false], + ['with_nonempty_bio', PrimitiveTypeId::UINT64, false], + ['column3', PrimitiveTypeId::BOOL, false], + ], + static fn($items): GetAuthorStatisticsRow => new GetAuthorStatisticsRow( + YdbValueCodec::uint64($items->offsetGet(0), 'GetAuthorStatistics.total'), + YdbValueCodec::uint64($items->offsetGet(1), 'GetAuthorStatistics.with_bio'), + YdbValueCodec::uint64($items->offsetGet(2), 'GetAuthorStatistics.with_nonempty_bio'), + YdbValueCodec::bool($items->offsetGet(3), 'GetAuthorStatistics.column3'), + ), + ); + + return $rows[0] ?? null; + } + + // -- name: GetAuthorExportMetadata :one + public function getAuthorExportMetadata(string $authorId): ?GetAuthorExportMetadataRow + { + $parameters = [ + '$author_id' => YdbValueCodec::typedUint64($authorId, 'author_id'), + ]; + + $result = $this->execute(function (Session $session) use ($parameters): ExecuteQueryResult { + $query = $session->newQuery(<<<'SQLC_YDB_YQL' + DECLARE $author_id AS Uint64; + SELECT + id, + CAST(CurrentUtcDate() AS String) AS export_date, + CAST(CurrentUtcDatetime() AS String) AS export_datetime, + CurrentUtcTimestamp() AS export_timestamp, + CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros, + COALESCE(CAST(id AS Uint32), 0), + CAST('{"source":"authors"}' AS Json) AS export_metadata + FROM authors + WHERE id = $author_id; + SQLC_YDB_YQL) + ->parameters($parameters) + ->keepInCache(count($parameters) > 0); + if ($this->txId !== null) { + $query->txControl(new TransactionControl(['tx_id' => $this->txId])); + $txControl = $query->getRequestData()['tx_control']->serializeToString(); + } else { + $query->beginTx('serializable_read_write'); + } + if ($this->configure !== null) { + ($this->configure)($query); + } + if ($this->txId !== null && $query->getRequestData()['tx_control']->serializeToString() !== $txControl) { + throw new \LogicException('configure must not change transaction control on a transaction-bound Queries'); + } + + return (new YdbRawExecutor($this->table))->execute($session, $query, $this->txId === null); + }); + + $rows = $this->decodeRows( + $result, + 'GetAuthorExportMetadata', + [ + ['id', PrimitiveTypeId::UINT64, false], + ['export_date', PrimitiveTypeId::STRING, false], + ['export_datetime', PrimitiveTypeId::STRING, false], + ['export_timestamp', PrimitiveTypeId::TIMESTAMP, false], + ['export_timestamp_text', PrimitiveTypeId::STRING, false], + ['export_timestamp_micros', PrimitiveTypeId::UINT64, false], + ['column6', PrimitiveTypeId::UINT32, false], + ['export_metadata', PrimitiveTypeId::JSON, true], + ], + static fn($items): GetAuthorExportMetadataRow => new GetAuthorExportMetadataRow( + YdbValueCodec::uint64($items->offsetGet(0), 'GetAuthorExportMetadata.id'), + YdbValueCodec::bytes($items->offsetGet(1), 'GetAuthorExportMetadata.export_date'), + YdbValueCodec::bytes($items->offsetGet(2), 'GetAuthorExportMetadata.export_datetime'), + YdbValueCodec::timestamp($items->offsetGet(3), 'GetAuthorExportMetadata.export_timestamp'), + YdbValueCodec::bytes($items->offsetGet(4), 'GetAuthorExportMetadata.export_timestamp_text'), + YdbValueCodec::uint64($items->offsetGet(5), 'GetAuthorExportMetadata.export_timestamp_micros'), + YdbValueCodec::uint32($items->offsetGet(6), 'GetAuthorExportMetadata.column6'), + YdbValueCodec::optionalJson($items->offsetGet(7), 'GetAuthorExportMetadata.export_metadata'), + ), + ); + + return $rows[0] ?? null; + } + /** * @param array $expectedColumns * @return list diff --git a/examples/authors/php/native/YdbRuntime.php b/examples/authors/php/native/YdbRuntime.php index cda506ba..837dc52a 100644 --- a/examples/authors/php/native/YdbRuntime.php +++ b/examples/authors/php/native/YdbRuntime.php @@ -110,6 +110,21 @@ public static function typedOptionalUtf8(?string $value, string $where): TypedVa return self::typedOptional($value === null ? null : self::typedUtf8($value, $where), PrimitiveTypeId::UTF8); } + public static function bool(Value $value, string $where): bool + { + $raw = self::read($value, 'bool_value', 'getBoolValue', $where); + if (!is_bool($raw)) { + throw new UnexpectedValueException($where . ': invalid Bool value'); + } + + return $raw; + } + + public static function uint32(Value $value, string $where): int + { + return self::decodedInt($value, 'uint32_value', 'getUint32Value', 0, 4294967295, $where); + } + public static function uint64(Value $value, string $where): string { $raw = self::read($value, 'uint64_value', 'getUint64Value', $where); @@ -128,11 +143,36 @@ public static function utf8(Value $value, string $where): string return $raw; } + public static function bytes(Value $value, string $where): string + { + return self::decodedString($value, 'bytes_value', 'getBytesValue', $where); + } + + public static function json(Value $value, string $where): string + { + $raw = self::decodedString($value, 'text_value', 'getTextValue', $where); + self::validJson($raw, $where); + + return $raw; + } + + public static function timestamp(Value $value, string $where): int + { + $raw = self::uint64($value, $where); + if (bccomp($raw, self::TIMESTAMP_MAX_MICROSECONDS, 0) > 0) throw new UnexpectedValueException($where . ': value is outside YQL Timestamp range'); + return (int) $raw; + } + public static function optionalUtf8(Value $value, string $where): ?string { return self::isNull($value) ? null : self::utf8($value, $where); } + public static function optionalJson(Value $value, string $where): ?string + { + return self::isNull($value) ? null : self::json($value, $where); + } + private static function typed(int $typeId, string $case, mixed $value): TypedValue { return new TypedValue(['type' => self::type($typeId), 'value' => new Value([$case => $value])]); @@ -172,6 +212,11 @@ private static function validUtf8(string $value, string $where): void } } + private static function validJson(string $value, string $where): void + { + try { json_decode($value, false, 512, JSON_THROW_ON_ERROR); } + catch (\JsonException $error) { throw new \InvalidArgumentException($where . ': value is not valid JSON', 0, $error); } + } private static function validateUint64(string $value, string $where): void { if (!preg_match('/^(0|[1-9][0-9]*)$/D', $value) || bccomp($value, self::UINT64_MAX, 0) > 0) { @@ -194,6 +239,12 @@ private static function isNull(Value $value): bool return $value->getValue() === 'null_flag_value'; } + private static function decodedInt(Value $value, string $case, string $getter, int $min, int $max, string $where): int + { + $raw = self::read($value, $case, $getter, $where); + if (!is_int($raw) || $raw < $min || $raw > $max) throw new UnexpectedValueException($where . ': invalid integer value'); + return $raw; + } private static function decodedString(Value $value, string $case, string $getter, string $where): string { $raw = self::read($value, $case, $getter, $where); diff --git a/examples/authors/python/dbapi/models.py b/examples/authors/python/dbapi/models.py index c0eadbbf..0db8a91c 100644 --- a/examples/authors/python/dbapi/models.py +++ b/examples/authors/python/dbapi/models.py @@ -13,3 +13,28 @@ class Authors: @dataclass class GetAuthorNameRow: name: str + +@dataclass +class FindAuthorsByNamePrefixRow: + id: int + name: str + bio: Optional[str] + has_bio: bool + +@dataclass +class GetAuthorStatisticsRow: + total: int + with_bio: int + with_nonempty_bio: int + column3: bool + +@dataclass +class GetAuthorExportMetadataRow: + id: int + export_date: bytes + export_datetime: bytes + export_timestamp: datetime + export_timestamp_text: bytes + export_timestamp_micros: int + column6: int + export_metadata: Optional[str] diff --git a/examples/authors/python/dbapi/queries.py b/examples/authors/python/dbapi/queries.py index a85ccd4d..37c156ad 100644 --- a/examples/authors/python/dbapi/queries.py +++ b/examples/authors/python/dbapi/queries.py @@ -193,3 +193,93 @@ def find_authors_by_name_covering(self, name: str) -> list[_models.Authors]: ) for row in rows] finally: cursor.close() + + # -- name: FindAuthorsByNamePrefix :many + def find_authors_by_name_prefix(self, prefix: str) -> list[_models.FindAuthorsByNamePrefixRow]: + parameters = { + "$prefix": (prefix, _ydb.PrimitiveType.Utf8), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DECLARE $prefix AS Utf8;\n" + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + "FROM authors\n" + "WHERE name LIKE $prefix || \"%\"u\n" + "ORDER BY id;"), + parameters, + ) + rows = cursor.fetchall() + return [_models.FindAuthorsByNamePrefixRow( + id=row[0], + name=row[1], + bio=row[2], + has_bio=row[3], + ) for row in rows] + finally: + cursor.close() + + # -- name: GetAuthorStatistics :one + def get_author_statistics(self) -> Optional[_models.GetAuthorStatisticsRow]: + parameters = { + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("SELECT\n" + " COUNT(*) AS total,\n" + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + " CAST(COUNT(*) AS Bool)\n" + "FROM authors;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.GetAuthorStatisticsRow( + total=row[0], + with_bio=row[1], + with_nonempty_bio=row[2], + column3=row[3], + ) + finally: + cursor.close() + + # -- name: GetAuthorExportMetadata :one + def get_author_export_metadata(self, author_id: int) -> Optional[_models.GetAuthorExportMetadataRow]: + parameters = { + "$author_id": (author_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DECLARE $author_id AS Uint64;\n" + "SELECT\n" + " id,\n" + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + " CurrentUtcTimestamp() AS export_timestamp,\n" + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + " COALESCE(CAST(id AS Uint32), 0),\n" + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + "FROM authors\n" + "WHERE id = $author_id;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.GetAuthorExportMetadataRow( + id=row[0], + export_date=row[1], + export_datetime=row[2], + export_timestamp=row[3], + export_timestamp_text=row[4], + export_timestamp_micros=row[5], + column6=row[6], + export_metadata=row[7], + ) + finally: + cursor.close() diff --git a/examples/authors/python/native/models.py b/examples/authors/python/native/models.py index c0eadbbf..35332b84 100644 --- a/examples/authors/python/native/models.py +++ b/examples/authors/python/native/models.py @@ -4,6 +4,10 @@ from uuid import UUID from typing import Optional +from typing import Union + +JSONValue = Union[None, bool, int, float, str, list["JSONValue"], dict[str, "JSONValue"]] + @dataclass class Authors: id: int @@ -13,3 +17,28 @@ class Authors: @dataclass class GetAuthorNameRow: name: str + +@dataclass +class FindAuthorsByNamePrefixRow: + id: int + name: str + bio: Optional[str] + has_bio: bool + +@dataclass +class GetAuthorStatisticsRow: + total: int + with_bio: int + with_nonempty_bio: int + column3: bool + +@dataclass +class GetAuthorExportMetadataRow: + id: int + export_date: bytes + export_datetime: bytes + export_timestamp: datetime + export_timestamp_text: bytes + export_timestamp_micros: int + column6: int + export_metadata: Optional[JSONValue] diff --git a/examples/authors/python/native/queries.py b/examples/authors/python/native/queries.py index 2d07ee4e..3ad52f7f 100644 --- a/examples/authors/python/native/queries.py +++ b/examples/authors/python/native/queries.py @@ -184,3 +184,89 @@ def find_authors_by_name_covering(self, name: str) -> list[_models.Authors]: name=row["name"], bio=row["bio"], ) for row in rows] + + # -- name: FindAuthorsByNamePrefix :many + def find_authors_by_name_prefix(self, prefix: str) -> list[_models.FindAuthorsByNamePrefixRow]: + parameters = { + "$prefix": _ydb.TypedValue(prefix, _ydb.PrimitiveType.Utf8), + } + result_sets = self._execute( + ("DECLARE $prefix AS Utf8;\n" + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + "FROM authors\n" + "WHERE name LIKE $prefix || \"%\"u\n" + "ORDER BY id;"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + return [_models.FindAuthorsByNamePrefixRow( + id=row["id"], + name=row["name"], + bio=row["bio"], + has_bio=row["has_bio"], + ) for row in rows] + + # -- name: GetAuthorStatistics :one + def get_author_statistics(self) -> Optional[_models.GetAuthorStatisticsRow]: + parameters = { + } + result_sets = self._execute( + ("SELECT\n" + " COUNT(*) AS total,\n" + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + " CAST(COUNT(*) AS Bool)\n" + "FROM authors;"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + row = rows[0] if rows else None + if row is None: + return None + return _models.GetAuthorStatisticsRow( + total=row["total"], + with_bio=row["with_bio"], + with_nonempty_bio=row["with_nonempty_bio"], + column3=row["column3"], + ) + + # -- name: GetAuthorExportMetadata :one + def get_author_export_metadata(self, author_id: int) -> Optional[_models.GetAuthorExportMetadataRow]: + parameters = { + "$author_id": _ydb.TypedValue(author_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("DECLARE $author_id AS Uint64;\n" + "SELECT\n" + " id,\n" + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + " CurrentUtcTimestamp() AS export_timestamp,\n" + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + " COALESCE(CAST(id AS Uint32), 0),\n" + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + "FROM authors\n" + "WHERE id = $author_id;"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + row = rows[0] if rows else None + if row is None: + return None + return _models.GetAuthorExportMetadataRow( + id=row["id"], + export_date=row["export_date"], + export_datetime=row["export_datetime"], + export_timestamp=row["export_timestamp"], + export_timestamp_text=row["export_timestamp_text"], + export_timestamp_micros=row["export_timestamp_micros"], + column6=row["column6"], + export_metadata=row["export_metadata"], + ) diff --git a/examples/authors/python/sqlalchemy/models.py b/examples/authors/python/sqlalchemy/models.py index c0eadbbf..0db8a91c 100644 --- a/examples/authors/python/sqlalchemy/models.py +++ b/examples/authors/python/sqlalchemy/models.py @@ -13,3 +13,28 @@ class Authors: @dataclass class GetAuthorNameRow: name: str + +@dataclass +class FindAuthorsByNamePrefixRow: + id: int + name: str + bio: Optional[str] + has_bio: bool + +@dataclass +class GetAuthorStatisticsRow: + total: int + with_bio: int + with_nonempty_bio: int + column3: bool + +@dataclass +class GetAuthorExportMetadataRow: + id: int + export_date: bytes + export_datetime: bytes + export_timestamp: datetime + export_timestamp_text: bytes + export_timestamp_micros: int + column6: int + export_metadata: Optional[str] diff --git a/examples/authors/python/sqlalchemy/queries.py b/examples/authors/python/sqlalchemy/queries.py index 409cd131..03ab58e9 100644 --- a/examples/authors/python/sqlalchemy/queries.py +++ b/examples/authors/python/sqlalchemy/queries.py @@ -200,3 +200,96 @@ def find_authors_by_name_covering(self, name: str) -> list[_models.Authors]: name=row._mapping["name"], bio=row._mapping["bio"], ) for row in rows] + + # -- name: FindAuthorsByNamePrefix :many + def find_authors_by_name_prefix(self, prefix: str) -> list[_models.FindAuthorsByNamePrefixRow]: + parameters = { + "prefix": (prefix, _ydb.PrimitiveType.Utf8), + } + result = self._connection.execute( + _text( + ("DECLARE $prefix AS Utf8;\n" + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + "FROM authors\n" + "WHERE name LIKE :prefix || \"%\"u\n" + "ORDER BY id;") + ), + parameters, + ) + try: + rows = result.fetchall() + finally: + result.close() + return [_models.FindAuthorsByNamePrefixRow( + id=row._mapping["id"], + name=row._mapping["name"], + bio=row._mapping["bio"], + has_bio=row._mapping["has_bio"], + ) for row in rows] + + # -- name: GetAuthorStatistics :one + def get_author_statistics(self) -> Optional[_models.GetAuthorStatisticsRow]: + parameters = { + } + result = self._connection.execute( + _text( + ("SELECT\n" + " COUNT(*) AS total,\n" + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + " CAST(COUNT(*) AS Bool)\n" + "FROM authors;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.GetAuthorStatisticsRow( + total=row._mapping["total"], + with_bio=row._mapping["with_bio"], + with_nonempty_bio=row._mapping["with_nonempty_bio"], + column3=row._mapping["column3"], + ) + + # -- name: GetAuthorExportMetadata :one + def get_author_export_metadata(self, author_id: int) -> Optional[_models.GetAuthorExportMetadataRow]: + parameters = { + "author_id": (author_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("DECLARE $author_id AS Uint64;\n" + "SELECT\n" + " id,\n" + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + " CurrentUtcTimestamp() AS export_timestamp,\n" + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + " COALESCE(CAST(id AS Uint32), 0),\n" + " CAST('{\"source\"\\:\"authors\"}' AS Json) AS export_metadata\n" + "FROM authors\n" + "WHERE id = :author_id;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.GetAuthorExportMetadataRow( + id=row._mapping["id"], + export_date=row._mapping["export_date"], + export_datetime=row._mapping["export_datetime"], + export_timestamp=row._mapping["export_timestamp"], + export_timestamp_text=row._mapping["export_timestamp_text"], + export_timestamp_micros=row._mapping["export_timestamp_micros"], + column6=row._mapping["column6"], + export_metadata=row._mapping["export_metadata"], + ) diff --git a/examples/authors/queries.sql b/examples/authors/queries.sql index 523e5f44..8764903d 100644 --- a/examples/authors/queries.sql +++ b/examples/authors/queries.sql @@ -31,3 +31,32 @@ WHERE a.name = $name ORDER BY a.id; -- name: FindAuthorsByNameCovering :many DECLARE $name AS Utf8; SELECT * FROM authors VIEW by_name_covering WHERE name = $name ORDER BY id; + +-- name: FindAuthorsByNamePrefix :many +DECLARE $prefix AS Utf8; +SELECT id, name, bio, bio IS NOT NULL AS has_bio +FROM authors +WHERE name LIKE $prefix || "%"u +ORDER BY id; + +-- name: GetAuthorStatistics :one +SELECT + COUNT(*) AS total, + COUNT_IF(bio IS NOT NULL) AS with_bio, + COUNT_IF(bio != ""u) AS with_nonempty_bio, + CAST(COUNT(*) AS Bool) +FROM authors; + +-- name: GetAuthorExportMetadata :one +DECLARE $author_id AS Uint64; +SELECT + id, + CAST(CurrentUtcDate() AS String) AS export_date, + CAST(CurrentUtcDatetime() AS String) AS export_datetime, + CurrentUtcTimestamp() AS export_timestamp, + CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros, + COALESCE(CAST(id AS Uint32), 0), + CAST('{"source":"authors"}' AS Json) AS export_metadata +FROM authors +WHERE id = $author_id; diff --git a/examples/authors/rust/native/models.rs b/examples/authors/rust/native/models.rs index 57cc9f29..177d0064 100644 --- a/examples/authors/rust/native/models.rs +++ b/examples/authors/rust/native/models.rs @@ -46,3 +46,31 @@ pub struct FindAuthorsByNameCoveringRow { pub name: String, pub bio: Option, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FindAuthorsByNamePrefixRow { + pub id: u64, + pub name: String, + pub bio: Option, + pub has_bio: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct GetAuthorStatisticsRow { + pub total: u64, + pub with_bio: u64, + pub with_nonempty_bio: u64, + pub column3: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct GetAuthorExportMetadataRow { + pub id: u64, + pub export_date: ydb::Bytes, + pub export_datetime: ydb::Bytes, + pub export_timestamp: std::time::SystemTime, + pub export_timestamp_text: ydb::Bytes, + pub export_timestamp_micros: u64, + pub column6: u32, + pub export_metadata: Option, +} diff --git a/examples/authors/rust/native/queries.rs b/examples/authors/rust/native/queries.rs index a52b3c11..7ab5fc8b 100644 --- a/examples/authors/rust/native/queries.rs +++ b/examples/authors/rust/native/queries.rs @@ -185,4 +185,90 @@ impl<'a, E: ydb::QueryExecutor> Queries<'a, E> { }) .collect() } + + // -- name: FindAuthorsByNamePrefix :many + #[builder(on(String, into))] + pub async fn find_authors_by_name_prefix( + &mut self, + prefix: String, + ) -> ydb::YdbResult> { + self.client + .query_result_set(concat!( + "DECLARE $prefix AS Utf8;\n", + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n", + "FROM authors\n", + "WHERE name LIKE $prefix || \"%\"u\n", + "ORDER BY id;", + )) + .param("$prefix", prefix) + .await? + .rows() + .map(|mut row| { + Ok(FindAuthorsByNamePrefixRow { + id: row.remove_field(0)?.try_into()?, + name: row.remove_field(1)?.try_into()?, + bio: row.remove_field(2)?.try_into()?, + has_bio: row.remove_field(3)?.try_into()?, + }) + }) + .collect() + } + + // -- name: GetAuthorStatistics :one + #[builder(on(String, into))] + pub async fn author_statistics(&mut self) -> ydb::YdbResult { + let mut row = self + .client + .query_row(concat!( + "SELECT\n", + " COUNT(*) AS total,\n", + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n", + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n", + " CAST(COUNT(*) AS Bool)\n", + "FROM authors;", + )) + .await?; + Ok(GetAuthorStatisticsRow { + total: row.remove_field(0)?.try_into()?, + with_bio: row.remove_field(1)?.try_into()?, + with_nonempty_bio: row.remove_field(2)?.try_into()?, + column3: row.remove_field(3)?.try_into()?, + }) + } + + // -- name: GetAuthorExportMetadata :one + #[builder(on(String, into))] + pub async fn author_export_metadata( + &mut self, + author_id: u64, + ) -> ydb::YdbResult { + let mut row = self + .client + .query_row(concat!( + "DECLARE $author_id AS Uint64;\n", + "SELECT\n", + " id,\n", + " CAST(CurrentUtcDate() AS String) AS export_date,\n", + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n", + " CurrentUtcTimestamp() AS export_timestamp,\n", + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n", + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n", + " COALESCE(CAST(id AS Uint32), 0),\n", + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n", + "FROM authors\n", + "WHERE id = $author_id;", + )) + .param("$author_id", author_id) + .await?; + Ok(GetAuthorExportMetadataRow { + id: row.remove_field(0)?.try_into()?, + export_date: row.remove_field(1)?.try_into()?, + export_datetime: row.remove_field(2)?.try_into()?, + export_timestamp: row.remove_field(3)?.try_into()?, + export_timestamp_text: row.remove_field(4)?.try_into()?, + export_timestamp_micros: row.remove_field(5)?.try_into()?, + column6: row.remove_field(6)?.try_into()?, + export_metadata: row.remove_field(7)?.try_into()?, + }) + } } diff --git a/examples/authors/typescript/native/queries.ts b/examples/authors/typescript/native/queries.ts index 3d2f2435..2f33801d 100644 --- a/examples/authors/typescript/native/queries.ts +++ b/examples/authors/typescript/native/queries.ts @@ -1,5 +1,6 @@ // Code generated by sqlc-ydb. DO NOT EDIT. import type { Query, SQL } from "@ydbjs/query"; +import type { JSValue } from "@ydbjs/value"; import { Int32, Uint32, Uint64, Utf8, Utf8Type } from "@ydbjs/value/primitive"; import { Optional } from "@ydbjs/value/optional"; @@ -62,6 +63,31 @@ export type FindAuthorsByNameCoveringRow = { readonly bio: string | null; }; +export type FindAuthorsByNamePrefixRow = { + readonly id: bigint; + readonly name: string; + readonly bio: string | null; + readonly has_bio: boolean; +}; + +export type GetAuthorStatisticsRow = { + readonly total: bigint; + readonly with_bio: bigint; + readonly with_nonempty_bio: bigint; + readonly column3: boolean; +}; + +export type GetAuthorExportMetadataRow = { + readonly id: bigint; + readonly export_date: Uint8Array; + readonly export_datetime: Uint8Array; + readonly export_timestamp: Date; + readonly export_timestamp_text: Uint8Array; + readonly export_timestamp_micros: bigint; + readonly column6: number; + readonly export_metadata: JSValue; +}; + export class Queries { readonly #sql: SQL; @@ -190,4 +216,65 @@ export class Queries { return rows; } + + // -- name: FindAuthorsByNamePrefix :many + async findAuthorsByNamePrefix(prefix: string, configure?: ConfigureQuery): Promise { + const stmt = this.#sql<[FindAuthorsByNamePrefixRow]>( + "DECLARE $prefix AS Utf8;\n" + + "SELECT id, name, bio, bio IS NOT NULL AS has_bio\n" + + "FROM authors\n" + + "WHERE name LIKE $prefix || \"%\"u\n" + + "ORDER BY id;" + ); + // Keep explicit DECLARE statements; the SDK otherwise prepends duplicates. + Object.defineProperty(stmt, "text", { value: stmt.text, writable: false }); + stmt + .parameter("prefix", new Utf8(prefix)); + configure?.(stmt); + const [rows] = await stmt; + + return rows; + } + + // -- name: GetAuthorStatistics :one + async getAuthorStatistics(configure?: ConfigureQuery): Promise { + const stmt = this.#sql<[GetAuthorStatisticsRow]>( + "SELECT\n" + + " COUNT(*) AS total,\n" + + " COUNT_IF(bio IS NOT NULL) AS with_bio,\n" + + " COUNT_IF(bio != \"\"u) AS with_nonempty_bio,\n" + + " CAST(COUNT(*) AS Bool)\n" + + "FROM authors;" + ); + configure?.(stmt); + const [rows] = await stmt; + + return rows[0] ?? null; + } + + // -- name: GetAuthorExportMetadata :one + async getAuthorExportMetadata(authorId: bigint, configure?: ConfigureQuery): Promise { + const stmt = this.#sql<[GetAuthorExportMetadataRow]>( + "DECLARE $author_id AS Uint64;\n" + + "SELECT\n" + + " id,\n" + + " CAST(CurrentUtcDate() AS String) AS export_date,\n" + + " CAST(CurrentUtcDatetime() AS String) AS export_datetime,\n" + + " CurrentUtcTimestamp() AS export_timestamp,\n" + + " CAST(CurrentUtcTimestamp() AS String) AS export_timestamp_text,\n" + + " CAST(CurrentUtcTimestamp() AS Uint64) AS export_timestamp_micros,\n" + + " COALESCE(CAST(id AS Uint32), 0),\n" + + " CAST('{\"source\":\"authors\"}' AS Json) AS export_metadata\n" + + "FROM authors\n" + + "WHERE id = $author_id;" + ); + // Keep explicit DECLARE statements; the SDK otherwise prepends duplicates. + Object.defineProperty(stmt, "text", { value: stmt.text, writable: false }); + stmt + .parameter("author_id", new Uint64(authorId)); + configure?.(stmt); + const [rows] = await stmt; + + return rows[0] ?? null; + } } diff --git a/examples/batch/java/jooq/Queries.java b/examples/batch/java/jooq/Queries.java index a9351979..1e6e4e63 100644 --- a/examples/batch/java/jooq/Queries.java +++ b/examples/batch/java/jooq/Queries.java @@ -33,6 +33,7 @@ public Optional getAuthor(ULong authorId) { return dsl.select(AUTHORS.AUTHOR_ID, AUTHORS.NAME, AUTHORS.BIOGRAPHY) .from(AUTHORS) .where(AUTHORS.AUTHOR_ID.eq(val(authorId, YdbTypes.UINT64))) + .coerce(field(name("author_id"), YdbTypes.UINT64), field(name("name"), YdbTypes.UTF8), field(name("biography"), YdbTypes.JSON)) .fetchOptional(mapping(GetAuthorRow::new)); } @@ -78,6 +79,7 @@ public List booksByYear(Integer year) { ) .from(BOOKS) .where(BOOKS.YEAR.eq(val(year, YdbTypes.INT32))) + .coerce(field(name("book_id"), YdbTypes.UINT64), field(name("author_id"), YdbTypes.UINT64), field(name("isbn"), YdbTypes.UTF8), field(name("book_type"), YdbTypes.UTF8), field(name("title"), YdbTypes.UTF8), field(name("year"), YdbTypes.INT32), field(name("available"), YdbTypes.TIMESTAMP), field(name("tags"), YdbTypes.JSON)) .fetch(mapping(BooksByYearRow::new)); } @@ -160,6 +162,7 @@ public Optional getBiography(ULong authorId) { return dsl.select(AUTHORS.BIOGRAPHY) .from(AUTHORS) .where(AUTHORS.AUTHOR_ID.eq(val(authorId, YdbTypes.UINT64))) + .coerce(field(name("biography"), YdbTypes.JSON)) .fetchOptional(mapping(GetBiographyRow::new)); } diff --git a/examples/booktest/java/jooq/Queries.java b/examples/booktest/java/jooq/Queries.java index 0888ffc5..8569e7ff 100644 --- a/examples/booktest/java/jooq/Queries.java +++ b/examples/booktest/java/jooq/Queries.java @@ -29,6 +29,7 @@ public Optional getAuthor(ULong authorId) { return dsl.select(AUTHORS.AUTHOR_ID, AUTHORS.NAME) .from(AUTHORS) .where(AUTHORS.AUTHOR_ID.eq(val(authorId, YdbTypes.UINT64))) + .coerce(field(name("author_id"), YdbTypes.UINT64), field(name("name"), YdbTypes.UTF8)) .fetchOptional(mapping(GetAuthorRow::new)); } @@ -46,6 +47,7 @@ public Optional getBook(ULong bookId) { ) .from(BOOKS) .where(BOOKS.BOOK_ID.eq(val(bookId, YdbTypes.UINT64))) + .coerce(field(name("book_id"), YdbTypes.UINT64), field(name("author_id"), YdbTypes.UINT64), field(name("isbn"), YdbTypes.UTF8), field(name("book_type"), YdbTypes.UTF8), field(name("title"), YdbTypes.UTF8), field(name("publication_year"), YdbTypes.INT32), field(name("available"), YdbTypes.TIMESTAMP), field(name("tags"), YdbTypes.JSON)) .fetchOptional(mapping(GetBookRow::new)); } @@ -72,6 +74,7 @@ public List booksByTitleYear(String title, Integer publicat .where( BOOKS.TITLE.eq(val(title, YdbTypes.UTF8)).and(BOOKS.PUBLICATION_YEAR.eq(val(publicationYear, YdbTypes.INT32))) ) + .coerce(field(name("book_id"), YdbTypes.UINT64), field(name("author_id"), YdbTypes.UINT64), field(name("isbn"), YdbTypes.UTF8), field(name("book_type"), YdbTypes.UTF8), field(name("title"), YdbTypes.UTF8), field(name("publication_year"), YdbTypes.INT32), field(name("available"), YdbTypes.TIMESTAMP), field(name("tags"), YdbTypes.JSON)) .fetch(mapping(BooksByTitleYearRow::new)); } @@ -201,6 +204,7 @@ public void deleteAuthorBeforeYear(Integer publicationYear, ULong authorId) { // -- name: SayHello :one public Optional sayHello(String name) { return dsl.select(inline("hello ", YdbTypes.UTF8).concat(val(name, YdbTypes.UTF8)).as("greeting")) + .coerce(field(name("greeting"), YdbTypes.UTF8)) .fetchOptional(mapping(SayHelloRow::new)); } } diff --git a/examples/jets/java/jooq/Queries.java b/examples/jets/java/jooq/Queries.java index 1b757371..9e94dc97 100644 --- a/examples/jets/java/jooq/Queries.java +++ b/examples/jets/java/jooq/Queries.java @@ -24,6 +24,7 @@ public Queries(YdbDSLContext dsl) { public Optional countPilots() { return dsl.select(count().coerce(YdbTypes.UINT64).as("pilot_count")) .from(PILOTS) + .coerce(field(name("pilot_count"), YdbTypes.UINT64)) .fetchOptional(mapping(CountPilotsRow::new)); } @@ -33,6 +34,7 @@ public List listPilots() { .from(PILOTS) .orderBy(PILOTS.ID) .limit(inline(5)) + .coerce(field(name("id"), YdbTypes.INT32), field(name("name"), YdbTypes.UTF8)) .fetch(mapping(ListPilotsRow::new)); } diff --git a/examples/ondeck/java/jooq/Queries.java b/examples/ondeck/java/jooq/Queries.java index c48bcb15..e54ff888 100644 --- a/examples/ondeck/java/jooq/Queries.java +++ b/examples/ondeck/java/jooq/Queries.java @@ -29,6 +29,7 @@ public List listCities() { return dsl.select(CITY.SLUG, CITY.NAME) .from(CITY) .orderBy(CITY.NAME) + .coerce(field(name("slug"), YdbTypes.UTF8), field(name("name"), YdbTypes.UTF8)) .fetch(mapping(ListCitiesRow::new)); } @@ -37,6 +38,7 @@ public Optional getCity(String slug) { return dsl.select(CITY.SLUG, CITY.NAME) .from(CITY) .where(CITY.SLUG.eq(val(slug, YdbTypes.UTF8))) + .coerce(field(name("slug"), YdbTypes.UTF8), field(name("name"), YdbTypes.UTF8)) .fetchOptional(mapping(GetCityRow::new)); } @@ -77,6 +79,7 @@ public List listVenues(String city) { .from(VENUE) .where(VENUE.CITY.eq(val(city, YdbTypes.UTF8))) .orderBy(VENUE.NAME) + .coerce(field(name("id"), YdbTypes.UINT64), field(name("slug"), YdbTypes.UTF8), field(name("name"), YdbTypes.UTF8), field(name("city"), YdbTypes.UTF8), field(name("status"), YdbTypes.UTF8), field(name("statuses"), YdbTypes.JSON), field(name("spotify_playlist"), YdbTypes.UTF8), field(name("songkick_id"), YdbTypes.UTF8), field(name("tags"), YdbTypes.JSON), field(name("created_at"), YdbTypes.TIMESTAMP)) .fetch(mapping(ListVenuesRow::new)); } @@ -103,6 +106,7 @@ public Optional getVenue(String slug, String city) { ) .from(VENUE) .where(VENUE.SLUG.eq(val(slug, YdbTypes.UTF8)).and(VENUE.CITY.eq(val(city, YdbTypes.UTF8)))) + .coerce(field(name("id"), YdbTypes.UINT64), field(name("slug"), YdbTypes.UTF8), field(name("name"), YdbTypes.UTF8), field(name("city"), YdbTypes.UTF8), field(name("status"), YdbTypes.UTF8), field(name("statuses"), YdbTypes.JSON), field(name("spotify_playlist"), YdbTypes.UTF8), field(name("songkick_id"), YdbTypes.UTF8), field(name("tags"), YdbTypes.JSON), field(name("created_at"), YdbTypes.TIMESTAMP)) .fetchOptional(mapping(GetVenueRow::new)); } @@ -168,6 +172,7 @@ public List venueCountByCity() { .from(VENUE) .groupBy(VENUE.CITY) .orderBy(VENUE.CITY) + .coerce(field(name("city"), YdbTypes.UTF8), field(name("venue_count"), YdbTypes.UINT64)) .fetch(mapping(VenueCountByCityRow::new)); } } diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index ef34aeac..acea83cd 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -258,27 +258,33 @@ UPSERT INTO authors (id) VALUES (1);`}}, nil) } } -func TestAnalyzeRejectsComputedExpressionInsteadOfGuessingType(t *testing.T) { - _, err := Analyze( +func TestAnalyzeResolvesComparisonProjectionAsBool(t *testing.T) { + result, err := Analyze( []model.Source{{Name: "schema.sql", Text: `CREATE TABLE authors (id Uint64 NOT NULL, PRIMARY KEY (id));`}}, []model.Source{{Name: "query.sql", Text: `-- name: Matches :many SELECT id = 1 AS matches FROM authors;`}}, ) - if err == nil || !strings.Contains(err.Error(), `computed result expression "id=1" is not supported`) { + if err != nil { t.Fatalf("error = %v", err) } + if typ := result.Queries[0].ResultSets[0].Columns[0].Type; typ.Kind != "Bool" { + t.Fatalf("comparison type = %s, want Bool", typ.String()) + } } -func TestAnalyzeRejectsCompositeParameterProjectionInsteadOfUsingBindType(t *testing.T) { - _, err := Analyze( +func TestAnalyzeComparisonProjectionDiffersFromParameterType(t *testing.T) { + result, err := Analyze( []model.Source{{Name: "schema.sql", Text: `CREATE TABLE authors (id Uint64 NOT NULL, PRIMARY KEY (id));`}}, []model.Source{{Name: "query.sql", Text: `-- name: Matches :many DECLARE $value AS Uint64; SELECT $value = 1ul AS matches FROM authors;`}}, ) - if err == nil || !strings.Contains(err.Error(), `unsupported result expression "$value=1ul"`) { + if err != nil { t.Fatalf("error = %v", err) } + if typ := result.Queries[0].ResultSets[0].Columns[0].Type; typ.Kind != "Bool" { + t.Fatalf("comparison type = %s, want Bool", typ.String()) + } } func TestAnalyzeKeepsDirectParameterProjection(t *testing.T) { @@ -586,14 +592,17 @@ SELECT id FROM authors WHERE id = $local_id;`}}, } } -func TestAnalyzeRejectsCountComparisonInsteadOfCallingItCount(t *testing.T) { - _, err := Analyze( +func TestAnalyzeCountComparisonReturnsBool(t *testing.T) { + result, err := Analyze( []model.Source{{Name: "schema.sql", Text: `CREATE TABLE authors (id Uint64 NOT NULL, PRIMARY KEY (id));`}}, []model.Source{{Name: "query.sql", Text: "-- name: HasAuthors :one\nSELECT COUNT(*) > 0 AS has_authors FROM authors;"}}, ) - if err == nil || !strings.Contains(err.Error(), "unsupported result expression") { + if err != nil { t.Fatalf("error = %v", err) } + if typ := result.Queries[0].ResultSets[0].Columns[0].Type; typ.Kind != "Bool" { + t.Fatalf("comparison type = %s, want Bool", typ.String()) + } } func TestAnalyzeInfersListTypeForINParameter(t *testing.T) { diff --git a/internal/analyzer/boolean.go b/internal/analyzer/boolean.go new file mode 100644 index 00000000..910ca469 --- /dev/null +++ b/internal/analyzer/boolean.go @@ -0,0 +1,67 @@ +package analyzer + +import ( + "fmt" + + "github.com/antlr4-go/antlr/v4" + "github.com/ydb-platform/sqlc-ydb/internal/model" + parser "github.com/ydb-platform/yql-parsers/go" +) + +func resolveBoolean(root antlr.ParserRuleContext, scope expressionScope) (model.Type, bool, error) { + var operands []antlr.ParserRuleContext + operator := "" + switch operation := coveringExpressionContext(root).(type) { + case *parser.ExprContext: + if len(operation.AllOR()) != 0 { + operator = "OR" + for _, operand := range operation.AllOr_subexpr() { + operands = append(operands, operand) + } + } + case *parser.Or_subexprContext: + if len(operation.AllAND()) != 0 { + operator = "AND" + for _, operand := range operation.AllAnd_subexpr() { + operands = append(operands, operand) + } + } + case *parser.And_subexprContext: + if len(operation.AllXOR()) != 0 { + operator = "XOR" + for _, operand := range operation.AllXor_subexpr() { + operands = append(operands, operand) + } + } + case *parser.Con_subexprContext: + if operation.Unary_op() != nil && operation.Unary_op().NOT() != nil { + operator = "NOT" + operands = append(operands, operation.Unary_subexpr()) + } + } + if len(operands) == 0 { + return model.Type{}, false, nil + } + result := model.Type{Kind: "Bool"} + if operator == "NOT" { + result = model.Type{Kind: "Null"} + } + optional := false + for _, operand := range operands { + typ, err := resolveScalarNode(operand, scope) + if err != nil { + return model.Type{}, true, fmt.Errorf("cannot resolve %s operand: %w", operator, err) + } + if typ.Kind != "Null" && typ.UnwrapOptional().Kind != "Bool" { + return model.Type{}, true, fmt.Errorf("%s operand has type %s, want Bool or Optional", operator, typ.String()) + } + optional = optional || typ.Kind == "Null" || typ.IsOptional() + if typ.Kind != "Null" { + result = model.Type{Kind: "Bool"} + } + } + if optional && result.Kind != "Null" { + result = model.Optional(result) + } + return result, true, nil +} diff --git a/internal/analyzer/dml_expression.go b/internal/analyzer/dml_expression.go index def37531..1e10301a 100644 --- a/internal/analyzer/dml_expression.go +++ b/internal/analyzer/dml_expression.go @@ -21,7 +21,6 @@ func validateDMLValue(expr parser.IExprContext, column model.Column, scope expre if containsAggregate(expr) { return fmt.Errorf("aggregate functions are not allowed in DML values") } - scope.predicate = true typ, err := resolveExpression(expr, scope) if err != nil { return err diff --git a/internal/analyzer/dml_test.go b/internal/analyzer/dml_test.go index 5f4400f2..924b2bdf 100644 --- a/internal/analyzer/dml_test.go +++ b/internal/analyzer/dml_test.go @@ -64,7 +64,6 @@ func TestAnalyzeRejectsUnsupportedQueryForms(t *testing.T) { {"join using", ":many", "SELECT a.id FROM records a JOIN records b USING (id);", "JOIN USING is not yet supported; use an explicit ON condition"}, {"derived table", ":many", "SELECT id FROM (SELECT id FROM records) r;", "only named catalog tables are supported in FROM and JOIN"}, {"table function", ":many", "SELECT id FROM AS_TABLE($rows);", "requires DECLARE $rows AS List>"}, - {"order by result alias", ":many", "SELECT id AS result FROM records ORDER BY result;", "unknown column \"result\""}, {"in subquery", ":many", "SELECT id FROM records WHERE id IN (SELECT r.id FROM records r UNION SELECT r.id FROM records r);", "unknown column \"r.id\""}, {"delete subquery", ":exec", "DELETE FROM records WHERE id NOT IN (SELECT r.id FROM records r);", "unknown column \"r.id\""}, {"array expression", ":one", "SELECT [1, 2] AS values;", "unsupported result expression"}, diff --git a/internal/analyzer/expression.go b/internal/analyzer/expression.go index 6e419a2d..0204fd1f 100644 --- a/internal/analyzer/expression.go +++ b/internal/analyzer/expression.go @@ -14,7 +14,6 @@ type expressionScope struct { relations []relation bindings map[string]model.Type grouped bool - predicate bool functions *builtins.Registry } @@ -28,6 +27,9 @@ func resolveExpression(expr parser.IExprContext, scope expressionScope) (model.T if typ, ok, err := resolveArithmetic(expr, scope); ok { return typ, err } + if typ, ok, err := resolveBoolean(expr, scope); ok { + return typ, err + } if typ, ok, err := resolveMemberAccess(expr, scope); ok { return typ, err } @@ -55,13 +57,11 @@ func resolveExpression(expr parser.IExprContext, scope expressionScope) (model.T if name, invoke, ok := directFunctionCall(expr); ok { return resolveFunction(name, invoke, scope) } - if typeValue, ok, err := concatenationType(expr, scope.bindings); ok { + if typeValue, ok, err := concatenationType(expr, scope); ok { return typeValue, err } - if scope.predicate { - if typeValue, ok, err := resolveComparison(expr, scope); ok { - return typeValue, err - } + if typeValue, ok, err := resolveComparison(expr, scope); ok { + return typeValue, err } if len(columnRefs(expr)) != 0 { return model.Type{}, fmt.Errorf("computed result expression %q is not supported", expr.GetText()) @@ -69,8 +69,9 @@ func resolveExpression(expr parser.IExprContext, scope expressionScope) (model.T return model.Type{}, fmt.Errorf("unsupported result expression %q", expr.GetText()) } -func resolveComparison(expr parser.IExprContext, scope expressionScope) (model.Type, bool, error) { +func resolveComparison(expr antlr.ParserRuleContext, scope expressionScope) (model.Type, bool, error) { var operands []antlr.ParserRuleContext + nullSafe := false descendants(expr, func(node antlr.Tree) { ctx, ok := node.(*parser.Eq_subexprContext) if !ok || !sameSpan(expr, ctx) || len(ctx.AllNeq_subexpr()) < 2 { @@ -99,27 +100,37 @@ func resolveComparison(expr parser.IExprContext, scope expressionScope) (model.T return model.Type{Kind: "Bool"}, true, nil } if condition.IN() != nil { - return model.Type{}, true, fmt.Errorf("typed IN predicates are not yet supported in CASE, IF, or HAVING") + return model.Type{}, true, fmt.Errorf("typed IN expressions are not yet supported outside WHERE or JOIN predicates") } operands = append(operands, xor.Eq_subexpr()) for _, operand := range condition.AllEq_subexpr() { operands = append(operands, operand) } + nullSafe = len(condition.AllDistinct_from_op()) == len(operands)-1 if len(operands) < 2 { return model.Type{}, false, nil } } - return comparisonBoolType(operands, scope) + typ, matched, err := comparisonBoolType(operands, scope) + if nullSafe && err == nil { + typ = model.Type{Kind: "Bool"} + } + return typ, matched, err } func comparisonBoolType(operands []antlr.ParserRuleContext, scope expressionScope) (model.Type, bool, error) { types := make([]model.Type, 0, len(operands)) + allNull := true for _, operand := range operands { typeValue, err := resolveScalarNode(operand, scope) if err != nil { return model.Type{}, true, fmt.Errorf("cannot resolve comparison operand %q: %w", operand.GetText(), err) } types = append(types, typeValue) + allNull = allNull && typeValue.Kind == "Null" + } + if allNull { + return model.Optional(model.Type{Kind: "Bool"}), true, nil } common, err := builtins.CommonType(types...) if err != nil { @@ -136,25 +147,17 @@ func resolveScalarNode(root antlr.ParserRuleContext, scope expressionScope) (mod if typ, ok, err := resolveArithmetic(root, scope); ok { return typ, err } - if typ, ok, err := resolveMemberAccess(root, scope); ok { + if typ, ok, err := resolveBoolean(root, scope); ok { return typ, err } - var unaryNot *parser.Con_subexprContext - descendants(root, func(node antlr.Tree) { - ctx, ok := node.(*parser.Con_subexprContext) - if ok && sameSpan(root, ctx) && ctx.Unary_op() != nil && ctx.Unary_op().NOT() != nil { - unaryNot = ctx - } - }) - if unaryNot != nil { - typeValue, err := resolveScalarNode(unaryNot.Unary_subexpr(), scope) - if err != nil { - return model.Type{}, fmt.Errorf("cannot resolve NOT operand: %w", err) - } - if typeValue.UnwrapOptional().Kind != "Bool" { - return model.Type{}, fmt.Errorf("NOT operand has type %s, want Bool", typeValue.String()) - } - return typeValue, nil + if typ, ok, err := concatenationType(root, scope); ok { + return typ, err + } + if typ, ok, err := resolveComparison(root, scope); ok { + return typ, err + } + if typ, ok, err := resolveMemberAccess(root, scope); ok { + return typ, err } var literal parser.ILiteral_valueContext descendants(root, func(node antlr.Tree) { @@ -292,9 +295,7 @@ func resolveCase(caseExpr *parser.Case_exprContext, scope expressionScope) (mode if len(parts) != 2 { return model.Type{}, fmt.Errorf("invalid CASE WHEN branch") } - conditionScope := scope - conditionScope.predicate = true - conditionType, err := resolveExpression(parts[0], conditionScope) + conditionType, err := resolveExpression(parts[0], scope) if err != nil { return model.Type{}, fmt.Errorf("cannot resolve CASE condition: %w", err) } @@ -391,13 +392,11 @@ func resolveFunction(name string, invoke *parser.Invoke_exprContext, scope expre if isAggregateFunction(name) && containsAggregate(named.Expr()) { return model.Type{}, fmt.Errorf("aggregate function %q cannot contain another aggregate", name) } - argumentScope := scope - argumentScope.predicate = strings.EqualFold(name, "if") && len(callArgs) == 0 - typeValue, err := resolveExpression(named.Expr(), argumentScope) + typeValue, err := resolveExpression(named.Expr(), scope) if err != nil { return model.Type{}, fmt.Errorf("cannot resolve argument of %s: %w", name, err) } - argument := builtins.CallArgument{Type: typeValue} + argument := builtins.CallArgument{Type: typeValue, IntegerLiteral: integerLiteralValue(named.Expr())} if named.AS() != nil { argument.Name = identifier(named.An_id_or_type().GetText()) } diff --git a/internal/analyzer/insert_select.go b/internal/analyzer/insert_select.go index 384494b3..8ae90786 100644 --- a/internal/analyzer/insert_select.go +++ b/internal/analyzer/insert_select.go @@ -74,6 +74,9 @@ func analyzeSelectCore(catalog model.Catalog, block queryBlock, core *parser.Sel if len(ds) != 0 { return nil, diagnostics } + if len(relations) == 0 && containsAggregate(core) { + return nil, []model.Diagnostic{diagnosticAt(block.file, block.line-1, core, "aggregate functions require a FROM source")} + } recordColumnBindings(syntax, core, relations) tree := collectQueryTree(core) inferFromComparisons(tree, relations, inferred) @@ -90,7 +93,10 @@ func analyzeSelectCore(catalog model.Catalog, block queryBlock, core *parser.Sel diagnostics = append(diagnostics, validateLimitOffset(block, partial, bindings)...) columns, ds := projection(block, core, relations, bindings) diagnostics = append(diagnostics, ds...) - diagnostics = append(diagnostics, validateColumnReferences(block, core, relations)...) + if len(ds) == 0 { + diagnostics = append(diagnostics, resolveOrderByProjections(block, core, relations, columns, syntax)...) + } + diagnostics = append(diagnostics, validateColumnReferences(block, core, relations, columns)...) diagnostics = append(diagnostics, validatePredicateContexts(block, core, relations, bindings)...) diagnostics = append(diagnostics, validateGrouping(block, core, relations, bindings)...) return columns, diagnostics diff --git a/internal/analyzer/literal.go b/internal/analyzer/literal.go index fff76a65..e0bb8777 100644 --- a/internal/analyzer/literal.go +++ b/internal/analyzer/literal.go @@ -2,6 +2,7 @@ package analyzer import ( "fmt" + "math/big" "strconv" "strings" @@ -10,6 +11,28 @@ import ( parser "github.com/ydb-platform/yql-parsers/go" ) +// Preserve only a direct literal's value for value-dependent builtin coercion. +// Arbitrary arithmetic and local bindings require their own static type rules. +func integerLiteralValue(root antlr.ParserRuleContext) *big.Int { + for inner := parenthesizedExpression(root); inner != nil; inner = parenthesizedExpression(root) { + root = inner + } + integer, ok := coveringExpressionContext(root).(*parser.IntegerContext) + if !ok { + return nil + } + text := strings.ToLower(integer.GetText()) + for _, suffix := range []string{"ul", "us", "ut", "l", "s", "t", "u"} { + if strings.HasSuffix(text, suffix) { + text = strings.TrimSuffix(text, suffix) + break + } + } + number, base := integerDigits(text) + value, _ := new(big.Int).SetString(number, base) + return value +} + func literalType(expr parser.IExprContext) (model.Type, bool, error) { var literals []parser.ILiteral_valueContext descendants(expr, func(node antlr.Tree) { @@ -48,7 +71,7 @@ func literalValueType(literal parser.ILiteral_valueContext) (model.Type, error) } func stringLiteralType(text string) (model.Type, error) { - if len(text) < 2 { + if len(text) < 2 || strings.HasSuffix(text, "'") || strings.HasSuffix(text, "\"") || strings.HasSuffix(text, "@@") { return model.Type{Kind: "String"}, nil } prefix, suffix := text[:len(text)-1], text[len(text)-1] diff --git a/internal/analyzer/named_insert_select_test.go b/internal/analyzer/named_insert_select_test.go index 8bf60b01..5d40434a 100644 --- a/internal/analyzer/named_insert_select_test.go +++ b/internal/analyzer/named_insert_select_test.go @@ -92,7 +92,7 @@ func TestNamedInsertSelectDiagnostics(t *testing.T) { {"duplicate alias", "SELECT id AS id, label AS `id` FROM records", `duplicate source column "id"`}, {"type mismatch", "SELECT label AS id FROM records", `source column "id" has type Optional`}, {"null key", "SELECT NULL AS id", `source column "id" has type Null`}, - {"missing alias", "SELECT 1ul", `computed result expressions require an explicit AS alias`}, + {"missing alias", "SELECT 1ul", `unknown target column "column0"`}, {"bad source", "SELECT id FROM missing", `unknown table "missing"`}, {"unknown qualifier", "SELECT absent.* FROM records", `unknown table or alias "absent"`}, {"qualified join", "SELECT r.id FROM records r JOIN records other ON r.id = other.id", `unknown target column "r.id"; use AS id`}, diff --git a/internal/analyzer/order_by.go b/internal/analyzer/order_by.go new file mode 100644 index 00000000..95bdd6d2 --- /dev/null +++ b/internal/analyzer/order_by.go @@ -0,0 +1,84 @@ +package analyzer + +import ( + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/ydb-platform/sqlc-ydb/internal/model" + parser "github.com/ydb-platform/yql-parsers/go" +) + +func resolveOrderByProjections(block queryBlock, core *parser.Select_coreContext, relations []relation, columns []model.Column, syntax *model.QuerySyntax) []model.Diagnostic { + // Output names shadow source columns in ORDER BY. An empty binding marks + // a computed output, which must not be rendered as a physical table field. + outputs := make(map[string]model.ColumnBinding, len(columns)) + for _, column := range columns { + outputs[column.ResultName()] = model.ColumnBinding{} + } + for _, result := range core.AllResult_column() { + if result.ASTERISK() != nil { + prefix := identifier(strings.TrimSuffix(result.Opt_id_prefix().GetText(), ".")) + for _, relation := range relations { + if prefix != "" && prefix != relation.alias { + continue + } + for _, column := range relation.table.Columns { + outputs[column.Name] = model.ColumnBinding{TableBinding: model.TableBinding{Table: relation.table.Name, Alias: relation.alias}, Column: column} + } + } + continue + } + expr := unwrapOrderByColumn(result.Expr()) + if !isPureColumnExpression(expr) { + continue + } + ref := columnRefs(expr)[0] + name := ref.name + if len(relations) > 1 && ref.qualifier != "" { + name = qualifiedName(ref) + } + if result.An_id_or_type() != nil { + name = identifier(result.An_id_or_type().GetText()) + } + if result.An_id_as_compat() != nil { + name = identifier(result.An_id_as_compat().GetText()) + } + outputs[name] = syntax.Columns[ref.ctx.GetStart().GetTokenIndex()] + } + var diagnostics []model.Diagnostic + descendants(core, func(node antlr.Tree) { + order, ok := node.(*parser.Sort_specificationContext) + if !ok { + return + } + direct := isPureColumnExpression(unwrapOrderByColumn(order.Expr())) + for _, ref := range columnRefs(order.Expr()) { + output, exists := outputs[ref.name] + if ref.qualifier != "" || !exists { + continue + } + token := ref.ctx.GetStart().GetTokenIndex() + original := syntax.Columns[token] + if output.Column.Name != "" && output.TableBinding == original.TableBinding && output.Column.Name == original.Column.Name { + continue + } + if !direct { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, order, "ORDER BY expressions referencing projection aliases are not yet supported; project the complete sorting expression with AS and order by that alias directly")) + continue + } + if output.Column.Name == "" { + delete(syntax.Columns, token) + } else { + syntax.Columns[token] = output + } + } + }) + return diagnostics +} + +func unwrapOrderByColumn(expr parser.IExprContext) parser.IExprContext { + for inner := parenthesizedExpression(expr); inner != nil; inner = parenthesizedExpression(expr) { + expr = inner + } + return expr +} diff --git a/internal/analyzer/order_by_test.go b/internal/analyzer/order_by_test.go new file mode 100644 index 00000000..9d538707 --- /dev/null +++ b/internal/analyzer/order_by_test.go @@ -0,0 +1,58 @@ +package analyzer + +import ( + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestOrderByUsesProjectedTypesAndBindings(t *testing.T) { + schema := `CREATE TABLE records (id Uint64 NOT NULL, column0 Utf8, flag Bool NOT NULL, PRIMARY KEY(id));` + for _, tc := range []struct { + statement, typ, boundColumn string + }{ + {`SELECT id > 0ul FROM records ORDER BY column0;`, "", ""}, + {`SELECT id > 0ul AS id FROM records ORDER BY id;`, "", ""}, + {`SELECT column0 AS id FROM records ORDER BY (id) DESC;`, "", "column0"}, + {`SELECT column0 AS renamed FROM records ORDER BY renamed;`, "", "column0"}, + {`SELECT id FROM records ORDER BY id = $p;`, "Uint64", "id"}, + {`SELECT id AS id FROM records ORDER BY id = $p;`, "Uint64", "id"}, + {`SELECT flag FROM records ORDER BY column0 = $p;`, "Optional", "column0"}, + {`SELECT id > 0ul AS id FROM records ORDER BY records.id = $p;`, "Uint64", "id"}, + {`SELECT r.*, id > 0ul FROM records AS r ORDER BY column1;`, "", ""}, + } { + t.Run(tc.statement, func(t *testing.T) { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + tc.statement}}) + if err != nil { + t.Fatal(err) + } + query := result.Queries[0] + if (tc.typ == "" && len(query.Parameters) != 0) || (tc.typ != "" && (len(query.Parameters) != 1 || query.Parameters[0].Type.String() != tc.typ)) { + t.Fatalf("parameters = %+v; want $p %s", query.Parameters, tc.typ) + } + for _, ref := range columnRefs(query.Syntax.Root) { + if !isOrderByReference(ref.ctx) { + continue + } + binding, exists := query.Syntax.Columns[ref.ctx.GetStart().GetTokenIndex()] + if exists != (tc.boundColumn != "") || binding.Column.Name != tc.boundColumn { + t.Fatalf("ORDER BY binding = %+v, exists %v; want source column %q", binding, exists, tc.boundColumn) + } + } + }) + } + for _, statement := range []string{ + `SELECT id > 0ul FROM records ORDER BY column0 = $p;`, + `SELECT id > 0ul AS id FROM records ORDER BY id = $p;`, + `SELECT column0 AS id FROM records ORDER BY id = $p;`, + `SELECT column0 AS renamed FROM records ORDER BY renamed = $p;`, + `SELECT id > 0ul FROM records ORDER BY column0 IN $p;`, + `DECLARE $p AS Bool; SELECT id > 0ul AS id FROM records ORDER BY id = $p;`, + } { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + statement}}) + if err == nil || !strings.Contains(err.Error(), "ORDER BY expressions referencing projection aliases") { + t.Fatalf("unsafe ORDER BY scope accepted for %s: %v", statement, err) + } + } +} diff --git a/internal/analyzer/predicate.go b/internal/analyzer/predicate.go index b8816c5f..f6632e14 100644 --- a/internal/analyzer/predicate.go +++ b/internal/analyzer/predicate.go @@ -38,7 +38,11 @@ func validatePredicateContexts(block queryBlock, root antlr.Tree, relations []re continue } seen[predicate.GetStart().GetStart()] = true - if err := validatePredicate(predicate, expressionScope{relations: relations, bindings: bindings, predicate: true, functions: block.functions}); err != nil { + if containsAggregate(predicate) { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, predicate, "aggregate functions are not allowed in WHERE or JOIN predicates; use HAVING after aggregation")) + continue + } + if err := validatePredicate(predicate, expressionScope{relations: relations, bindings: bindings, functions: block.functions}); err != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, predicate, fmt.Sprintf("invalid predicate: %v", err))) } } @@ -118,25 +122,11 @@ func validatePredicateAtom(atom *parser.Xor_subexprContext, scope expressionScop } return nil } - var types []model.Type - types = append(types, left) - for _, operand := range condition.AllEq_subexpr() { - typeValue, err := resolveScalarNode(operand, scope) - if err != nil { - return fmt.Errorf("cannot resolve predicate operand %q: %w", operand.GetText(), err) - } - if condition.IN() != nil && typeValue.Kind == "List" && typeValue.Elem != nil { - typeValue = *typeValue.Elem - } - types = append(types, typeValue) - } - if len(types) < 2 { + _, matched, err := resolveComparison(atom, scope) + if !matched { return fmt.Errorf("unsupported predicate %q", atom.GetText()) } - if _, err := builtins.CommonType(types...); err != nil { - return fmt.Errorf("predicate operands have incompatible types: %w", err) - } - return nil + return err } eq := atom.Eq_subexpr() @@ -163,7 +153,7 @@ func validatePredicateAtom(atom *parser.Xor_subexprContext, scope expressionScop if err != nil { return fmt.Errorf("cannot resolve NOT operand: %w", err) } - if typeValue.UnwrapOptional().Kind != "Bool" { + if typeValue.Kind != "Null" && typeValue.UnwrapOptional().Kind != "Bool" { return fmt.Errorf("NOT operand has type %s, want Bool", typeValue.String()) } return nil @@ -175,7 +165,7 @@ func validatePredicateAtom(atom *parser.Xor_subexprContext, scope expressionScop } return fmt.Errorf("cannot resolve predicate operand %q: %w", eq.GetText(), err) } - if typeValue.UnwrapOptional().Kind != "Bool" { + if typeValue.Kind != "Null" && typeValue.UnwrapOptional().Kind != "Bool" { return fmt.Errorf("predicate expression has type %s, want Bool", typeValue.String()) } return nil diff --git a/internal/analyzer/projection_names.go b/internal/analyzer/projection_names.go new file mode 100644 index 00000000..7831f982 --- /dev/null +++ b/internal/analyzer/projection_names.go @@ -0,0 +1,51 @@ +package analyzer + +import ( + "cmp" + "fmt" + "slices" + + "github.com/ydb-platform/sqlc-ydb/internal/model" + parser "github.com/ydb-platform/yql-parsers/go" +) + +type implicitProjection struct { + column, ordinal int + expression parser.IExprContext +} + +// YQL reserves every authored name before allocating columnN from each +// expression's projection ordinal. A collision discards its result order hint, +// so the wire result then follows lexically ordered struct members. +func nameImplicitProjections(columns []model.Column, unnamed []implicitProjection, wildcard bool, rewrites *wildcardRewrites) { + if len(unnamed) == 0 { + return + } + reserved := make(map[string]bool, len(columns)) + for _, column := range columns { + if column.Name != "" { + reserved[column.ResultName()] = true + } + } + shifted := false + for _, projection := range unnamed { + ordinal := projection.ordinal + name := fmt.Sprintf("column%d", ordinal) + for reserved[name] { + ordinal++ + name = fmt.Sprintf("column%d", ordinal) + } + reserved[name] = true + columns[projection.column].Name = name + shifted = shifted || ordinal != projection.ordinal + if wildcard && rewrites != nil { + // Expanding '*' changes projection ordinals. Pin the authored implicit + // name so references such as ORDER BY column1 keep their meaning. + offset := runeByteOffset(rewrites.source, projection.expression.GetStop().GetStop()+1) + rewrites.replacements = append(rewrites.replacements, wildcardReplacement{start: offset, end: offset, text: " AS " + quotedYQLIdentifier(name)}) + } + } + if shifted { + slices.SortStableFunc(columns, func(a, b model.Column) int { return cmp.Compare(a.ResultName(), b.ResultName()) }) + } +} diff --git a/internal/analyzer/projection_names_test.go b/internal/analyzer/projection_names_test.go new file mode 100644 index 00000000..00a9769b --- /dev/null +++ b/internal/analyzer/projection_names_test.go @@ -0,0 +1,80 @@ +package analyzer + +import ( + "reflect" + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestImplicitProjectionNames(t *testing.T) { + schema := `CREATE TABLE records (id Uint64 NOT NULL, column0 Uint64, PRIMARY KEY(id));` + for _, tc := range []struct { + sql string + names []string + }{ + {`SELECT 1, 2 + 3, CAST(1 AS Uint64);`, []string{"column0", "column1", "column2"}}, + {`SELECT 1 AS z, 2;`, []string{"z", "column1"}}, + {`SELECT "Привет 😀 ?"u /* keep */, 2 AS Column0;`, []string{"column0", "Column0"}}, + {`SELECT 1, 2 AS column0;`, []string{"column0", "column1"}}, + {`SELECT 1 AS column2, 2, 3;`, []string{"column1", "column2", "column3"}}, + {`SELECT 1 AS z, 2 AS column2, 3, 4;`, []string{"column2", "column3", "column4", "z"}}, + {`SELECT (id), COALESCE(column0, 0ul) FROM records;`, []string{"id", "column1"}}, + {`SELECT a.column0, b.id, 1 FROM records AS a JOIN records AS b ON a.id=b.id;`, []string{"a.column0", "b.id", "column2"}}, + {`SELECT 1, a.column0 FROM records AS a JOIN records AS b ON a.id=b.id;`, []string{"column0", "a.column0"}}, + } { + t.Run(tc.sql, func(t *testing.T) { + sql := "-- name: Read :many\n" + tc.sql + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + query := result.Queries[0] + var names []string + for _, column := range query.ResultSets[0].Columns { + names = append(names, column.ResultName()) + } + if !reflect.DeepEqual(names, tc.names) { + t.Fatalf("names %v, want %v", names, tc.names) + } + if query.SQL != sql { + t.Fatalf("SQL changed: %s", query.SQL) + } + }) + } +} + +func TestImplicitProjectionNamesSurviveWildcardExpansion(t *testing.T) { + schema := `CREATE TABLE records (id Uint64 NOT NULL, column0 Uint64, PRIMARY KEY(id));` + for _, tc := range []struct { + projection, want string + names []string + }{ + {"r.*, /* Привет 😀 */ COALESCE(column0, 7ul) /* tail */", "r.`id` AS `id`, `r`.`column0` AS `column0`, /* Привет 😀 */ COALESCE(column0, 7ul) AS `column1` /* tail */", []string{"id", "column0", "column1"}}, + {"COALESCE(column0, 7ul), r.*", "COALESCE(column0, 7ul) AS `column1`, r.`id` AS `id`, `r`.`column0` AS `column0`", []string{"column1", "id", "column0"}}, + } { + t.Run(tc.projection, func(t *testing.T) { + sql := "-- name: Read :many\nSELECT " + tc.projection + " FROM records AS r ORDER BY column1;" + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + query := result.Queries[0] + want := "-- name: Read :many\nSELECT " + tc.want + " FROM records AS r ORDER BY column1;" + if query.SQL != want { + t.Fatalf("SQL:\n%s\nwant:\n%s", query.SQL, want) + } + var names []string + for _, column := range query.ResultSets[0].Columns { + names = append(names, column.ResultName()) + } + if !reflect.DeepEqual(names, tc.names) { + t.Fatalf("names %v", names) + } + if strings.Contains(query.SQL, "column2") { + t.Fatal("wildcard expansion renamed original expression") + } + }) + } +} diff --git a/internal/analyzer/scalar_select_test.go b/internal/analyzer/scalar_select_test.go index 7511c2bc..3ee0fd17 100644 --- a/internal/analyzer/scalar_select_test.go +++ b/internal/analyzer/scalar_select_test.go @@ -86,13 +86,13 @@ func TestAnalyzeRejectsUnsupportedConcatenationOperands(t *testing.T) { } func TestAnalyzeRejectsColumnReferencesWithoutTable(t *testing.T) { - for _, query := range []string{ - "SELECT COUNT(missing) AS n;", - "SELECT 1 AS n WHERE missing = 1;", + for _, test := range []struct{ query, want string }{ + {"SELECT COUNT(missing) AS n;", "aggregate functions require a FROM source"}, + {"SELECT 1 AS n WHERE missing = 1;", `unknown column "missing"`}, } { - _, err := Analyze(nil, []model.Source{{Name: "query.sql", Text: "-- name: Invalid :one\n" + query}}) - if err == nil || !strings.Contains(err.Error(), `unknown column "missing"`) { - t.Errorf("query %q error = %v", query, err) + _, err := Analyze(nil, []model.Source{{Name: "query.sql", Text: "-- name: Invalid :one\n" + test.query}}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("query %q error = %v, want %q", test.query, err, test.want) } } } diff --git a/internal/analyzer/select_semantic.go b/internal/analyzer/select_semantic.go index fd452d43..c24bf7d6 100644 --- a/internal/analyzer/select_semantic.go +++ b/internal/analyzer/select_semantic.go @@ -195,7 +195,7 @@ func validateGrouping(block queryBlock, core *parser.Select_coreContext, relatio diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, ref.ctx, fmt.Sprintf("HAVING column %q must appear in GROUP BY or an aggregate function", qualifiedName(ref)))) } } - typeValue, err := resolveExpression(having, expressionScope{relations: relations, bindings: bindings, grouped: core.Group_by_clause() != nil, predicate: true, functions: block.functions}) + typeValue, err := resolveExpression(having, expressionScope{relations: relations, bindings: bindings, grouped: core.Group_by_clause() != nil, functions: block.functions}) if err != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, having, fmt.Sprintf("cannot resolve HAVING expression: %v", err))) } else if typeValue.UnwrapOptional().Kind != "Bool" { @@ -281,7 +281,7 @@ func unaggregatedColumnRefs(root antlr.Tree) []columnRef { func isAggregateFunction(name string) bool { switch strings.ToLower(name) { - case "count", "sum", "avg", "min", "max", "some", "every": + case "count", "count_if", "sum", "avg", "min", "max", "some", "every": return true default: return false diff --git a/internal/analyzer/semantic.go b/internal/analyzer/semantic.go index 4b36ac92..1d10bcab 100644 --- a/internal/analyzer/semantic.go +++ b/internal/analyzer/semantic.go @@ -152,7 +152,7 @@ func analyzeQuery(catalog model.Catalog, block queryBlock) (model.AnalyzedQuery, if selectStatement == nil && len(relations) != 0 && !(len(tree.insert) == 1 && insertSelect(tree.insert[0]) != nil) && !(len(tree.updates) == 1 && updateSelect(tree.updates[0]) != nil) && !(len(tree.deletes) == 1 && deleteSelect(tree.deletes[0]) != nil) { recordColumnBindings(query.Syntax, parsed.tree, relations) - diagnostics = append(diagnostics, validateColumnReferences(block, parsed.tree, relations)...) + diagnostics = append(diagnostics, validateColumnReferences(block, parsed.tree, relations, nil)...) inferFromComparisons(tree, relations, inferred) inferFromInLists(tree.conds, relations, inferred) for name, typeValue := range inferred { @@ -380,6 +380,10 @@ func localBindings(block queryBlock, tree queryTree, declared map[string]model.T diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, statement, "only single scalar local assignments are supported")) continue } + if containsAggregate(statement.Expr()) { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, statement.Expr(), "aggregate functions are not allowed in scalar local assignments")) + continue + } name := bindName(lhs[0]) if _, exists := types[name]; exists { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, statement, fmt.Sprintf("local $%s is assigned more than once", name))) @@ -530,14 +534,17 @@ func selectDMLProjection(block queryBlock, selectCore *parser.Select_coreContext return selectProjectionMode(block, selectCore, relations, declared, false) } -func selectProjectionMode(block queryBlock, selectCore *parser.Select_coreContext, relations []relation, declared map[string]model.Type, requireComputedAlias bool) ([]model.Column, []model.Diagnostic) { +func selectProjectionMode(block queryBlock, selectCore *parser.Select_coreContext, relations []relation, declared map[string]model.Type, namedResult bool) ([]model.Column, []model.Diagnostic) { var columns []model.Column var diagnostics []model.Diagnostic + var unnamed []implicitProjection + hasWildcard := false if selectCore.Without_column_list() != nil { return nil, []model.Diagnostic{diagnosticAt(block.file, block.line-1, selectCore.Without_column_list(), "SELECT WITHOUT is not yet supported")} } - for _, result := range selectCore.AllResult_column() { + for ordinal, result := range selectCore.AllResult_column() { if result.ASTERISK() != nil { + hasWildcard = true prefix := identifier(strings.TrimSuffix(result.Opt_id_prefix().GetText(), ".")) matched := false var expressions []string @@ -585,9 +592,8 @@ func selectProjectionMode(block queryBlock, selectCore *parser.Select_coreContex column.Name = alias column.WireName = "" } - if alias == "" && !pure && requireComputedAlias { - diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, result, "computed result expressions require an explicit AS alias")) - continue + if alias == "" && !pure && namedResult { + unnamed = append(unnamed, implicitProjection{column: len(columns), ordinal: ordinal, expression: expr}) } if alias == "" && pure && len(relations) > 1 { refs := columnRefs(expr) @@ -597,10 +603,16 @@ func selectProjectionMode(block queryBlock, selectCore *parser.Select_coreContex } columns = append(columns, column) } + if namedResult { + nameImplicitProjections(columns, unnamed, hasWildcard, block.wildcards) + } return columns, diagnostics } func expressionColumn(expr parser.IExprContext, relations []relation, declared map[string]model.Type, grouped bool, functions *builtins.Registry) (model.Column, bool, error) { + for inner := parenthesizedExpression(expr); inner != nil; inner = parenthesizedExpression(expr) { + expr = inner + } scope := expressionScope{relations: relations, bindings: declared, grouped: grouped, functions: functions} if typeValue, ok, err := resolveMemberAccess(expr, scope); ok { return model.Column{Type: typeValue}, false, err @@ -617,29 +629,25 @@ func expressionColumn(expr parser.IExprContext, relations []relation, declared m return model.Column{Type: typeValue}, false, err } -func concatenationType(expr parser.IExprContext, declared map[string]model.Type) (model.Type, bool, error) { - var concatenations []*parser.Mul_subexprContext - descendants(expr, func(node antlr.Tree) { - ctx, ok := node.(*parser.Mul_subexprContext) - if ok && len(ctx.AllDOUBLE_PIPE()) != 0 { - concatenations = append(concatenations, ctx) - } - }) - if len(concatenations) == 0 { +func concatenationType(root antlr.ParserRuleContext, scope expressionScope) (model.Type, bool, error) { + operation, ok := coveringExpressionContext(root).(*parser.Mul_subexprContext) + if !ok || len(operation.AllDOUBLE_PIPE()) == 0 { return model.Type{}, false, nil } - if len(concatenations) != 1 || concatenations[0].GetStart() != expr.GetStart() || concatenations[0].GetStop() != expr.GetStop() { - return model.Type{}, true, fmt.Errorf("nested concatenation result expressions are not supported") - } - operands := concatenations[0].AllCon_subexpr() + operands := operation.AllCon_subexpr() var result model.Type optional := false + null := false for _, operand := range operands { - typeValue, err := concatenationOperandType(operand, declared) + typeValue, err := resolveScalarNode(operand, scope) if err != nil { return model.Type{}, true, err } + if typeValue.Kind == "Null" { + null = true + continue + } optional = optional || typeValue.IsOptional() base := typeValue.UnwrapOptional() if base.Kind != "String" && base.Kind != "Utf8" { @@ -651,37 +659,15 @@ func concatenationType(expr parser.IExprContext, declared map[string]model.Type) return model.Type{}, true, fmt.Errorf("concatenation operands must both be String or both be Utf8") } } + if null { + return model.Type{Kind: "Null"}, true, nil + } if optional { result = model.Optional(result) } return result, true, nil } -func concatenationOperandType(operand parser.ICon_subexprContext, declared map[string]model.Type) (model.Type, error) { - var binds []parser.IBind_parameterContext - var literals []parser.ILiteral_valueContext - descendants(operand, func(node antlr.Tree) { - switch ctx := node.(type) { - case *parser.Bind_parameterContext: - binds = append(binds, ctx) - case parser.ILiteral_valueContext: - literals = append(literals, ctx) - } - }) - if len(binds) == 1 && len(literals) == 0 && operand.GetText() == binds[0].GetText() { - name := bindName(binds[0]) - typeValue, ok := declared[name] - if !ok { - return model.Type{}, fmt.Errorf("cannot resolve type of parameter $%s in concatenation", name) - } - return typeValue, nil - } - if len(literals) == 1 && len(binds) == 0 && operand.GetText() == literals[0].GetText() && literals[0].STRING_VALUE() != nil { - return stringLiteralType(literals[0].GetText()) - } - return model.Type{}, fmt.Errorf("unsupported concatenation operand %q; only string literals and declared parameters are supported", operand.GetText()) -} - type columnRef struct { qualifier, name string ctx antlr.ParserRuleContext @@ -729,7 +715,7 @@ func qualifiedName(ref columnRef) string { return ref.qualifier + "." + ref.name } -func validateColumnReferences(block queryBlock, root antlr.Tree, relations []relation) []model.Diagnostic { +func validateColumnReferences(block queryBlock, root antlr.Tree, relations []relation, projection []model.Column) []model.Diagnostic { var diagnostics []model.Diagnostic seen := map[int]bool{} for _, ref := range columnRefs(root) { @@ -738,6 +724,15 @@ func validateColumnReferences(block queryBlock, root antlr.Tree, relations []rel continue } seen[position] = true + if ref.qualifier == "" && isOrderByReference(ref.ctx) { + found := false + for _, column := range projection { + found = found || column.ResultName() == ref.name + } + if found { + continue + } + } if _, err := resolveColumn(relations, ref); err != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, ref.ctx, err.Error())) } @@ -745,6 +740,18 @@ func validateColumnReferences(block queryBlock, root antlr.Tree, relations []rel return diagnostics } +func isOrderByReference(root antlr.Tree) bool { + for node := root.GetParent(); node != nil; node = node.GetParent() { + switch node.(type) { + case *parser.Order_by_clauseContext: + return true + case *parser.Select_coreContext: + return false + } + } + return false +} + func resolveColumn(relations []relation, ref columnRef) (model.Column, error) { var matches []model.Column for _, rel := range relations { diff --git a/internal/analyzer/shared_expression_test.go b/internal/analyzer/shared_expression_test.go new file mode 100644 index 00000000..dd53867e --- /dev/null +++ b/internal/analyzer/shared_expression_test.go @@ -0,0 +1,259 @@ +package analyzer + +import ( + "reflect" + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +const sharedExpressionSchema = `CREATE TABLE records ( + id Uint64 NOT NULL, + enabled Bool NOT NULL, + optional_flag Bool, + optional_id Uint64, + counter Uint32, + label String, + PRIMARY KEY(id) +);` + +func TestParenthesizedColumnRetainsIdentity(t *testing.T) { + for _, expression := range []string{"(id)", "((r.id))"} { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\nSELECT " + expression + " FROM records AS r;"}}) + if err != nil { + t.Fatal(err) + } + column := result.Queries[0].ResultSets[0].Columns[0] + if column.Name != "id" || column.Table != "records" || column.Type.Kind != "Uint64" { + t.Fatalf("parenthesized column lost identity: %#v", column) + } + } +} + +func TestOrderByProjectionAliasesAreScoped(t *testing.T) { + for _, statement := range []string{ + "SELECT id + 1ul AS next_id FROM records ORDER BY next_id;", + "SELECT id + 1ul FROM records ORDER BY column0;", + "SELECT id, id + 1ul AS next_id FROM records ORDER BY next_id, id;", + } { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + statement}}) + if err != nil { + t.Fatalf("ORDER BY result name: %v", err) + } + } + for _, statement := range []string{ + "SELECT id + 1ul AS next_id FROM records WHERE next_id > 0ul;", + "SELECT id + 1ul AS next_id, next_id AS another FROM records;", + "SELECT id + 1ul AS next_id FROM records ORDER BY records.next_id;", + } { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + statement}}) + if err == nil || !strings.Contains(err.Error(), "unknown column") { + t.Fatalf("out-of-scope result name: %v", err) + } + } +} + +func TestSharedBooleanExpressions(t *testing.T) { + for _, tc := range []struct{ expression, typ string }{ + {"id > 0ul", "Bool"}, + {"optional_id > 0ul", "Optional"}, + {"optional_id IS NULL", "Bool"}, + {"optional_id IS NOT NULL", "Bool"}, + {"enabled AND id > 0ul", "Bool"}, + {"enabled OR optional_flag", "Optional"}, + {"enabled XOR optional_flag", "Optional"}, + {"NOT enabled", "Bool"}, + {"NOT optional_flag", "Optional"}, + {"NOT (enabled AND (optional_id > 0ul OR optional_flag))", "Optional"}, + {"IF(enabled AND id > 0ul, true, optional_flag)", "Optional"}, + {"COALESCE(optional_id > 0ul, false)", "Bool"}, + {"NULL AND enabled", "Optional"}, + {"NULL OR false", "Optional"}, + {"NULL XOR true", "Optional"}, + {"NULL AND NULL", "Optional"}, + {"NULL = NULL", "Optional"}, + {"NULL IS DISTINCT FROM NULL", "Bool"}, + {"NULL IS NOT DISTINCT FROM NULL", "Bool"}, + {"COALESCE(NOT NULL, false)", "Bool"}, + {"COALESCE(NULL || 'x', 'fallback') = 'fallback'", "Bool"}, + {"optional_id IS DISTINCT FROM id", "Bool"}, + {"optional_id IS NOT DISTINCT FROM id", "Bool"}, + } { + t.Run(tc.expression, func(t *testing.T) { + sql := "-- name: Read :many\nSELECT " + tc.expression + " AS value FROM records;" + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + query := result.Queries[0] + if query.ResultSets[0].Columns[0].Type.String() != tc.typ || query.SQL != sql { + t.Fatalf("query = %#v; want type %s and unchanged SQL", query, tc.typ) + } + }) + } +} + +func TestSharedBooleanBindingsAndDML(t *testing.T) { + for _, statement := range []string{ + "SELECT $valid AS valid;", + "UPDATE records SET enabled = $valid, optional_flag = NOT ($valid AND optional_flag) WHERE id = $id;", + "INSERT INTO records(id, enabled, optional_flag) VALUES ($id, $valid, $valid AND $flag);", + } { + sql := "-- name: Read :exec\nDECLARE $id AS Uint64; DECLARE $flag AS Bool?; $valid = ($id > 0ul) AND NOT ($flag IS NULL); " + statement + if strings.HasPrefix(statement, "SELECT") { + sql = strings.Replace(sql, ":exec", ":one", 1) + } + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + if result.Queries[0].SQL != sql || !reflect.DeepEqual(result.Queries[0].Parameters, []model.Parameter{{Name: "id", Type: model.Type{Kind: "Uint64"}}, {Name: "flag", Type: model.Optional(model.Type{Kind: "Bool"})}}) { + t.Fatalf("query = %#v", result.Queries[0]) + } + } +} + +func TestSharedBooleanExpressionsRejectNonBooleans(t *testing.T) { + for _, expression := range []string{"enabled AND id", "id OR optional_flag", "enabled XOR label", "NOT id", "NOT id = 1ul"} { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\nSELECT " + expression + " AS value FROM records;"}}) + if err == nil || !strings.Contains(err.Error(), "want Bool") { + t.Fatalf("expression %s: error = %v", expression, err) + } + } + _, err := Analyze(nil, []model.Source{{Name: "query.sql", Text: "-- name: Read :one\nDECLARE $flag AS Optional>; SELECT $flag AND true AS value;"}}) + if err == nil || !strings.Contains(err.Error(), "want Bool or Optional") { + t.Fatalf("nested optional Boolean error = %v", err) + } +} + +func TestSharedConcatenationOperands(t *testing.T) { + for _, statement := range []string{ + "SELECT label || $prefix || '%' AS value FROM records;", + "SELECT id FROM records WHERE label LIKE ($prefix || '%');", + "SELECT id FROM records WHERE ($prefix || label) = ('pre' || $prefix);", + "UPDATE records SET label = COALESCE(label, '') || $prefix WHERE label LIKE $prefix || '%';", + } { + command := ":many" + if strings.HasPrefix(statement, "UPDATE") { + command = ":exec" + } + sql := "-- name: Read " + command + "\nDECLARE $prefix AS String; " + statement + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + if result.Queries[0].SQL != sql { + t.Fatal("concatenation SQL changed") + } + if strings.HasPrefix(statement, "SELECT label") && result.Queries[0].ResultSets[0].Columns[0].Type.String() != "Optional" { + t.Fatalf("columns = %#v", result.Queries[0].ResultSets[0].Columns) + } + } +} + +func TestSharedEmptyStringLiterals(t *testing.T) { + for _, literal := range []string{"''", `""`, "@@@@"} { + result, err := Analyze(nil, []model.Source{{Name: "query.sql", Text: "-- name: Empty :one\nSELECT " + literal + " || 'value' AS value;"}}) + if err != nil { + t.Fatal(err) + } + if typ := result.Queries[0].ResultSets[0].Columns[0].Type; typ.Kind != "String" { + t.Fatalf("literal %s: type %s", literal, typ.String()) + } + } +} + +func TestSharedConcatenationRejectsUnresolvedOperands(t *testing.T) { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\nSELECT id FROM records WHERE label LIKE Unknown::Pattern() || '%';"}}) + if err == nil || !strings.Contains(err.Error(), `unsupported YQL function "Unknown::Pattern"`) { + t.Fatalf("concat operand error = %v", err) + } +} + +func TestSharedNullPredicatesAndConcatenation(t *testing.T) { + for _, predicate := range []string{"NULL", "NOT NULL", "enabled OR NULL", "NULL = NULL", "NULL IS DISTINCT FROM NULL"} { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\nSELECT id FROM records WHERE " + predicate + ";"}}) + if err != nil { + t.Fatalf("predicate %s: %v", predicate, err) + } + } + for _, expression := range []string{"NULL || 'x'", "'x' || NULL", "NULL || NULL"} { + _, err := Analyze(nil, []model.Source{{Name: "query.sql", Text: "-- name: Read :one\nSELECT " + expression + " AS value;"}}) + if err == nil || !strings.Contains(err.Error(), "unresolved Null type") { + t.Fatalf("concat %s must retain its Null result: %v", expression, err) + } + } +} + +func TestCountIfAggregateSemantics(t *testing.T) { + for _, statement := range []string{ + "SELECT COUNT_IF(optional_flag) AS total FROM records;", + "SELECT COUNT_IF(id > 0ul AND optional_flag) AS total FROM records;", + "SELECT enabled, COUNT_IF(id > 0ul) AS total FROM records GROUP BY enabled HAVING COUNT_IF(optional_flag) > 0ul;", + } { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + statement}}) + if err != nil { + t.Fatal(err) + } + columns := result.Queries[0].ResultSets[0].Columns + if columns[len(columns)-1].Type.String() != "Uint64" { + t.Fatalf("COUNT_IF columns = %#v", columns) + } + } + for _, tc := range []struct{ statement, message string }{ + {"SELECT id, COUNT_IF(enabled) AS total FROM records;", "must appear in GROUP BY"}, + {"SELECT COUNT_IF(COUNT(*) > 0ul) AS total FROM records;", "cannot contain another aggregate"}, + {"SELECT SUM(COUNT_IF(enabled)) AS total FROM records;", "cannot contain another aggregate"}, + {"UPDATE records SET id = COUNT_IF(enabled);", "aggregate functions are not allowed in DML values"}, + } { + command := ":many" + if strings.HasPrefix(tc.statement, "UPDATE") { + command = ":exec" + } + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read " + command + "\n" + tc.statement}}) + if err == nil || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("%s: error = %v, want %q", tc.statement, err, tc.message) + } + } +} + +func TestAggregateFunctionsRequireAggregationContext(t *testing.T) { + for _, statement := range []string{ + "SELECT COUNT(*) AS value;", + "SELECT COUNT(true) AS value;", + "SELECT SUM(1) AS value;", + "SELECT COUNT_IF(true) AS value;", + "$value = COUNT_IF(true); SELECT $value AS value FROM records;", + "SELECT id FROM records WHERE COUNT_IF(enabled) > 0ul;", + "SELECT a.id FROM records AS a JOIN records AS b ON COUNT_IF(a.enabled) = b.id;", + } { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + statement}}) + if err == nil || !strings.Contains(err.Error(), "aggregate") { + t.Fatalf("invalid aggregate context %s: %v", statement, err) + } + } +} + +func TestCoalesceUsesIntegerLiteralValues(t *testing.T) { + for _, tc := range []struct{ expression, typ string }{ + {"COALESCE(counter, 0)", "Uint32"}, + {"COALESCE(counter, (0l))", "Uint32"}, + {"NVL(counter, 0x0ul)", "Uint32"}, + {"COALESCE(counter, 0o1l)", "Uint32"}, + {"COALESCE(counter, 0b1ul)", "Uint32"}, + {"COALESCE(counter, 18446744073709551615ul)", "Uint64"}, + {"COALESCE(counter, 0l, 1ul)", "Uint32"}, + {"COALESCE(0l, counter)", "Int64"}, + {"COALESCE(counter, $fallback)", "Int64"}, + {"COALESCE(counter, 1l + 1l)", "Int64"}, + } { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: sharedExpressionSchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\nDECLARE $fallback AS Int64; SELECT " + tc.expression + " AS value FROM records;"}}) + if err != nil { + t.Fatal(err) + } + if typ := result.Queries[0].ResultSets[0].Columns[0].Type; typ.Kind != tc.typ { + t.Fatalf("%s: type = %s, want %s", tc.expression, typ.String(), tc.typ) + } + } +} diff --git a/internal/analyzer/wildcard.go b/internal/analyzer/wildcard.go index 871c8c22..45bf032a 100644 --- a/internal/analyzer/wildcard.go +++ b/internal/analyzer/wildcard.go @@ -53,7 +53,7 @@ func (r *wildcardRewrites) apply() (string, error) { var sql strings.Builder cursor := 0 for _, replacement := range r.replacements { - if replacement.start < cursor || replacement.end > len(r.source) || replacement.start >= replacement.end { + if replacement.start < cursor || replacement.end > len(r.source) || replacement.start > replacement.end { return "", fmt.Errorf("invalid or overlapping wildcard source span") } sql.WriteString(r.source[cursor:replacement.start]) diff --git a/internal/codegen/java/generator.go b/internal/codegen/java/generator.go index 504b3b28..1a1d140a 100644 --- a/internal/codegen/java/generator.go +++ b/internal/codegen/java/generator.go @@ -469,7 +469,13 @@ func emitRows(b *strings.Builder, q model.AnalyzedQuery, row, indent string, nat } func indentExpression(s, indent string) string { - return strings.ReplaceAll(s, "\n", "\n"+indent) + lines := strings.Split(s, "\n") + for i := 1; i < len(lines); i++ { + if lines[i] != "" { + lines[i] = indent + lines[i] + } + } + return strings.Join(lines, "\n") } func emitJDBCNamedParameter(b *strings.Builder, p model.Parameter, n, indent string) { diff --git a/internal/codegen/java/jooq.go b/internal/codegen/java/jooq.go index d1bc9646..887df9fd 100644 --- a/internal/codegen/java/jooq.go +++ b/internal/codegen/java/jooq.go @@ -156,6 +156,7 @@ func generateJooq(a *model.AnalysisResult, o Options) ([]model.File, error) { return nil, err } ret := "void" + var resultFields []string if q.Command == model.One || q.Command == model.Many { if len(q.ResultSets) != 1 || len(q.ResultSets[0].Columns) == 0 { return nil, fmt.Errorf("%s: jOOQ requires one result set", q.Name) @@ -171,11 +172,12 @@ func generateJooq(a *model.AnalysisResult, o Options) ([]model.File, error) { return nil, fmt.Errorf("%s: Java field collision: %s", q.Name, fn) } seen[fn] = true - typ, _, e := jooqType(c.Type) + typ, dataType, e := jooqType(c.Type) if e != nil { return nil, e } fields = append(fields, typ+" "+fn) + resultFields = append(resultFields, "field(name("+quoted(c.ResultName())+"), "+dataType+")") } files = append(files, model.File{Name: row + ".java", Content: []byte(header + "public record " + row + "(" + strings.Join(fields, ", ") + ") {}\n")}) ret = "Optional<" + row + ">" @@ -265,16 +267,11 @@ func generateJooq(a *model.AnalysisResult, o Options) ([]model.File, error) { if q.Command == model.Exec { fmt.Fprintf(&b, " dsl.query(\"{0};\\n{1}\", sql(%s), stmt).execute();\n", sqlLiteral(text)) } else { - var fields []string - for _, c := range q.ResultSets[0].Columns { - _, dataType, _ := jooqType(c.Type) - fields = append(fields, "field(name("+quoted(c.ResultName())+"), "+dataType+")") - } fetch := "fetchOptional" if q.Command == model.Many { fetch = "fetch" } - fmt.Fprintf(&b, " return dsl.resultQuery(\"{0};\\n{1}\", sql(%s), stmt)\n .coerce(%s)\n .%s(mapping(%s::new));\n", sqlLiteral(text), strings.Join(fields, ", "), fetch, row) + fmt.Fprintf(&b, " return dsl.resultQuery(\"{0};\\n{1}\", sql(%s), stmt)\n .coerce(%s)\n .%s(mapping(%s::new));\n", sqlLiteral(text), strings.Join(resultFields, ", "), fetch, row) } } else if q.Command == model.Exec { b.WriteString(" " + body + "\n .execute();\n") @@ -293,7 +290,7 @@ func generateJooq(a *model.AnalysisResult, o Options) ([]model.File, error) { // executeUpdate/getGeneratedKeys. YDB returns an ordinary result set. fmt.Fprintf(&b, " var stmt = %s;\n\n // YDB RETURNING produces a result set, not JDBC generated keys.\n return dsl.resultQuery(\"{0}\", stmt)\n .coerce(%s)\n .%s(mapping(%s::new));\n", body, strings.Join(fields, ", "), fetch, row) } else { - fmt.Fprintf(&b, " return %s\n .%s(mapping(%s::new));\n", body, fetch, row) + fmt.Fprintf(&b, " return %s\n .coerce(%s)\n .%s(mapping(%s::new));\n", body, strings.Join(resultFields, ", "), fetch, row) } } b.WriteString(" }\n") @@ -353,6 +350,11 @@ func (r *jooqRenderer) expr(n antlr.Tree) string { } } switch node := n.(type) { + case *parser.Cast_exprContext: + if node.Type_name_or_bind() == nil || !strings.EqualFold(node.Type_name_or_bind().GetText(), "Bool") { + return r.fail(n) + } + return r.expr(node.Expr()) + ".cast(YdbTypes.BOOL)" case *parser.Bind_parameterContext: key := strings.TrimPrefix(node.GetText(), "$") pn, ok := r.parameters[key] @@ -408,6 +410,8 @@ func (r *jooqRenderer) expr(n antlr.Tree) string { } } switch strings.ToLower(function) { + case "count_if": + return jooqCall("function", append([]string{"systemName(\"COUNT_IF\")", "YdbTypes.UINT64"}, args...)) case "count": if inv.GetText() == "(*)" { return "count().coerce(YdbTypes.UINT64)" @@ -434,6 +438,13 @@ func (r *jooqRenderer) expr(n antlr.Tree) string { return r.expr(children[1]) + ".not()" } if cond, ok := children[1].(*parser.Cond_exprContext); ok { + if cond.NULL() != nil || cond.ISNULL() != nil || cond.NOTNULL() != nil { + method := "isNull" + if cond.NOT() != nil || cond.NOTNULL() != nil { + method = "isNotNull" + } + return r.expr(children[0]) + "." + method + "()" + } parts := cond.GetChildren() if len(parts) == 2 { op := parts[0].(antlr.ParseTree).GetText() diff --git a/internal/codegen/java/jooq_expressions_test.go b/internal/codegen/java/jooq_expressions_test.go new file mode 100644 index 00000000..287bebaa --- /dev/null +++ b/internal/codegen/java/jooq_expressions_test.go @@ -0,0 +1,63 @@ +package java + +import ( + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestJooqConditionalAggregateExpressions(t *testing.T) { + analysis, err := analyzer.Analyze( + []model.Source{{Name: "schema.sql", Text: "CREATE TABLE items (id Uint64 NOT NULL, note Utf8, PRIMARY KEY(id));"}}, + []model.Source{{Name: "query.sql", Text: "-- name: Statistics :one\nSELECT COUNT_IF(note IS NOT NULL) AS present, COUNT_IF(note != \"\"u) AS nonempty, CAST(COUNT(*) AS Bool) FROM items;"}}, + ) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "db", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + var queries string + for _, file := range files { + if file.Name == "Queries.java" { + queries = string(file.Content) + } + } + for _, expression := range []string{ + `systemName("COUNT_IF")`, `YdbTypes.UINT64`, `ITEMS.NOTE.isNotNull()`, + `ITEMS.NOTE.ne(inline("", YdbTypes.UTF8))`, `count().coerce(YdbTypes.UINT64).cast(YdbTypes.BOOL)`, + } { + if !strings.Contains(queries, expression) { + t.Errorf("missing %s in generated query:\n%s", expression, queries) + } + } +} + +func TestJooqNullChecks(t *testing.T) { + for _, tc := range []struct{ sql, method string }{ + {"IS NULL", "isNull"}, {"IS NOT NULL", "isNotNull"}, + {"ISNULL", "isNull"}, {"NOTNULL", "isNotNull"}, + } { + t.Run(tc.sql, func(t *testing.T) { + analysis, err := analyzer.Analyze( + []model.Source{{Name: "schema.sql", Text: "CREATE TABLE items (id Uint64 NOT NULL, note Utf8, PRIMARY KEY(id));"}}, + []model.Source{{Name: "query.sql", Text: "-- name: CheckNote :many\nSELECT note " + tc.sql + " AS missing FROM items;"}}, + ) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "db", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if file.Name == "Queries.java" && !strings.Contains(string(file.Content), "ITEMS.NOTE."+tc.method+"()") { + t.Fatalf("wrong null predicate: %s", file.Content) + } + } + }) + } +} diff --git a/internal/codegen/java/jooq_result_order_test.go b/internal/codegen/java/jooq_result_order_test.go new file mode 100644 index 00000000..0c2b1b29 --- /dev/null +++ b/internal/codegen/java/jooq_result_order_test.go @@ -0,0 +1,95 @@ +package java + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestJooqImplicitColumnCollisionResultOrder(t *testing.T) { + analysis, err := analyzer.Analyze(nil, []model.Source{{Name: "query.sql", Text: `-- name: ReadOne :one +SELECT "z"u AS z, 2 AS column2, 3, 4; +-- name: ReadMany :many +SELECT "z"u AS z, 2 AS column2, 3, 4;`}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "resultorder", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.HasSuffix(file.Name, "Row.java") && !strings.Contains(string(file.Content), "Integer column2, Integer column3, Integer column4, String z") { + t.Fatalf("unexpected resolved row: %s", file.Content) + } + } + t.Run("published dialect", func(t *testing.T) { + maven := os.Getenv("SQLC_YDB_TEST_MAVEN") + if maven == "" { + t.Skip("set SQLC_YDB_TEST_MAVEN to compile and execute collision-ordered rows against the published dialect") + } + dir := t.TempDir() + classpath := filepath.Join(dir, "classpath") + cmd := exec.Command(maven, "-q", "dependency:build-classpath", "-Dmdep.outputFile="+classpath) + cmd.Dir = filepath.Join("..", "..", "..", "tests", "examples", "java", "jooq") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("SDK classpath: %v\n%s", err, out) + } + cp, err := os.ReadFile(classpath) + if err != nil { + t.Fatal(err) + } + program := `package resultorder; +import java.util.*; +import org.jooq.tools.jdbc.*; +import tech.ydb.jooq.YDB; +import tech.ydb.jooq.YdbTypes; +import static org.jooq.impl.DSL.*; +public class Main { + public static void main(String[] args) throws Exception { + var statements = new ArrayList(); + try (var connection = new MockConnection(ctx -> { + String sql = ctx.sql().replace("` + "`" + `", "").replaceAll("\\s+", " ").trim().toLowerCase(Locale.ROOT); + statements.add(sql); + if (!sql.equals("select 'z' z, 2 column2, 3, 4")) throw new AssertionError(sql); + if (ctx.bindings().length != 0) throw new AssertionError(Arrays.toString(ctx.bindings())); + var dsl = YDB.using(); + var column2 = field(name("column2"), YdbTypes.INT32); + var column3 = field(name("column3"), YdbTypes.INT32); + var column4 = field(name("column4"), YdbTypes.INT32); + var z = field(name("z"), YdbTypes.UTF8); + // YDB returns collision-renamed expressions in lexical result-name order. + var result = dsl.newResult(column2, column3, column4, z); + result.add(dsl.newRecord(column2, column3, column4, z).values(2, 3, 4, "z")); + return new MockResult[]{new MockResult(1, result)}; + })) { + var queries = new Queries(YDB.using(connection)); + var one = queries.readOne().orElseThrow(); + if (!one.equals(new ReadOneRow(2, 3, 4, "z"))) throw new AssertionError(one); + var many = queries.readMany(); + if (!many.equals(List.of(new ReadManyRow(2, 3, 4, "z")))) throw new AssertionError(many); + if (statements.size() != 2) throw new AssertionError(statements); + } + } +}` + files = append(files, model.File{Name: "Main.java", Content: []byte(program)}) + compile := []string{"-cp", strings.TrimSpace(string(cp)), "-d", dir} + for _, file := range files { + path := filepath.Join(dir, file.Name) + if err := os.WriteFile(path, file.Content, 0600); err != nil { + t.Fatal(err) + } + compile = append(compile, path) + } + for _, args := range [][]string{append([]string{"javac"}, compile...), {"java", "-cp", dir + string(os.PathListSeparator) + strings.TrimSpace(string(cp)), "resultorder.Main"}} { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", args[0], err, out) + } + } + }) +} diff --git a/internal/codegen/java/jooq_test.go b/internal/codegen/java/jooq_test.go index 6fe3cf8f..b551df01 100644 --- a/internal/codegen/java/jooq_test.go +++ b/internal/codegen/java/jooq_test.go @@ -223,6 +223,12 @@ func TestJooqDeclaredSQLBytesThroughJava(t *testing.T) { if err != nil { t.Fatal(err) } + expression = indentExpression(expression, " ") + for _, line := range strings.Split(expression, "\n") { + if line != "" && strings.TrimSpace(line) == "" { + t.Fatalf("mapped SQL has a whitespace-only source line: %q", line) + } + } want := "DECLARE $id AS Uint64;\n\n-- Автор 🚀\n" + tc.want if i == 0 { want = "DECLARE $title AS Utf8;\n" + want diff --git a/internal/endtoend/shared_expressions_live_test.go b/internal/endtoend/shared_expressions_live_test.go new file mode 100644 index 00000000..92b2ee47 --- /dev/null +++ b/internal/endtoend/shared_expressions_live_test.go @@ -0,0 +1,229 @@ +package endtoend + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/cli" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestLiveYDBSharedExpressions(t *testing.T) { + dsn := os.Getenv("YDB_CONNECTION_STRING") + if dsn == "" { + t.Skip("set YDB_CONNECTION_STRING for shared expression validation") + } + dir := t.TempDir() + table := fmt.Sprintf("sqlc_expressions_%d", time.Now().UnixNano()) + schema := "CREATE TABLE " + table + " (id Uint64 NOT NULL, enabled Bool NOT NULL, flag Bool, counter Uint32, text String, PRIMARY KEY(id));" + queries := strings.ReplaceAll(sharedExpressionsQueries, "records", table) + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "queries.sql", Text: queries}}) + if err != nil { + t.Fatal(err) + } + configuration := "version: '2'\nsql:\n" + for _, runtime := range []string{"ydb", "database/sql"} { + configuration += "- engine: ydb\n schema: schema.sql\n queries: queries.sql\n gen:\n go:\n package: records\n out: " + strings.ReplaceAll(runtime, "/", "_") + "\n sql_package: " + runtime + "\n" + } + for name, contents := range map[string]string{"schema.sql": schema, "queries.sql": queries, "sqlc.yaml": configuration, "go.mod": "module generated\n\ngo 1.26.0\n\nrequire github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1\n"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + var stdout, stderr bytes.Buffer + if code := cli.Run([]string{"generate", "-f", filepath.Join(dir, "sqlc.yaml")}, &stdout, &stderr); code != 0 { + t.Fatalf("generate shared expressions: %s", stderr.String()) + } + // Timezone types are analyzed and compared with server metadata separately; + // their Go row representation is outside the generated scalar subset. + timezones, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "timezone.sql", Text: "-- name: Timezones :many\nSELECT CurrentTzDate('Europe/Moscow') AS day, CurrentTzDatetime(CAST('Europe/Moscow' AS String?)) AS seconds, CurrentTzTimestamp('invalid-zone') AS micros FROM " + table + " LIMIT 0;"}}) + if err != nil { + t.Fatal(err) + } + analysis.Queries = append(analysis.Queries, timezones.Queries...) + var metadata strings.Builder + for _, query := range analysis.Queries { + fmt.Fprintf(&metadata, "\n checkMetadata(t,ctx,driver,%q,[]string{", query.SQL) + for _, column := range query.ResultSets[0].Columns { + fmt.Fprintf(&metadata, "%q,", column.ResultName()) + } + metadata.WriteString("},[]string{") + for _, column := range query.ResultSets[0].Columns { + fmt.Fprintf(&metadata, "%q,", column.Type.String()) + } + metadata.WriteString("})\n") + } + for _, runtime := range []string{"ydb", "database/sql"} { + t.Run(runtime, func(t *testing.T) { + setup, sqlImport := "q := New(driver.Query())", "" + if runtime == "database/sql" { + setup = "db := sql.OpenDB(ydb.MustConnector(driver)); defer db.Close(); q := New(db)" + sqlImport = "\"database/sql\"" + } + source := strings.NewReplacer("$DSN", strconv.Quote(dsn), "$SCHEMA", strconv.Quote(schema), "$TABLE", strconv.Quote(table), "$SETUP", setup, "$SQL_IMPORT", sqlImport, "$METADATA", metadata.String()).Replace(sharedExpressionsRuntime) + compileTypedDMLPackage(t, dir, "./"+strings.ReplaceAll(runtime, "/", "_"), source, false) + }) + } +} + +const sharedExpressionsQueries = `-- name: Logic :many +SELECT id, flag AND enabled AS both, flag OR enabled AS either, flag XOR enabled AS differing, + NOT flag AS inverted, flag IS NULL AS absent, flag IS DISTINCT FROM enabled AS distinct_flag, + text || '!' AS appended, COALESCE(counter, 0l) AS counter_or_zero +FROM records ORDER BY id; + +-- name: Statistics :one +SELECT COUNT(*) AS total, COUNT_IF(flag) AS flagged, COUNT_IF(NULL) AS null_count, CAST(COUNT(*) AS Bool) +FROM records; + +-- name: EmptyStatistics :one +SELECT COUNT_IF(flag) AS flagged FROM records WHERE false; + +-- name: Grouped :many +SELECT enabled, COUNT_IF(flag) AS flagged, COUNT_IF(flag IS NOT NULL) AS present +FROM records GROUP BY enabled ORDER BY enabled; + +-- name: Casts :many +SELECT id, CAST(id AS Bool) AS active, CAST(enabled AS Uint64) AS numeric, + CAST(id AS Uint32) AS narrow, CAST(text AS Json) AS document, COALESCE(CAST(id AS Uint32), 0) +FROM records ORDER BY id; + +-- name: Names :one +SELECT 'last' AS z, 2 AS column2, 3, 4; + +-- name: Mixed :many +SELECT t.*, COALESCE(counter, 0u) FROM records AS t ORDER BY column1; + +-- name: Builtins :one +SELECT CurrentUtcDate() AS today, CurrentUtcDatetime(1) AS seconds, CurrentUtcTimestamp(NULL) AS micros, + Random(1) AS random_value, RandomNumber(1) AS random_number, RandomUuid(NULL) AS random_uuid, + Version() AS version, NANVL(CAST('NaN' AS Double), 1.5) AS replacement, + CAST(CurrentUtcTimestamp() AS String) AS timestamp_text, + CAST(CurrentUtcTimestamp() AS Uint64) AS timestamp_number, + CAST(18446744073709551615ul AS Timestamp) AS invalid_timestamp; +` + +const sharedExpressionsRuntime = `package records + +import ( + "context" + ydb "github.com/ydb-platform/ydb-go-sdk/v3" + "io" + "reflect" + $SQL_IMPORT + "testing" + "time" +) + +func TestSharedExpressions(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + driver, err := ydb.Open(ctx, $DSN, ydb.WithAnonymousCredentials()) + if err != nil { + t.Fatal(err) + } + defer driver.Close(ctx) + table := $TABLE + if err := driver.Query().Exec(ctx, $SCHEMA); err != nil { + t.Fatal(err) + } + defer func() { + cleanup, done := context.WithTimeout(context.Background(), 10*time.Second) + defer done() + if err := driver.Query().Exec(cleanup, "DROP TABLE "+table); err != nil { + t.Error(err) + } + }() + if err := driver.Query().Exec(ctx, "UPSERT INTO "+table+" (id,enabled,flag,counter,text) VALUES (1ul,false,NULL,NULL,NULL),(2ul,true,true,7u,'{}'),(18446744073709551615ul,true,false,4294967295u,'bad');"); err != nil { + t.Fatal(err) + } + $SETUP + $METADATA + rows, err := q.Logic(ctx) + if err != nil || len(rows) != 3 { + t.Fatalf("logic: %+v %v", rows, err) + } + first := rows[0] + if first.Both == nil || *first.Both || first.Either != nil || first.Differing != nil || first.Inverted != nil || !first.Absent || !first.DistinctFlag || first.Appended != nil || first.CounterOrZero != 0 { + t.Fatalf("nullable Boolean/string behavior: %+v", first) + } + second := rows[1] + if second.Both == nil || !*second.Both || second.Either == nil || !*second.Either || second.Differing == nil || *second.Differing || second.Inverted == nil || *second.Inverted || second.Absent || second.DistinctFlag || second.Appended == nil || string(*second.Appended) != "{}!" || second.CounterOrZero != 7 { + t.Fatalf("non-null behavior: %+v", second) + } + stats, err := q.Statistics(ctx) + if err != nil || stats.Total != 3 || stats.Flagged != 1 || stats.NullCount != 0 || !stats.Column3 { + t.Fatalf("global aggregate: %+v %v", stats, err) + } + empty, err := q.EmptyStatistics(ctx) + if err != nil || empty.Flagged != 0 { + t.Fatalf("empty aggregate: %+v %v", empty, err) + } + groups, err := q.Grouped(ctx) + if err != nil || len(groups) != 2 || groups[0].Enabled || groups[0].Flagged != 0 || groups[0].Present != 0 || !groups[1].Enabled || groups[1].Flagged != 1 || groups[1].Present != 2 { + t.Fatalf("grouped aggregate: %+v %v", groups, err) + } + casts, err := q.Casts(ctx) + if err != nil || len(casts) != 3 { + t.Fatalf("casts: %+v %v", casts, err) + } + if casts[0].Document != nil || casts[1].Document == nil || *casts[1].Document != "{}" || casts[2].Document != nil || casts[2].Narrow != nil || casts[2].Column5 != 0 || !casts[2].Active || casts[2].Numeric != 1 { + t.Fatalf("cast validation/boundaries: %+v", casts) + } + names, err := q.Names(ctx) + if err != nil || string(names.Z) != "last" || names.Column2 != 2 || names.Column3 != 3 || names.Column4 != 4 { + t.Fatalf("collision names and positional order: %+v %v", names, err) + } + mixed, err := q.Mixed(ctx) + if err != nil || len(mixed) != 3 || mixed[0].ID != 1 || mixed[0].Column1 != 0 || mixed[1].ID != 2 || mixed[1].Column1 != 7 { + t.Fatalf("wildcard and original implicit ORDER BY name: %+v %v", mixed, err) + } + before := time.Now().Add(-time.Minute) + builtins, err := q.Builtins(ctx) + if err != nil || builtins.Today.IsZero() || builtins.Seconds.Before(before) || builtins.Micros.Before(before) || builtins.RandomValue < 0 || builtins.RandomValue >= 1 || len(builtins.Version) == 0 || builtins.Replacement == nil || *builtins.Replacement != 1.5 || len(builtins.TimestampText) == 0 || builtins.TimestampNumber < uint64(before.UnixMicro()) || builtins.InvalidTimestamp != nil { + t.Fatalf("builtins: %+v %v", builtins, err) + } + for _, statement := range []string{"SELECT COUNT_IF(true);", "SELECT COUNT(*) AS n;", "SELECT 1u AND true;", "SELECT Random();", "SELECT RandomNumber();", "SELECT RandomUuid();"} { + if err := driver.Query().Exec(ctx, statement); err == nil { + t.Errorf("invalid expression accepted: %s", statement) + } + } +} +func checkMetadata(t *testing.T, ctx context.Context, driver *ydb.Driver, statement string, names, types []string) { + t.Helper() + result, err := driver.Query().Query(ctx, statement) + if err != nil { + t.Fatal(err) + } + defer result.Close(ctx) + set, err := result.NextResultSet(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(set.Columns(), names) { + t.Fatalf("metadata names %v, want %v; SQL %s", set.Columns(), names, statement) + } + for i, typ := range set.ColumnTypes() { + if typ.Yql() != types[i] { + t.Fatalf("metadata %s: %s, want %s; SQL %s", names[i], typ.Yql(), types[i], statement) + } + } + for { + if _, err := set.NextRow(ctx); err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + } + if _, err := result.NextResultSet(ctx); err != io.EOF { + t.Fatalf("result completion: %v", err) + } +} +` diff --git a/internal/yql/builtins/README.md b/internal/yql/builtins/README.md index a2b088c5..68a63659 100644 --- a/internal/yql/builtins/README.md +++ b/internal/yql/builtins/README.md @@ -18,10 +18,13 @@ The public package API is: SQL built-in names are case-insensitive: - `COALESCE`/`NVL`, `IF`; +- `NANVL` for Float/Double inputs; +- `CurrentUtcDate`, `CurrentUtcDatetime`, `CurrentUtcTimestamp`, `CurrentTzDate`, `CurrentTzDatetime`, `CurrentTzTimestamp`; +- `Random`, `RandomNumber`, `RandomUuid`, `Version`; - `LENGTH`/`LEN`, `SUBSTRING`, `FIND`, `RFIND`, `StartsWith`, `EndsWith`; - `ABS`; - `ToSet` and `SetIsDisjoint` for concrete List/Dict key types; -- `COUNT`, `MIN`, `MAX`, `SUM`, `AVG`. +- `COUNT`, `COUNT_IF`, `MIN`, `MAX`, `SUM`, `AVG`. C++ library names use the documented, case-sensitive `Module::Function` spelling: @@ -31,15 +34,17 @@ C++ library names use the documented, case-sensitive `Module::Function` spelling - `Yson::ConvertToStringList` for `Json` and `Yson` inputs, including optional inputs; verified YDB behavior keeps its `List` result non-optional. - the scalar `Digest` subset documented in [function signatures](../../../docs/functions.md), including `Digest::CityHash` with its named, omittable `Init` argument. -The aggregate resolver models empty-input behavior: `COUNT` is non-optional `Uint64`; the other supported aggregates are optional when an empty input is possible. A grouped aggregate over a non-optional argument is non-optional and the analyzer removes that wrapper using its group context. `SUM` widens signed and unsigned integers to `Int64` and `Uint64`, respectively, and widens Decimal precision to 35 while preserving its scale. `AVG` converts integer, `Float`, and interval input to `Double`, while preserving Decimal precision and scale. The strict `MIN`/`MAX` subset accepts primitive numeric values plus `String` and `Utf8`. +The aggregate resolver models empty-input behavior: `COUNT` and `COUNT_IF` are non-optional `Uint64`; the other supported aggregates are optional when an empty input is possible. `COUNT_IF` requires Bool or Optional, also accepts contextual NULL, and counts only true values; NULL and empty input do not make its result optional. A grouped aggregate over a non-optional argument is non-optional and the analyzer removes that wrapper using its group context. `SUM` widens signed and unsigned integers to `Int64` and `Uint64`, respectively, and widens Decimal precision to 35 while preserving its scale. `AVG` converts integer, `Float`, and interval input to `Double`, while preserving Decimal precision and scale. The strict `MIN`/`MAX` subset accepts primitive numeric values plus `String` and `Utf8`. `CommonType` implements the documented primitive numeric result matrix and preserves optionality. Non-numeric types must match exactly. Different Decimal precision or scale is rejected rather than inventing Decimal arithmetic rules. -`COALESCE` and `NVL` require all non-`Null` arguments to have the same base type. YQL also permits some value-dependent implicit conversions, such as narrowing an integer literal when its value fits the other argument's type, but `model.Type` does not retain the literal value needed to resolve those calls correctly. Mixed base types therefore require an explicit `CAST` to the same YQL type. `Null` and `Optional` inputs keep their normal result-nullability behavior. +`COALESCE` and `NVL` reconcile arguments pairwise from left to right. Numeric bases use the existing numeric common-type matrix, and mixed String/Utf8 becomes String. A direct right-hand integer literal that fits the accumulated left-hand integer type is resolved as that type. The analyzer retains its exact value in `CallArgument.IntegerLiteral`; parameter values, local bindings and arithmetic expressions are not guessed to be fitting literals. Thus `COALESCE($optional_uint32, 0l)` has type Uint32, while reversing these arguments or using an Int64 parameter/arithmetic fallback has type Int64. A result remains optional only when all available non-NULL alternatives are optional. Other incompatible base types require CAST. This is type analysis only: generated SQL and bound values are unchanged, including YDB's signed/unsigned conversion behavior. + +UTC clock functions return the required date/time type named by the function. Random, RandomNumber and RandomUuid return required Double, Uint64 and Uuid. Their typed positional arguments control evaluation dependencies and do not propagate optionality. UTC clock arguments are optional, while each Random function requires at least one dependency argument (NULL is accepted). CurrentTz functions require a String, Optional or contextual NULL zone followed by optional dependency arguments; their TzDate/TzDatetime/TzTimestamp result is optional because YDB resolves and validates the zone. Utf8 zone arguments are rejected. Version takes no arguments and returns String. NANVL reconciles only Float/Double arguments and preserves optionality. Core `SUBSTRING` accepts `String` or `Optional`; use `Unicode::Substring` for `Utf8`. Core `SUBSTRING` offsets and lengths and the optional third argument of core `FIND`/`RFIND` accept `Null`, `Uint8`, `Uint16`, or `Uint32`, including optional forms. Other integer types require an explicit `CAST(... AS Uint32)`. This is deliberately stricter than YQL's handling of fitting literals because the resolver sees their types but not their values. The C++ `String::` and `Unicode::` library position rules are separate and continue to use `Uint64` where their signatures require it. -`Cast` supports identity, primitive non-Decimal numeric conversions, `String`/`Utf8` parsing to primitive numeric types, primitive numeric conversion to `String`, and conversion between `String` and `Utf8`. A conversion that is not valid for every source value adds one `Optional` level, matching YQL's failure-to-`NULL` rule. Container casts and cross-precision Decimal casts are left unsupported until the analyzer can preserve their full semantics. +`Cast` supports identity, primitive non-Decimal numeric conversions, all eight integer widths to/from Bool, `String`/`Utf8` parsing to primitive numeric types, primitive numeric conversion to `String`, and conversion between `String` and `Utf8`. Date, Datetime and Timestamp to String is total; Timestamp to Uint64 is also total, while Uint64 to Timestamp may fail. String/Utf8 to Json validates the input and may fail even though a particular literal is valid. A conversion that is not valid for every source value adds one `Optional` level, matching YQL's failure-to-`NULL` rule. Container casts and cross-precision Decimal casts are left unsupported until the analyzer can preserve their full semantics. ## Sources @@ -55,6 +60,8 @@ Rules and signatures were checked against the primary YDB documentation: - [`Unicode` library](https://ydb.tech/docs/en/yql/reference/udf/list/unicode) - [`DateTime` library](https://ydb.tech/docs/en/yql/reference/udf/list/datetime) +The additional shared-expression rules were checked on 2026-09-23 against YDB commit `1415fed8104201c5e973dd8bbf12c71c6b1ed8b9`: [basic functions](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/basic.md), [aggregation](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/builtins/aggregation.md), [CAST rules](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/ydb/docs/en/core/yql/reference/types/cast.md), and `CoalesceWrapper`, `DataGeneratorWrapper` and `CurrentTzWrapper` in [type_ann_core.cpp](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/core/type_ann/type_ann_core.cpp). The SQL-level arities are recorded separately in [translation/builtin.cpp](https://github.com/ydb-platform/ydb/blob/1415fed8104201c5e973dd8bbf12c71c6b1ed8b9/yql/essentials/sql/v1/translation/builtin.cpp): Random functions require at least one argument even though their lower-level type wrapper accepts zero. Local YDB 26.3.1.16 metadata and execution probes confirmed COUNT_IF empty/NULL behavior, clock argument/result types, fitting versus nonliteral COALESCE fallbacks, mixed String/Utf8 COALESCE, all integer/Bool cast widths, Date/Datetime/Timestamp casts and String/Utf8 JSON validation. The [complete reference inventory](../../../docs/yql-builtins.md) distinguishes these implemented rules from remaining analyzer/type-model prerequisites. + On 2026-09-09, the package's supported scalar and library signatures were also checked against result-set metadata from the pinned `ydbplatform/local-ydb:26.3.1.8` image through `internal/endtoend/semantic_live_test.go` and `internal/endtoend/builtin_live_cases_test.go`. This caught rules not stated fully in the prose reference: `AVG(Float)` returns `Double`, Decimal `SUM` widens precision to 35, grouped aggregates over non-optional inputs are non-optional, `String::Substring` requires its position argument, core `SUBSTRING` is byte-string-only, and core string positions use the bounded unsigned types through `Uint32`. The function inventory was also compared with `ydb-platform/sqlc@8eed5d890396eb03953248a3ec4ab7e28dfaed45`, especially `internal/engine/ydb/lib/{basic,aggregate,cpp}.go`. Those archived descriptors used broad `any` arguments and incomplete nullability, so they are provenance and coverage input rather than executable type authority. diff --git a/internal/yql/builtins/basic_extended_test.go b/internal/yql/builtins/basic_extended_test.go new file mode 100644 index 00000000..32c65fb7 --- /dev/null +++ b/internal/yql/builtins/basic_extended_test.go @@ -0,0 +1,72 @@ +package builtins + +import ( + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestCurrentAndRandomBuiltinTypes(t *testing.T) { + for name, kind := range map[string]string{ + "CurrentUtcDate": "Date", "CurrentUtcDatetime": "Datetime", "CurrentUtcTimestamp": "Timestamp", + "Random": "Double", "RandomNumber": "Uint64", "RandomUuid": "Uuid", + } { + t.Run(name, func(t *testing.T) { + for _, args := range [][]model.Type{nil, {scalar("Uint32")}, {model.Optional(scalar("String")), scalar("Null")}} { + if len(args) == 0 && strings.HasPrefix(name, "Random") { + if _, err := Resolve(name, args); err == nil { + t.Fatal("random function requires at least one dependency argument") + } + continue + } + assertResolved(t, strings.ToLower(name), args, scalar(kind)) + } + if _, err := Resolve(name, []model.Type{{Kind: "Any"}}); err == nil { + t.Fatal("unresolved dependency must not produce a concrete result") + } + if _, err := defaultRegistry.ResolveCall(name, []CallArgument{{Name: "unexpected", Type: scalar("Uint32")}}); err == nil { + t.Fatal("unexpected named argument accepted") + } + }) + } + assertResolved(t, "Version", nil, scalar("String")) + if _, err := Resolve("Version", []model.Type{scalar("String")}); err == nil { + t.Fatal("Version must not accept arguments") + } +} + +func TestCountIfType(t *testing.T) { + for _, typ := range []model.Type{scalar("Bool"), model.Optional(scalar("Bool")), scalar("Null")} { + assertResolved(t, "COUNT_IF", []model.Type{typ}, scalar("Uint64")) + } + for _, args := range [][]model.Type{nil, {scalar("Uint64")}, {scalar("String")}, {scalar("Bool"), scalar("Bool")}} { + if _, err := Resolve("COUNT_IF", args); err == nil { + t.Fatalf("COUNT_IF(%v) accepted invalid operands", args) + } + } +} + +func TestCurrentTimezoneBuiltinTypes(t *testing.T) { + for name, kind := range map[string]string{"CurrentTzDate": "TzDate", "CurrentTzDatetime": "TzDatetime", "CurrentTzTimestamp": "TzTimestamp"} { + for _, zone := range []model.Type{scalar("String"), model.Optional(scalar("String")), scalar("Null")} { + assertResolved(t, name, []model.Type{zone}, model.Optional(scalar(kind))) + assertResolved(t, name, []model.Type{zone, scalar("Uint32"), scalar("Null")}, model.Optional(scalar(kind))) + } + for _, args := range [][]model.Type{nil, {scalar("Utf8")}, {scalar("Uint32")}, {scalar("String"), scalar("Any")}} { + if got, err := Resolve(name, args); err == nil || got.Kind != "" { + t.Fatalf("%s(%v) = %v, %v; want unsupported argument", name, args, got, err) + } + } + } +} + +func TestNanvlTypes(t *testing.T) { + assertResolved(t, "NANVL", []model.Type{scalar("Float"), scalar("Float")}, scalar("Float")) + assertResolved(t, "NANVL", []model.Type{model.Optional(scalar("Float")), scalar("Double")}, model.Optional(scalar("Double"))) + for _, args := range [][]model.Type{nil, {scalar("Float")}, {scalar("Double"), scalar("Int32")}} { + if _, err := Resolve("NANVL", args); err == nil { + t.Fatalf("NANVL(%v) accepted invalid operands", args) + } + } +} diff --git a/internal/yql/builtins/builtins_test.go b/internal/yql/builtins/builtins_test.go index 6e369c67..fe1d617a 100644 --- a/internal/yql/builtins/builtins_test.go +++ b/internal/yql/builtins/builtins_test.go @@ -297,22 +297,23 @@ func TestResolveRejectsUnknownOrInvalidCalls(t *testing.T) { } } -func TestResolveCoalesceRejectsMixedBaseTypes(t *testing.T) { +func TestResolveCoalesceNumericCommonTypes(t *testing.T) { tests := []struct { name string function string args []model.Type + want model.Type }{ - {name: "coalesce non-optional numeric", function: "COALESCE", args: []model.Type{scalar("Int32"), scalar("Int64")}}, - {name: "coalesce optional numeric", function: "coalesce", args: []model.Type{model.Optional(scalar("Int32")), scalar("Int64")}}, - {name: "NVL numeric", function: "NVL", args: []model.Type{model.Optional(scalar("Uint16")), scalar("Uint32")}}, + {name: "coalesce non-optional numeric", function: "COALESCE", args: []model.Type{scalar("Int32"), scalar("Int64")}, want: scalar("Int64")}, + {name: "coalesce optional numeric", function: "coalesce", args: []model.Type{model.Optional(scalar("Int32")), scalar("Int64")}, want: scalar("Int64")}, + {name: "NVL numeric", function: "NVL", args: []model.Type{model.Optional(scalar("Uint16")), scalar("Uint32")}, want: scalar("Uint32")}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Resolve(tt.function, tt.args) - if err == nil || !strings.Contains(err.Error(), "CAST") || !strings.Contains(err.Error(), "same YQL type") { - t.Fatalf("Resolve(%q) = %#v, error %v; want mixed-type CAST error", tt.function, got, err) + if err != nil || !got.Equal(tt.want) { + t.Fatalf("Resolve(%q) = %#v, error %v; want %s", tt.function, got, err, tt.want.String()) } }) } @@ -379,7 +380,7 @@ func TestCast(t *testing.T) { {name: "utf8 to string is total", source: scalar("Utf8"), target: scalar("String"), want: scalar("String")}, {name: "string to utf8 may fail validation", source: scalar("String"), target: scalar("Utf8"), want: model.Optional(scalar("Utf8"))}, {name: "unsupported composite", source: model.Type{Kind: "List", Elem: typePointer(scalar("Int32"))}, target: model.Type{Kind: "List", Elem: typePointer(scalar("Int64"))}, error: "unsupported CAST"}, - {name: "boolean to integer", source: scalar("Bool"), target: scalar("Int32"), error: "unsupported CAST"}, + {name: "boolean to integer", source: scalar("Bool"), target: scalar("Int32"), want: scalar("Int32")}, {name: "invalid target null", source: scalar("Int32"), target: scalar("Null"), error: "target"}, } diff --git a/internal/yql/builtins/cast.go b/internal/yql/builtins/cast.go index 24f23c7f..4fd13a11 100644 --- a/internal/yql/builtins/cast.go +++ b/internal/yql/builtins/cast.go @@ -36,6 +36,21 @@ func castRule(source, target model.Type) (mayFail, supported bool) { if source.Equal(target) { return false, true } + if (isInteger(source.Kind) && target.Kind == "Bool") || (source.Kind == "Bool" && isInteger(target.Kind)) { + return false, true + } + if (source.Kind == "Date" || source.Kind == "Datetime" || source.Kind == "Timestamp") && target.Kind == "String" { + return false, true + } + if source.Kind == "Timestamp" && target.Kind == "Uint64" { + return false, true + } + if source.Kind == "Uint64" && target.Kind == "Timestamp" { + return true, true + } + if (source.Kind == "String" || source.Kind == "Utf8") && target.Kind == "Json" { + return true, true + } if isInteger(source.Kind) && isInteger(target.Kind) { return !totalIntegerCast(source.Kind, target.Kind), true } diff --git a/internal/yql/builtins/cast_extended_test.go b/internal/yql/builtins/cast_extended_test.go new file mode 100644 index 00000000..d06f21d6 --- /dev/null +++ b/internal/yql/builtins/cast_extended_test.go @@ -0,0 +1,46 @@ +package builtins + +import ( + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestBooleanAndTimestampCasts(t *testing.T) { + for _, integer := range []string{"Int8", "Int16", "Int32", "Int64", "Uint8", "Uint16", "Uint32", "Uint64"} { + for _, pair := range [][2]string{{integer, "Bool"}, {"Bool", integer}} { + for _, optionalInput := range []bool{false, true} { + source, want := scalar(pair[0]), scalar(pair[1]) + if optionalInput { + source, want = model.Optional(source), model.Optional(want) + } + got, err := Cast(source, scalar(pair[1])) + if err != nil || !got.Equal(want) { + t.Fatalf("CAST %s AS %s = %s, %v; want %s", source.String(), pair[1], got.String(), err, want.String()) + } + } + } + } + for _, tc := range []struct { + source, target string + optional bool + }{ + {"Timestamp", "String", false}, {"Timestamp", "Uint64", false}, {"Uint64", "Timestamp", true}, + {"Date", "String", false}, {"Datetime", "String", false}, + {"String", "Json", true}, {"Utf8", "Json", true}, + } { + for _, optionalInput := range []bool{false, true} { + source, want := scalar(tc.source), scalar(tc.target) + if optionalInput { + source = model.Optional(source) + } + if tc.optional || optionalInput { + want = model.Optional(want) + } + got, err := Cast(source, scalar(tc.target)) + if err != nil || !got.Equal(want) { + t.Fatalf("CAST %s AS %s = %s, %v; want %s", source.String(), tc.target, got.String(), err, want.String()) + } + } + } +} diff --git a/internal/yql/builtins/coalesce.go b/internal/yql/builtins/coalesce.go new file mode 100644 index 00000000..143b06fe --- /dev/null +++ b/internal/yql/builtins/coalesce.go @@ -0,0 +1,63 @@ +package builtins + +import ( + "fmt" + "math/big" + "strings" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func isCoalesce(name string) bool { + return strings.EqualFold(name, "COALESCE") || strings.EqualFold(name, "NVL") +} + +func resolveCoalesceArguments(name string, args []CallArgument) (model.Type, error) { + if len(args) == 0 { + return model.Type{}, fmt.Errorf("%s expects at least 1 argument", name) + } + result := model.Type{Kind: "Null"} + for _, argument := range args { + left, leftOptional := result.UnwrapOptional(), result.IsOptional() + right, rightOptional, err := baseType(argument.Type) + if err != nil { + return model.Type{}, fmt.Errorf("%s: %w", name, err) + } + if left.Kind == "Null" { + result = argument.Type + continue + } + if right.Kind == "Null" { + continue + } + // YQL reconciles COALESCE from left to right. Only the original + // right-hand literal can narrow to the accumulated left-hand type. + if !rightOptional && isInteger(left.Kind) && isInteger(right.Kind) && integerLiteralFits(argument.IntegerLiteral, left.Kind) { + right = left + } + if (left.Kind == "String" && right.Kind == "Utf8") || (left.Kind == "Utf8" && right.Kind == "String") { + left, right = model.Type{Kind: "String"}, model.Type{Kind: "String"} + } + common, err := commonConcreteType(left, right) + if err != nil { + return model.Type{}, fmt.Errorf("%s arguments have incompatible types: %w; use CAST to convert them to the same YQL type", name, err) + } + result = withOptional(common, leftOptional && rightOptional) + } + if result.Kind == "Null" { + return model.Type{}, fmt.Errorf("%s cannot infer a concrete type from only Null arguments", name) + } + return result, nil +} + +func integerLiteralFits(value *big.Int, kind string) bool { + if value == nil { + return false + } + signed, bits := integerInfo(kind) + if !signed { + return value.Sign() >= 0 && value.BitLen() <= bits + } + limit := new(big.Int).Lsh(big.NewInt(1), uint(bits-1)) + return value.Cmp(new(big.Int).Neg(limit)) >= 0 && value.Cmp(limit) < 0 +} diff --git a/internal/yql/builtins/coalesce_test.go b/internal/yql/builtins/coalesce_test.go new file mode 100644 index 00000000..ab45c818 --- /dev/null +++ b/internal/yql/builtins/coalesce_test.go @@ -0,0 +1,68 @@ +package builtins + +import ( + "math/big" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestCoalesceIntegerLiterals(t *testing.T) { + literal := func(kind, value string) CallArgument { + integer, ok := new(big.Int).SetString(value, 10) + if !ok { + t.Fatal(value) + } + return CallArgument{Type: scalar(kind), IntegerLiteral: integer} + } + u32 := CallArgument{Type: model.Optional(scalar("Uint32"))} + u64 := CallArgument{Type: model.Optional(scalar("Uint64"))} + for _, tc := range []struct { + name string + args []CallArgument + want model.Type + }{ + {"fitting fallback", []CallArgument{u32, literal("Int32", "0")}, scalar("Uint32")}, + {"explicit wide fitting fallback", []CallArgument{u32, literal("Uint64", "4294967295")}, scalar("Uint32")}, + {"negative fallback uses common type", []CallArgument{u32, literal("Int32", "-1")}, scalar("Int32")}, + {"large fallback uses common type", []CallArgument{u32, literal("Int64", "4294967296")}, scalar("Int64")}, + {"literal first retains its type", []CallArgument{literal("Int32", "0"), u32}, scalar("Int32")}, + {"negative with Uint64 uses server common type", []CallArgument{u64, literal("Int32", "-1")}, scalar("Uint64")}, + {"typed parameter is not a fitting literal", []CallArgument{u32, {Type: scalar("Int64")}}, scalar("Int64")}, + {"optional fallbacks", []CallArgument{u32, {Type: model.Optional(scalar("Int64"))}}, model.Optional(scalar("Int64"))}, + {"null before typed fallback", []CallArgument{{Type: scalar("Null")}, u32, literal("Int32", "0")}, scalar("Uint32")}, + {"signed minimum fits", []CallArgument{{Type: model.Optional(scalar("Int8"))}, literal("Int32", "-128")}, scalar("Int8")}, + {"below signed minimum widens", []CallArgument{{Type: model.Optional(scalar("Int8"))}, literal("Int32", "-129")}, scalar("Int32")}, + {"above signed maximum widens", []CallArgument{{Type: model.Optional(scalar("Int8"))}, literal("Int32", "128")}, scalar("Int32")}, + {"null fallback keeps optional", []CallArgument{u32, {Type: scalar("Null")}}, u32.Type}, + {"String then Utf8", []CallArgument{{Type: model.Optional(scalar("String"))}, {Type: scalar("Utf8")}}, scalar("String")}, + {"Utf8 then String", []CallArgument{{Type: model.Optional(scalar("Utf8"))}, {Type: scalar("String")}}, scalar("String")}, + {"optional string family", []CallArgument{{Type: model.Optional(scalar("Utf8"))}, {Type: model.Optional(scalar("String"))}}, model.Optional(scalar("String"))}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, name := range []string{"COALESCE", "NVL"} { + got, err := defaultRegistry.ResolveCall(name, tc.args) + if err != nil || !got.Equal(tc.want) { + t.Fatalf("%s = %s, %v; want %s", name, got.String(), err, tc.want.String()) + } + } + }) + } + arg := literal("Int32", "0") + _, _ = defaultRegistry.ResolveCall("COALESCE", []CallArgument{u32, arg}) + if arg.Type.Kind != "Int32" || arg.IntegerLiteral.Sign() != 0 { + t.Fatal("caller literal metadata changed") + } +} + +func TestCoalesceRejectsUnresolvedAndIncompatibleTypes(t *testing.T) { + for _, args := range [][]CallArgument{ + {{Type: model.Type{Kind: "Any"}}}, + {{Type: scalar("String")}, {Type: scalar("Bool")}}, + {{Type: model.Optional(model.Optional(scalar("Int32")))}}, + } { + if got, err := defaultRegistry.ResolveCall("COALESCE", args); err == nil || got.Kind != "" { + t.Fatalf("accepted %v: %v %v", args, got, err) + } + } +} diff --git a/internal/yql/builtins/resolve.go b/internal/yql/builtins/resolve.go index 5a28dae7..f84444e0 100644 --- a/internal/yql/builtins/resolve.go +++ b/internal/yql/builtins/resolve.go @@ -33,10 +33,35 @@ type functionResolver func([]model.Type) (model.Type, error) func lookupCore(name string) functionResolver { switch strings.ToUpper(name) { - case "COALESCE", "NVL": - return func(args []model.Type) (model.Type, error) { return resolveCoalesce(name, args) } case "IF": return resolveIf + case "NANVL": + return resolveNanvl + case "CURRENTUTCDATE": + return func(args []model.Type) (model.Type, error) { return resolveDependencyValue(name, "Date", 0, args) } + case "CURRENTUTCDATETIME": + return func(args []model.Type) (model.Type, error) { return resolveDependencyValue(name, "Datetime", 0, args) } + case "CURRENTUTCTIMESTAMP": + return func(args []model.Type) (model.Type, error) { return resolveDependencyValue(name, "Timestamp", 0, args) } + case "CURRENTTZDATE": + return func(args []model.Type) (model.Type, error) { return resolveCurrentTimezone(name, "TzDate", args) } + case "CURRENTTZDATETIME": + return func(args []model.Type) (model.Type, error) { return resolveCurrentTimezone(name, "TzDatetime", args) } + case "CURRENTTZTIMESTAMP": + return func(args []model.Type) (model.Type, error) { return resolveCurrentTimezone(name, "TzTimestamp", args) } + case "RANDOM": + return func(args []model.Type) (model.Type, error) { return resolveDependencyValue(name, "Double", 1, args) } + case "RANDOMNUMBER": + return func(args []model.Type) (model.Type, error) { return resolveDependencyValue(name, "Uint64", 1, args) } + case "RANDOMUUID": + return func(args []model.Type) (model.Type, error) { return resolveDependencyValue(name, "Uuid", 1, args) } + case "VERSION": + return func(args []model.Type) (model.Type, error) { + if err := arity(name, args, 0); err != nil { + return model.Type{}, err + } + return model.Type{Kind: "String"}, nil + } case "LENGTH", "LEN": return func(args []model.Type) (model.Type, error) { return resolveLength(name, args) } case "SUBSTRING": @@ -53,6 +78,8 @@ func lookupCore(name string) functionResolver { return resolveSetIsDisjoint case "COUNT": return resolveCount + case "COUNT_IF": + return resolveCountIf case "MIN", "MAX": return func(args []model.Type) (model.Type, error) { return resolveMinMax(name, args) } case "SUM": @@ -64,33 +91,58 @@ func lookupCore(name string) functionResolver { } } -func resolveCoalesce(name string, args []model.Type) (model.Type, error) { - if len(args) == 0 { - return model.Type{}, fmt.Errorf("%s expects at least 1 argument", name) +// Clock and random arguments control evaluation dependencies, not result +// nullability. Their types must still be resolved before generation. +func resolveDependencyValue(name, kind string, minimum int, args []model.Type) (model.Type, error) { + if len(args) < minimum { + return model.Type{}, fmt.Errorf("%s requires at least %d dependency argument", name, minimum) } - var result model.Type - nullable := true for _, arg := range args { - base, isOptional, err := baseType(arg) - if err != nil { - return model.Type{}, fmt.Errorf("%s: %w", name, err) - } - if base.Kind == "Null" { - continue - } - if result.Kind == "" { - result = base - } else if !result.Equal(base) { - return model.Type{}, fmt.Errorf("%s arguments must have the same non-Null base type; use CAST to convert them to the same YQL type", name) - } - if !isOptional { - nullable = false + if err := validateConcreteOrNull(arg); err != nil { + return model.Type{}, err } } - if result.Kind == "" { - return model.Type{}, fmt.Errorf("%s cannot infer a concrete type from only Null arguments", name) + return model.Type{Kind: kind}, nil +} + +func resolveCurrentTimezone(name, kind string, args []model.Type) (model.Type, error) { + if len(args) == 0 { + return model.Type{}, fmt.Errorf("%s requires a String timezone argument", name) + } + zone, _, err := baseType(args[0]) + if err != nil || (zone.Kind != "String" && zone.Kind != "Null") { + return model.Type{}, fmt.Errorf("%s timezone must be String or Optional", name) + } + result, err := resolveDependencyValue(name, kind, 0, args[1:]) + if err != nil { + return model.Type{}, err + } + // Zone names are evaluated by YDB; an unknown zone produces NULL. + return model.Optional(result), nil +} + +func resolveCountIf(args []model.Type) (model.Type, error) { + if err := arity("COUNT_IF", args, 1); err != nil { + return model.Type{}, err + } + base, _, err := baseType(args[0]) + if err != nil || (base.Kind != "Bool" && base.Kind != "Null") { + return model.Type{}, fmt.Errorf("COUNT_IF argument must be Bool or Optional") + } + return model.Type{Kind: "Uint64"}, nil +} + +func resolveNanvl(args []model.Type) (model.Type, error) { + if err := arity("NANVL", args, 2); err != nil { + return model.Type{}, err + } + for _, arg := range args { + base, _, err := baseType(arg) + if err != nil || (base.Kind != "Float" && base.Kind != "Double") { + return model.Type{}, fmt.Errorf("NANVL arguments must be Float or Double, including Optional forms") + } } - return withOptional(result, nullable), nil + return CommonType(args...) } func resolveIf(args []model.Type) (model.Type, error) { diff --git a/internal/yql/builtins/signatures.go b/internal/yql/builtins/signatures.go index 6f48cc1f..970dc738 100644 --- a/internal/yql/builtins/signatures.go +++ b/internal/yql/builtins/signatures.go @@ -2,6 +2,7 @@ package builtins import ( "fmt" + "math/big" "regexp" "strings" @@ -12,6 +13,9 @@ import ( type CallArgument struct { Name string Type model.Type + // IntegerLiteral retains the exact value needed for YQL's fitting-literal + // coercion. Nil means a value-dependent conversion cannot be proven. + IntegerLiteral *big.Int } // Parameter is one concrete function parameter. Optional means the argument @@ -84,6 +88,9 @@ func (r *Registry) ResolveCall(name string, args []CallArgument) (model.Type, er } plain[i] = argument.Type } + if isCoalesce(name) { + return resolveCoalesceArguments(name, args) + } return resolveLegacy(name, plain) } @@ -352,5 +359,5 @@ func standardSignatures(name string) []Signature { } func isKnownFunction(name string) bool { - return len(standardSignatures(name)) != 0 || lookupCore(name) != nil || lookupLibrary(name) != nil + return isCoalesce(name) || len(standardSignatures(name)) != 0 || lookupCore(name) != nil || lookupLibrary(name) != nil } diff --git a/tests/examples/go/authors/expressions_test.go b/tests/examples/go/authors/expressions_test.go new file mode 100644 index 00000000..8c1557fa --- /dev/null +++ b/tests/examples/go/authors/expressions_test.go @@ -0,0 +1,91 @@ +package authors_test + +import ( + "reflect" + "testing" + + "example.com/sqlc-ydb-example-tests/internal/testdb" + sq "example.com/sqlc-ydb-examples/authors/go/database/sql" + native "example.com/sqlc-ydb-examples/authors/go/native" +) + +func TestAuthorExpressions(t *testing.T) { + db := testdb.Open(t) + db.Apply(t, "../../../../examples/authors/schema.sql", "DROP TABLE authors;") + ctx := db.Context + n, s := native.New(db.Native), sq.New(db.SQL) + type statistics struct { + total, withBio, withNonemptyBio uint64 + exists bool + } + readers := []struct { + name string + stats func() (statistics, error) + prefix func(string) ([]bool, error) + }{ + {"native", func() (statistics, error) { + r, err := n.GetAuthorStatistics(ctx) + return statistics{r.Total, r.WithBio, r.WithNonemptyBio, r.Column3}, err + }, func(prefix string) ([]bool, error) { + rows, err := n.FindAuthorsByNamePrefix(ctx, prefix) + flags := make([]bool, len(rows)) + for i, row := range rows { + flags[i] = row.HasBio + } + return flags, err + }}, + {"database/sql", func() (statistics, error) { + r, err := s.GetAuthorStatistics(ctx) + return statistics{r.Total, r.WithBio, r.WithNonemptyBio, r.Column3}, err + }, func(prefix string) ([]bool, error) { + rows, err := s.FindAuthorsByNamePrefix(ctx, prefix) + flags := make([]bool, len(rows)) + for i, row := range rows { + flags[i] = row.HasBio + } + return flags, err + }}, + } + for _, reader := range readers { + if got, err := reader.stats(); err != nil || got != (statistics{}) { + t.Fatalf("%s empty aggregate: %#v, %v", reader.name, got, err) + } + } + empty, bio := "", "biography" + for _, row := range []native.UpsertAuthorParams{ + {AuthorID: 1, AuthorName: "Alice"}, + {AuthorID: 2, AuthorName: "Alfred", Biography: &empty}, + {AuthorID: ^uint64(0), AuthorName: "Bob", Biography: &bio}, + } { + if err := n.UpsertAuthor(ctx, row); err != nil { + t.Fatal(err) + } + } + for _, reader := range readers { + t.Run(reader.name, func(t *testing.T) { + if got, err := reader.stats(); err != nil || got != (statistics{3, 2, 1, true}) { + t.Fatalf("aggregate: %#v, %v", got, err) + } + if got, err := reader.prefix("Al"); err != nil || !reflect.DeepEqual(got, []bool{false, true}) { + t.Fatalf("prefix: %v, %v", got, err) + } + if got, err := reader.prefix("missing"); err != nil || len(got) != 0 { + t.Fatalf("missing prefix: %v, %v", got, err) + } + }) + } + for _, id := range []uint64{1, ^uint64(0)} { + want := uint32(0) + if id == 1 { + want = 1 + } + nativeRow, err := n.GetAuthorExportMetadata(ctx, id) + if err != nil || nativeRow.Column6 != want || nativeRow.ExportTimestamp.IsZero() { + t.Fatalf("native export %d: %#v, %v", id, nativeRow, err) + } + sqlRow, err := s.GetAuthorExportMetadata(ctx, id) + if err != nil || sqlRow.Column6 != want || sqlRow.ExportTimestamp.IsZero() { + t.Fatalf("database/sql export %d: %#v, %v", id, sqlRow, err) + } + } +} diff --git a/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java b/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java index 00aca0e3..de5eed37 100644 --- a/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java +++ b/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java @@ -55,7 +55,7 @@ void everyExampleRendersAndBindsThroughThePublishedDialect() throws Exception { if ((method.getName().startsWith("create") && method.getReturnType() != void.class) || method.getName().equals("updateVenueName")) { assertTrue(sql.toLowerCase().contains("returning"), sql); } - if (method.getName().startsWith("findAuthorsByName")) { + if (method.getName().equals("findAuthorsByName") || method.getName().equals("findAuthorsByNameCovering")) { assertTrue(sql.replace("`", "").contains("VIEW by_name"), sql); } if (method.getName().equals("booksByTags")) { @@ -65,7 +65,7 @@ void everyExampleRendersAndBindsThroughThePublishedDialect() throws Exception { } } } - assertEquals(52, statements.size()); + assertEquals(55, statements.size()); } @Test void declaredQueryReadsDialectCarriers() throws Exception { diff --git a/tests/examples/java/jooq/src/test/java/LiveTest.java b/tests/examples/java/jooq/src/test/java/LiveTest.java index a5e8fe75..12a52a72 100644 --- a/tests/examples/java/jooq/src/test/java/LiveTest.java +++ b/tests/examples/java/jooq/src/test/java/LiveTest.java @@ -101,6 +101,41 @@ void nativeAuthorsContractAndCallerTransaction() throws Exception { } } + @Test + void authorExpressionsKeepServerTypesAndResultNames() throws Exception { + try (Fixture fixture = new Fixture("authors")) { + fixture.create("authors", "id Uint64 NOT NULL, name Utf8 NOT NULL, bio Utf8, PRIMARY KEY(id)"); + var queries = new authors.jooq.Queries(fixture.dsl); + var empty = queries.getAuthorStatistics().orElseThrow(); + assertEquals(ULong.valueOf(0), empty.total()); + assertEquals(ULong.valueOf(0), empty.withBio()); + assertEquals(ULong.valueOf(0), empty.withNonemptyBio()); + assertFalse(empty.column3()); + queries.upsertAuthor(ULong.valueOf(1), "Alice", null); + queries.upsertAuthor(ULong.valueOf(2), "Alfred", ""); + queries.upsertAuthor(ULong.MAX, "Bob", "biography"); + var stats = queries.getAuthorStatistics().orElseThrow(); + assertEquals(ULong.valueOf(3), stats.total()); + assertEquals(ULong.valueOf(2), stats.withBio()); + assertEquals(ULong.valueOf(1), stats.withNonemptyBio()); + assertTrue(stats.column3()); + var prefixRows = queries.findAuthorsByNamePrefix("Al"); + assertEquals(2, prefixRows.size()); + assertFalse(prefixRows.get(0).hasBio()); + assertTrue(prefixRows.get(1).hasBio()); + assertTrue(queries.findAuthorsByNamePrefix("missing").isEmpty()); + var metadata = queries.getAuthorExportMetadata(ULong.MAX).orElseThrow(); + assertEquals(UInteger.valueOf(0), metadata.column6()); + assertEquals("{\"source\":\"authors\"}", metadata.exportMetadata().data()); + assertNotNull(metadata.exportDate()); + assertNotNull(metadata.exportDatetime()); + assertNotNull(metadata.exportTimestamp()); + assertNotNull(metadata.exportTimestampText()); + assertTrue(metadata.exportTimestampMicros().compareTo(ULong.valueOf(0)) > 0); + assertEquals(UInteger.valueOf(1), queries.getAuthorExportMetadata(ULong.valueOf(1)).orElseThrow().column6()); + } + } + @Test void batchJsonNullsAndAllDeleteVariants() throws Exception { try (Fixture fixture = new Fixture("authors", "books")) { From 642cd2183e1240af66a02eb5ee062adbe350bd63 Mon Sep 17 00:00:00 2001 From: Aleksey Myasnikov Date: Wed, 23 Sep 2026 13:25:19 +0300 Subject: [PATCH 2/4] Fix Rust derives for YDB byte result fields --- .agents/sdk-evidence.md | 4 + CHANGELOG.md | 4 + docs/rust.md | 4 +- examples/authors/rust/native/models.rs | 2 +- internal/codegen/rust/bytes_result_test.go | 142 +++++++++++++++++++++ internal/codegen/rust/generator.go | 30 +++-- 6 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 internal/codegen/rust/bytes_result_test.go diff --git a/.agents/sdk-evidence.md b/.agents/sdk-evidence.md index 1917ea5b..4d1c2e4e 100644 --- a/.agents/sdk-evidence.md +++ b/.agents/sdk-evidence.md @@ -46,6 +46,10 @@ Dapper uses `CommandDefinition`, `IDynamicParameters` and typed query methods. I Rust's public `From> for Value` implementation requires `T: Into + Default`. Small generated wrappers preserve JSON and optional temporal wire types, including typed nulls, without changing the SDK. Rechecked against the pinned 0.18.2 crate and generator on 2026-09-12: `:one` uses `query_row`, whose `client_query/builders.rs::take_single_row` rejects multiple rows with `YdbError::Custom` and reports `YdbError::NoRows` for none. It does not have upstream sqlc's first-row semantics. The generated `:many` methods use `query_result_set`. +Rechecked on 2026-09-23 for generated result traits: `Bytes` in the published `ydb` 0.18.2 crate's `src/types.rs` derives `Clone`, `Debug`, `Default`, `Eq` and `PartialEq`, but does not implement `Copy`, `Hash`, `PartialOrd` or `Ord`. Both YQL String and Yson map to this SDK wrapper. Option and Vec retain their element's comparison/hash requirements, while Vec is never Copy. Rust float fields retain PartialEq/PartialOrd but prevent Eq/Hash/Ord. Generated rows must use the intersection of their field representations' traits; Rust's standard String traits cannot be attributed to `ydb::Bytes` merely because both represent YQL string families. This changes derives only, without changing SQL, binding, decoding or executor ownership. + +The same published-SDK compile probe rejected `Option>` and `Option>` result decoding because their required `TryFrom` implementations are absent. Nested Optional result types therefore produce a generation diagnostic instead of non-compiling code. This is a decoding boundary: the SDK's generic `From> for Value` remains available to existing parameter bindings. Scalar, optional and list byte-string rows are compiled against the SDK and exercised for equality, with mixed float rows retaining IEEE NaN partial equality. + The [JavaScript SDK](https://github.com/ydb-platform/ydb-js-sdk) was inspected at `96793dbf49165581a1d5c21afff47905c720f44d`. Its published modules are `@ydbjs/core` 6.3.1, `@ydbjs/query` 6.3.0 and `@ydbjs/value` 6.0.8, pinned in [the shared npm lockfile](../tests/examples/typescript/package-lock.json). Declaration handling is documented [below](#typescript-declarations). The maintainer-approved [TypeScript examples in PR #4](https://github.com/ydb-platform/sqlc-ydb/pull/4) use direct typed tagged templates and the SDK's normal result conversion: `Timestamp` becomes `Date`, and JSON becomes parsed `JSValue`. TypeScript generics describe those runtime values; they do not control SDK decoding. Timestamp inputs use `new Timestamp(date)`. This API has JavaScript's millisecond precision and does not preserve JSON source text. See [TypeScript](../docs/typescript.md). The [PHP SDK](https://github.com/ydb-platform/ydb-php-sdk) is pinned to 1.16.1 (`5bce112ff6cc4a5eca83147232813cc94a675a50`) in [the shared Composer lockfile](../tests/examples/php/composer.lock). Main was inspected at `56a783e39368745a35a7bc3e206d4a8200184805`. The generated bridge uses the public `Table` accessors and `RequestTrait` to retain raw protobuf result values; [PHP](../docs/php.md) explains why the high-level result conversion is unsuitable. `Session` keeps its current transaction identifier protected, but [`beginTransaction()`](https://github.com/ydb-platform/ydb-php-sdk/blob/5bce112ff6cc4a5eca83147232813cc94a675a50/src/Session.php#L222-L241) returns that ID. Generated `withTx(Session, string)` uses the saved ID with public `YdbQuery::txControl()` and the raw bridge; the caller retains transaction ownership. The SDK's `retryTransaction()` callback does not expose the current ID, so binding from that callback's session alone remains unsupported. diff --git a/CHANGELOG.md b/CHANGELOG.md index e38b053e..fc74d726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Support the UTC clock family and additional scalar built-ins, with a documented inventory of the upstream YQL builtin catalog and explicit prerequisites for remaining families. - Infer YDB result names for computed SELECT columns without AS. Add authors reporting, prefix-search and export-metadata examples for every language/runtime profile. +### Fixed + +- Emit only supported Rust comparison and hashing derives for rows containing YDB byte strings, including optional and list fields. + ## v0.2.5 ### Added diff --git a/docs/rust.md b/docs/rust.md index 5811f485..d6cb5b6c 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -51,11 +51,11 @@ JSON parameters use generated private wrappers before entering the SDK. This is Result values are read by resolved projection position through `Row::remove_field` and decoded with the SDK's `TryFrom` implementations. This also handles qualified result names produced by joins. Missing columns, nullability mismatches, unexpected wire types and no rows for `:one` are returned as `ydb::YdbError`; generated code does not substitute defaults. -`:many` decodes rows with a fallible iterator collected into a `Vec`, returning the first decoding error. Result structs derive `Debug`, `Clone`, `PartialEq` and `PartialOrd`. `Eq`, `Hash` and `Ord` are emitted only when no field contains `Float` or `Double`, including inside `Option` or `Vec`; floating-point NaN prevents total equality and ordering. Structs also derive `Copy` when every field is copyable, including optional scalars and timestamps. +`:many` decodes rows with a fallible iterator collected into a `Vec`, returning the first decoding error. Result structs derive `Debug`, `Clone` and `PartialEq`. Additional traits follow every field's Rust representation, including inside `Option` or `Vec`: floating-point fields prevent `Eq`, `Hash` and `Ord`, while `ydb::Bytes` fields (`String` and `Yson`) prevent `Hash`, `PartialOrd` and `Ord`. Other supported fields retain those traits. Structs also derive `Copy` when every field is copyable, including optional scalars and timestamps; owned strings, byte buffers and vectors are not copyable. Multiline SQL is emitted as aligned quoted lines inside `concat!`, preserving explicit `DECLARE` statements, relative indentation, blank lines and control bytes. The outer indentation matches the surrounding call without adding whitespace to the runtime query. Query annotations become Rust comments before methods. Generated Rust source passes `rustfmt --check` without a formatting rewrite. -The shared examples pin `ydb` 0.18.2 and require Rust 1.88 or newer, matching the SDK's published minimum supported Rust version. `:execrows`, containers other than the list parameters described below, decimal values, UUID values, and other unmapped YQL types produce a generation error. Query names that normalize to `new` are rejected because `Queries::new` is the generated constructor; choose a different query annotation name. +The shared examples pin `ydb` 0.18.2 and require Rust 1.88 or newer, matching the SDK's published minimum supported Rust version. `:execrows`, containers other than the list parameters described below, nested Optional result types, decimal values, UUID values, and other unmapped YQL types produce a generation error. Query names that normalize to `new` are rejected because `Queries::new` is the generated constructor; choose a different query annotation name. All query methods use type-safe `bon` builders, including parameterless queries. Add `bon = "3.10.1"` to the consuming crate's dependencies. diff --git a/examples/authors/rust/native/models.rs b/examples/authors/rust/native/models.rs index 177d0064..c205062c 100644 --- a/examples/authors/rust/native/models.rs +++ b/examples/authors/rust/native/models.rs @@ -63,7 +63,7 @@ pub struct GetAuthorStatisticsRow { pub column3: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GetAuthorExportMetadataRow { pub id: u64, pub export_date: ydb::Bytes, diff --git a/internal/codegen/rust/bytes_result_test.go b/internal/codegen/rust/bytes_result_test.go new file mode 100644 index 00000000..b97900a7 --- /dev/null +++ b/internal/codegen/rust/bytes_result_test.go @@ -0,0 +1,142 @@ +package rust + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestResultComparisonDerives(t *testing.T) { + binary := model.Type{Kind: "String"} + yson := model.Type{Kind: "Yson"} + floating := model.Type{Kind: "Double"} + for _, tc := range []struct { + name string + types []model.Type + derives string + }{ + {"integer", []model.Type{{Kind: "Uint64"}}, "Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord"}, + {"float", []model.Type{model.Optional(floating)}, "Debug, Clone, Copy, PartialEq, PartialOrd"}, + {"bytes", []model.Type{binary}, "Debug, Clone, PartialEq, Eq"}, + {"optional yson", []model.Type{model.Optional(yson)}, "Debug, Clone, PartialEq, Eq"}, + {"bytes list", []model.Type{{Kind: "List", Elem: &binary}}, "Debug, Clone, PartialEq, Eq"}, + {"bytes then float", []model.Type{binary, floating}, "Debug, Clone, PartialEq"}, + {"float then bytes", []model.Type{floating, binary}, "Debug, Clone, PartialEq"}, + } { + t.Run(tc.name, func(t *testing.T) { + var columns []model.Column + for i, typ := range tc.types { + columns = append(columns, model.Column{Name: fmt.Sprintf("value%d", i), Type: typ}) + } + analysis := &model.AnalysisResult{Queries: []model.AnalyzedQuery{{Name: "Read", Command: model.One, SQL: "SELECT value FROM readings;", ResultSets: []model.ResultSet{{Columns: columns}}}}} + files, err := Generate(analysis, Options{}) + if err != nil { + t.Fatal(err) + } + models := generatedFile(t, files, "models.rs") + if !strings.Contains(models, "#[derive("+tc.derives+")]\npub struct ReadRow") { + t.Fatalf("unexpected public row traits:\n%s", models) + } + }) + } +} + +func TestRejectsNestedOptionalResultSDKTypes(t *testing.T) { + for _, kind := range []string{"String", "Utf8"} { + t.Run(kind, func(t *testing.T) { + typ := model.Optional(model.Optional(model.Type{Kind: kind})) + query := model.AnalyzedQuery{Name: "Read", Command: model.One, SQL: "SELECT value FROM readings;", ResultSets: []model.ResultSet{{Columns: []model.Column{{Name: "value", Type: typ}}}}} + files, err := Generate(&model.AnalysisResult{Queries: []model.AnalyzedQuery{query}}, Options{}) + if files != nil || err == nil || !strings.Contains(err.Error(), "nested optional result decoding is unsupported by ydb 0.18.2") { + t.Fatalf("files=%v error=%v", files, err) + } + }) + } +} + +func TestBytesResultsCompileAndRetainEquality(t *testing.T) { + if os.Getenv("SQLC_YDB_RUST_SDK_CHECK") == "" { + t.Skip("set SQLC_YDB_RUST_SDK_CHECK=1 for published SDK trait checks") + } + var queries []model.AnalyzedQuery + for _, kind := range []string{"String", "Yson"} { + scalar := model.Type{Kind: kind} + for _, tc := range []struct { + name string + typ model.Type + }{ + {"Scalar", scalar}, {"Optional", model.Optional(scalar)}, + {"List", model.Type{Kind: "List", Elem: &scalar}}, + } { + queries = append(queries, model.AnalyzedQuery{Name: "Read" + kind + tc.name, Command: model.One, SQL: "SELECT value FROM readings;", ResultSets: []model.ResultSet{{Columns: []model.Column{{Name: "value", Type: tc.typ}}}}}) + } + } + for _, first := range []bool{false, true} { + columns := []model.Column{{Name: "payload", Type: model.Type{Kind: "String"}}, {Name: "ratio", Type: model.Type{Kind: "Double"}}} + if first { + columns[0], columns[1] = columns[1], columns[0] + } + queries = append(queries, model.AnalyzedQuery{Name: fmt.Sprintf("ReadMixed%t", first), Command: model.One, SQL: fmt.Sprintf("SELECT %s,%s FROM readings;", columns[0].Name, columns[1].Name), ResultSets: []model.ResultSet{{Columns: columns}}}) + } + queries = append(queries, model.AnalyzedQuery{Name: "ReadText", Command: model.One, SQL: "SELECT value FROM readings;", ResultSets: []model.ResultSet{{Columns: []model.Column{{Name: "value", Type: model.Optional(model.Type{Kind: "Utf8"})}}}}}) + queries = append(queries, model.AnalyzedQuery{Name: "BindNestedText", Command: model.Exec, SQL: "DECLARE $value AS Optional>; SELECT $value;", Parameters: []model.Parameter{{Name: "value", Type: model.Optional(model.Optional(model.Type{Kind: "Utf8"}))}}}) + files, err := Generate(&model.AnalysisResult{Queries: queries}, Options{}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "src"), 0700); err != nil { + t.Fatal(err) + } + for _, file := range files { + if err := os.WriteFile(filepath.Join(dir, "src", file.Name), file.Content, 0600); err != nil { + t.Fatal(err) + } + } + manifest := "[package]\nname=\"bytes-result-check\"\nversion=\"0.0.0\"\nedition=\"2024\"\n[dependencies]\nydb=\"=0.18.2\"\nbon=\"=3.10.1\"\n" + if err := os.WriteFile(filepath.Join(dir, "Cargo.toml"), []byte(manifest), 0600); err != nil { + t.Fatal(err) + } + consumer := `use bytes_result_check::models::*; +async fn check_nested_parameter(q: &mut bytes_result_check::queries::Queries<'_, ydb::QueryClient>) -> ydb::YdbResult<()> { + q.bind_nested_text().value(Some(Some("nested".to_string()))).call().await +} +fn require_eq() {} +fn require_ordered_hash() {} +fn main() { + require_eq::(); + require_eq::(); + require_eq::(); + require_eq::(); + require_eq::(); + require_eq::(); + require_ordered_hash::(); + let bytes = ydb::Bytes::from(vec![0, 255]); + let scalar = ReadStringScalarRow { value: bytes.clone() }; + assert_eq!(scalar, scalar.clone()); + assert_ne!(scalar, ReadStringScalarRow { value: ydb::Bytes::from(vec![255, 0]) }); + let optional = ReadStringOptionalRow { value: None }; + assert_eq!(optional, optional.clone()); + assert_ne!(optional, ReadStringOptionalRow { value: Some(bytes.clone()) }); + let list = ReadYsonListRow { value: vec![bytes.clone()] }; + assert_eq!(list, list.clone()); + let mixed = ReadMixedfalseRow { payload: bytes.clone(), ratio: 1.5 }; + assert_eq!(mixed, mixed.clone()); + let nan = ReadMixedtrueRow { ratio: f64::NAN, payload: bytes }; + assert_ne!(nan, nan.clone()); +} +` + if err := os.WriteFile(filepath.Join(dir, "src", "main.rs"), []byte(consumer), 0600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("cargo", "run", "--quiet") + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("byte result traits against ydb 0.18.2: %v\n%s", err, output) + } +} diff --git a/internal/codegen/rust/generator.go b/internal/codegen/rust/generator.go index 4c785570..edac6b9c 100644 --- a/internal/codegen/rust/generator.go +++ b/internal/codegen/rust/generator.go @@ -109,6 +109,9 @@ func validate(in *model.AnalysisResult) error { return fmt.Errorf("rust generator: query %q column name collision at %q (%q and %q)", q.Name, name, previous, c.Name) } columnNames[name] = c.Name + if c.Type.IsOptional() && c.Type.Elem != nil && c.Type.Elem.IsOptional() { + return fmt.Errorf("rust generator: query %q column %q: nested optional result decoding is unsupported by ydb 0.18.2", q.Name, c.Name) + } if _, err := rustType(c.Type); err != nil { return fmt.Errorf("rust generator: query %q column %q: %w", q.Name, c.Name, err) } @@ -131,16 +134,26 @@ func renderModels(in *model.AnalysisResult) string { continue } copyDerive := ", Copy" - comparisonDerives := ", PartialEq, Eq, Hash, PartialOrd, Ord" + floating, binary := false, false for _, c := range q.ResultSets[0].Columns { - if hasFloat(c.Type) { - comparisonDerives = ", PartialEq, PartialOrd" - } + floating = floating || typeContains(c.Type, "Float", false) || typeContains(c.Type, "Double", false) + binary = binary || typeContains(c.Type, "String", false) || typeContains(c.Type, "Yson", false) switch strings.ToLower(c.Type.UnwrapOptional().Kind) { case "utf8", "json", "jsondocument", "string", "yson", "list": copyDerive = "" } } + // ydb::Bytes supports equality, but not hashing or ordering. Floats + // retain only partial comparisons; wrappers require the same traits. + comparisonDerives := ", PartialEq, Eq, Hash, PartialOrd, Ord" + switch { + case binary && floating: + comparisonDerives = ", PartialEq" + case binary: + comparisonDerives = ", PartialEq, Eq" + case floating: + comparisonDerives = ", PartialEq, PartialOrd" + } fmt.Fprintf(&b, "#[derive(Debug, Clone%s%s)]\npub struct %sRow {\n", copyDerive, comparisonDerives, pascalName(q.Name)) for _, c := range q.ResultSets[0].Columns { t, _ := rustType(c.Type) @@ -151,15 +164,6 @@ func renderModels(in *model.AnalysisResult) string { return strings.TrimSuffix(b.String(), "\n") } -// Rust floats have partial comparison because NaN is neither equal nor ordered. -// Optional and collection wrappers retain the element's trait requirements. -func hasFloat(t model.Type) bool { - if strings.EqualFold(t.Kind, "Float") || strings.EqualFold(t.Kind, "Double") { - return true - } - return t.Elem != nil && hasFloat(*t.Elem) -} - func renderQueries(in *model.AnalysisResult) string { var b strings.Builder b.WriteString("// Code generated by sqlc-ydb. DO NOT EDIT.\n\nuse super::models::*;\n\n") From 2b2631fef9fc61fe05c21b53c4ca1a52e2f7d15b Mon Sep 17 00:00:00 2001 From: Aleksey Myasnikov Date: Wed, 23 Sep 2026 13:54:37 +0300 Subject: [PATCH 3/4] Cover predicate diagnostics and wildcard rewrite boundaries --- internal/analyzer/predicate.go | 5 +- internal/analyzer/predicate_test.go | 62 +++++++++++++++++++++ internal/analyzer/wildcard_rewrites_test.go | 55 ++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 internal/analyzer/wildcard_rewrites_test.go diff --git a/internal/analyzer/predicate.go b/internal/analyzer/predicate.go index f6632e14..910bed5e 100644 --- a/internal/analyzer/predicate.go +++ b/internal/analyzer/predicate.go @@ -122,10 +122,7 @@ func validatePredicateAtom(atom *parser.Xor_subexprContext, scope expressionScop } return nil } - _, matched, err := resolveComparison(atom, scope) - if !matched { - return fmt.Errorf("unsupported predicate %q", atom.GetText()) - } + _, _, err = resolveComparison(atom, scope) return err } diff --git a/internal/analyzer/predicate_test.go b/internal/analyzer/predicate_test.go index 469118ad..59cac4ac 100644 --- a/internal/analyzer/predicate_test.go +++ b/internal/analyzer/predicate_test.go @@ -92,3 +92,65 @@ func TestPredicateINValidatesItsActualOperands(t *testing.T) { }) } } + +func TestPredicateRejectsBareAndNegatedNonBooleanValues(t *testing.T) { + schema := []model.Source{{Name: "schema.sql", Text: `CREATE TABLE records (id Uint64 NOT NULL, label Utf8, flag Bool NOT NULL, PRIMARY KEY(id));`}} + for _, test := range []struct{ predicate, want string }{ + {"id", "predicate expression has type Uint64, want Bool"}, + {"label", "predicate expression has type Optional, want Bool"}, + {"NOT id", "NOT operand has type Uint64, want Bool"}, + {"NOT label", "NOT operand has type Optional, want Bool"}, + {"flag AND id", "predicate expression has type Uint64, want Bool"}, + } { + t.Run(test.predicate, func(t *testing.T) { + query := "-- name: Read :many\nSELECT id FROM records WHERE " + test.predicate + ";" + _, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: query}}) + if err == nil || !strings.Contains(err.Error(), "invalid predicate: "+test.want) { + t.Fatalf("error = %v, want Boolean requirement %q", err, test.want) + } + }) + } +} + +func TestPredicateINRejectsIncompatibleAndUnsupportedCollections(t *testing.T) { + schema := []model.Source{{Name: "schema.sql", Text: `CREATE TABLE records (id Uint64 NOT NULL, PRIMARY KEY(id));`}} + for _, test := range []struct{ declaration, predicate, want string }{ + {`DECLARE $labels AS List;`, "id IN $labels", "predicate operands have incompatible types"}, + {"", `id IN ("text"u)`, "predicate operands have incompatible types"}, + {"", "id IN []", `unsupported IN operand "[]"`}, + } { + t.Run(test.predicate, func(t *testing.T) { + query := "-- name: Read :many\n" + test.declaration + " SELECT id FROM records WHERE " + test.predicate + ";" + _, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: query}}) + if err == nil || !strings.Contains(err.Error(), "invalid predicate: "+test.want) { + t.Fatalf("error = %v, want collection diagnostic %q", err, test.want) + } + }) + } +} + +func TestPredicateComparisonFamiliesUseResolvedOperands(t *testing.T) { + schema := []model.Source{{Name: "schema.sql", Text: `CREATE TABLE records (id Uint64 NOT NULL, label Utf8, PRIMARY KEY(id));`}} + for _, predicate := range []string{ + `label LIKE "a%"u`, + `label NOT LIKE "a%"u`, + "id BETWEEN 1u AND 10u", + "id NOT BETWEEN 1u AND 10u", + "id = 1u", + "label IS DISTINCT FROM NULL", + "label IS NOT DISTINCT FROM NULL", + } { + t.Run(predicate, func(t *testing.T) { + query := "-- name: Read :many\nSELECT id FROM records WHERE " + predicate + ";" + result, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: query}}) + if err != nil { + t.Fatal(err) + } + analyzed := result.Queries[0] + columns := analyzed.ResultSets[0].Columns + if analyzed.SQL != query || len(columns) != 1 || columns[0].Name != "id" || columns[0].Type.String() != "Uint64" { + t.Fatalf("predicate changed result shape or SQL: %+v", analyzed) + } + }) + } +} diff --git a/internal/analyzer/wildcard_rewrites_test.go b/internal/analyzer/wildcard_rewrites_test.go new file mode 100644 index 00000000..3165c5c2 --- /dev/null +++ b/internal/analyzer/wildcard_rewrites_test.go @@ -0,0 +1,55 @@ +package analyzer + +import ( + "reflect" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestWildcardAliasInsertionsPreserveSQLBytes(t *testing.T) { + schema := []model.Source{{Name: "schema.sql", Text: "CREATE TABLE records (id Uint64 NOT NULL, label Utf8, PRIMARY KEY(id));"}} + sql := "-- name: Read :many\r\n-- Привет 😀 *\r\nSELECT \"строка *\"u /* first */, r.*, id > 0ul /* last */\r\nFROM records AS r ORDER BY column2;" + result, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + want := "-- name: Read :many\r\n-- Привет 😀 *\r\nSELECT \"строка *\"u AS `column0` /* first */, r.`id` AS `id`, `r`.`label` AS `label`, id > 0ul AS `column2` /* last */\r\nFROM records AS r ORDER BY column2;" + query := result.Queries[0] + if query.SQL != want { + t.Fatalf("SQL bytes = %q, want %q", query.SQL, want) + } + var names []string + for _, column := range query.ResultSets[0].Columns { + names = append(names, column.ResultName()) + } + if wantNames := []string{"column0", "id", "label", "column2"}; !reflect.DeepEqual(names, wantNames) { + t.Fatalf("result names = %v, want %v", names, wantNames) + } +} + +func TestWildcardRewritesRejectInvalidSpans(t *testing.T) { + // Invalid offsets cannot come from a successfully parsed query. The rewrite + // boundary must still reject them without returning partially modified SQL. + const sql = "SELECT * FROM records;" + for _, tc := range []struct { + name string + replacements []wildcardReplacement + }{ + {"negative start", []wildcardReplacement{{start: -1, end: 0, text: "`id`"}}}, + {"past source end", []wildcardReplacement{{start: len(sql), end: len(sql) + 1, text: " AS `column0`"}}}, + {"reversed span", []wildcardReplacement{{start: 8, end: 7, text: "`id`"}}}, + {"overlapping spans", []wildcardReplacement{{start: 7, end: 10, text: "`id`"}, {start: 9, end: 13, text: "FROM"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + rewrites := wildcardRewrites{source: sql, replacements: tc.replacements} + got, err := rewrites.apply() + if err == nil || err.Error() != "invalid or overlapping wildcard source span" { + t.Fatalf("error = %v, want invalid/overlapping span diagnostic", err) + } + if got != "" { + t.Fatalf("invalid rewrite returned usable SQL: %q", got) + } + }) + } +} From 6208938602847cc9b75fc1baacbc149579facdc6 Mon Sep 17 00:00:00 2001 From: Aleksey Myasnikov Date: Wed, 23 Sep 2026 14:25:52 +0300 Subject: [PATCH 4/4] Address shared expression review and jOOQ alias ordering --- .agents/development.md | 2 +- .agents/sdk-evidence.md | 2 + .agents/yql-evidence.md | 4 + .github/workflows/ci.yml | 2 + CHANGELOG.md | 2 +- internal/analyzer/expression.go | 2 +- internal/analyzer/in_context_test.go | 54 +++++ internal/codegen/java/jooq.go | 24 +- internal/codegen/java/jooq_order_by_test.go | 213 ++++++++++++++++++ .../shared_expressions_jooq_live_test.go | 130 +++++++++++ .../endtoend/shared_expressions_live_test.go | 73 +++++- 11 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 internal/analyzer/in_context_test.go create mode 100644 internal/codegen/java/jooq_order_by_test.go create mode 100644 internal/endtoend/shared_expressions_jooq_live_test.go diff --git a/.agents/development.md b/.agents/development.md index 5c38d430..72805cdd 100644 --- a/.agents/development.md +++ b/.agents/development.md @@ -174,4 +174,4 @@ The [TypeScript](../docs/typescript.md), [Rust](../docs/rust.md) and [PHP](../do `TestLiveYDBEach` generates the callback fixture and runs both Go profiles sequentially using unique disposable tables. It checks full and empty results, a 16 MiB streaming result, cancellation, early termination, no replay of retryable callback errors, connection reuse, sessions and transactions. Run `YDB_CONNECTION_STRING=grpc://localhost:2136/local go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBEach$' -v`. Stable and nightly CI run this suite in a separate sequential step with its own 240-second test budget; stable CI includes `coverage-each-live.out` in integration coverage. `TestEachGeneratedGoCompiles` checks the same harness offline; `TestEachRuntime` in the Go generator package covers deterministic error/cleanup paths and retained-memory bounds. -`TestLiveYDBSharedExpressions` checks Boolean values, conditional aggregates, string concatenation, verified casts, literal-aware COALESCE and implicit result names against server metadata and generated Go native/database/sql execution. Run `YDB_CONNECTION_STRING=grpc://localhost:2136/local go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBSharedExpressions$' -v` sequentially with the other live suites. Stable and nightly CI run the suite in a separate step; stable CI uploads `coverage-shared-expressions-live.out` with the integration coverage. The authors examples additionally execute reporting, prefix search and export metadata through the generated clients. +`TestLiveYDBSharedExpressions` checks Boolean values, conditional aggregates, string concatenation, verified casts, literal-aware COALESCE and implicit result names against server metadata and generated Go native/database/sql execution. Wildcard collision checks compare original and normalized SQL metadata independently and verify positional decoding with distinct values and types. Run `YDB_CONNECTION_STRING=grpc://localhost:2136/local go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBSharedExpressions$' -v` sequentially with the other live suites; set `SQLC_YDB_TEST_MAVEN=mvn` to include generated jOOQ execution for output-alias ordering and wildcard collisions. Stable and nightly CI run the suite in a separate step; stable CI enables jOOQ and uploads `coverage-shared-expressions-live.out` with the integration coverage. The authors examples additionally execute reporting, prefix search and export metadata through the generated clients. diff --git a/.agents/sdk-evidence.md b/.agents/sdk-evidence.md index 4d1c2e4e..f34dc21b 100644 --- a/.agents/sdk-evidence.md +++ b/.agents/sdk-evidence.md @@ -73,6 +73,8 @@ Go parameter binding adapts the historical ParamsBuilder idea against SDK `v3.15 On 2026-09-23, published jOOQ 3.21.0, YDB dialect 2.0.0 and JDBC 2.4.1 validated shared-expression generation. Typed `DSL.function(systemName("COUNT_IF"), YdbTypes.UINT64, ...)`, `Field.cast(YdbTypes.BOOL)` and null-check conditions compile and execute with their analyzer-resolved result types. Ordinary DSL SELECT queries use `ResultQuery.coerce(Field...)` with the already resolved field names/order, just as the existing prefixed result-query path does. This preserves authored SQL while making record constructor types follow the actual YDB result order when implicit names collide; `SELECT "z"u AS z, 2 AS column2, 3, 4` returns Int32/Int32/Int32/Utf8 fields in lexical name order. `TestJooqImplicitColumnCollisionResultOrder` failed javac before this change and then passed compile/execution through the published SDKs with mock JDBC metadata. Separate execution of the generated `:one` and `:many` methods against local YDB 26.3.1.16 confirmed both field order and values. No generated lifecycle, connection ownership or retry policy was added. +The same pinned SDKs compile typed `DSL.field(Name, DataType)` references for computed output aliases in ORDER BY. `TestJooqOrderByPublishedSDK` checks rendered SQL and row mapping through `MockConnection`, including implicit names, aggregate aliases, parentheses, collisions, shadowing and qualified source columns. Multiple sorting keys must be supplied in one `orderBy(...)` call: chaining a second call on `SelectSeekStep1` does not compile. Output-reference types come from the resolved result columns; ordinary source references retain their table bindings, and literals or function calls are not reinterpreted as output names. On 2026-09-23, `TestLiveYDBSharedExpressions/jooq` executed these ordering cases and the mixed-type wildcard collision against local YDB 26.3.1.16; stable CI runs the same subtest with the pinned Maven dependencies. + Index-source follow-up on 2026-09-23 verified `DSL.table(String, QueryPart...)` against the same pinned artifacts. Embedding the typed base table and index `Name` as query parts in `{0} VIEW {1}` preserves RenderMapping; aliases are applied after VIEW. For explicitly declared SQL, only the base-name span is mapped and an implicit qualifier, when needed, is inserted after the index selection. The authors generated-method compilation/render tests and mapped-table live test passed on local-ydb 26.3.1.8; quoted-index regressions include escaped backticks at identifier boundaries. Pinned runtime: jooq 3.21.0, jooq-ydb-dialect 2.0.0, ydb-jdbc-driver 2.4.1, Java 21. Inspected the published jars and corresponding ydb-java-dialects sources: [YdbTypes](https://github.com/ydb-platform/ydb-java-dialects/blob/main/jooq-dialect/src/main/java/tech/ydb/jooq/YdbTypes.java), [UpsertTest](https://github.com/ydb-platform/ydb-java-dialects/blob/main/jooq-dialect/src/test/java/tech/ydb/jooq/UpsertTest.java), [YdbDSLContextImpl](https://github.com/ydb-platform/ydb-java-dialects/blob/main/jooq-dialect/src/main/java/tech/ydb/jooq/impl/YdbDSLContextImpl.java). `org.jooq.impl.YdbListener` quotes Name nodes except Name.Quoted.SYSTEM. The DEFAULT DML execution path uses executeUpdate/getGeneratedKeys, incompatible with YDB RETURNING result sets. Generated RETURNING statements therefore execute as ResultQuery query parts with explicit field coercion. The exact compatibility contract and commands are in [Java generation](../docs/java.md#jooq-prototype); `tests/examples/java/jooq` compiles every example method and executes live checks in isolated tables. No upstream source was copied into the generator. diff --git a/.agents/yql-evidence.md b/.agents/yql-evidence.md index 9c608e23..03ef0460 100644 --- a/.agents/yql-evidence.md +++ b/.agents/yql-evidence.md @@ -82,4 +82,8 @@ On the same server, `SELECT id > 0ul AS id ... ORDER BY id = $p` compared the pr `TestLiveYDBSharedExpressions` passed against the already-running `ydbplatform/local-ydb:26.3.1.16` (`sha256:32687d3bc4b7a3e4200e2142800e5fc2e91d48ba46160ea094e9f1ce56794c12`) using Go SDK v3.151.1 through generated native Query SDK and database/sql clients. Metadata comparisons and actual execution covered optional Boolean/string expressions, empty/grouped/NULL COUNT_IF, integer/Bool casts and Uint32 narrowing boundaries, invalid JSON, timestamp conversions, fitting COALESCE fallbacks, UTC/Random/Version/NANVL types, implicit names and mixed-type collisions, and wildcard normalization with ORDER BY. Additional direct server probes covered all integer/Bool CAST widths, nonliteral COALESCE fallbacks, optional Tz result types and rejection of Utf8 zone arguments; those probes do not establish Tz decoding support in every output runtime. The test only created and removed its own tables and did not restart the shared service. +The same suite additionally verified the combined wildcard and collision-shift case on 2026-09-23: for a table with `za Utf8 NOT NULL` and `zb Uint64 NOT NULL`, authored `SELECT t.*, 1, 2 AS column1 FROM t` returned wire names `column1, column2, za, zb` with types `Int32, Int32, Utf8, Uint64` and positional values `2, 1, "after", 18446744073709551615`. The normalized explicit projection returned `za, zb, column2, column1`; its reanalyzed result metadata and both generated Go adapters decoded those values correctly. The test hardcodes and checks both raw and normalized server metadata independently, so agreement between normalization and analysis alone cannot hide a wrong order. This evidence distinguishes the original wildcard collision's lexical ordering from the explicit aliases inserted during normalization. + +The suite's opt-in jOOQ subtest also passed on that image with jOOQ 3.21.0, dialect 2.0.0 and JDBC 2.4.1. It executed implicit output names with multiple ORDER BY keys, a grouped `COUNT(*) AS n` ordered by `n DESC`, explicit/implicit aliases, parenthesized output ordering, a computed Bool alias shadowing a physical Utf8 column, qualified physical columns, and collision-renamed outputs. Its normalized wildcard-collision query decoded the same Utf8, maximum Uint64 and Int32 values through jOOQ's typed row mapping. The subtest creates and drops its own uniquely named tables and runs after both Go adapters; it requires `SQLC_YDB_TEST_MAVEN` in addition to `YDB_CONNECTION_STRING`. + The checked-in authors scenarios also passed on that server in both Go profiles and jOOQ 3.21.0 with dialect 2.0.0 and JDBC 2.4.1. They exercise prefix filtering, nullable/empty/nonempty bios, empty/nonempty conditional statistics, unaliased CAST results, and export metadata with a fitting Uint32 fallback. Date and Datetime are explicitly cast to String in the SQL example because not every output runtime supports those native result types. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49000528..6a9fc12e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,8 @@ jobs: run: go test -p 1 -count=1 -timeout=240s -covermode=atomic -coverpkg=./... -coverprofile=coverage-each-live.out ./internal/endtoend -run '^TestLiveYDBEach$' -v - name: Verify shared expressions and implicit result names with YDB timeout-minutes: 5 + env: + SQLC_YDB_TEST_MAVEN: mvn run: go test -p 1 -count=1 -timeout=240s -covermode=atomic -coverpkg=./... -coverprofile=coverage-shared-expressions-live.out ./internal/endtoend -run '^TestLiveYDBSharedExpressions$' -v - name: Verify public query metadata and EXPLAIN contracts run: go test -p 1 -count=1 -timeout=120s ./internal/database -run '^TestLiveYDBQueryMetadata$' -v diff --git a/CHANGELOG.md b/CHANGELOG.md index fc74d726..e0593b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Resolve Boolean expressions and string concatenation consistently in projections, function arguments, local bindings and supported DML values. Add conditional `COUNT_IF` aggregates, fitting integer-literal fallbacks in COALESCE/NVL, and verified Boolean, timestamp and JSON casts. - Support the UTC clock family and additional scalar built-ins, with a documented inventory of the upstream YQL builtin catalog and explicit prerequisites for remaining families. -- Infer YDB result names for computed SELECT columns without AS. Add authors reporting, prefix-search and export-metadata examples for every language/runtime profile. +- Infer YDB result names for computed SELECT columns without AS, including typed jOOQ ORDER BY references to computed aliases and implicit result names. Add authors reporting, prefix-search and export-metadata examples for every language/runtime profile. ### Fixed diff --git a/internal/analyzer/expression.go b/internal/analyzer/expression.go index 0204fd1f..f63ba545 100644 --- a/internal/analyzer/expression.go +++ b/internal/analyzer/expression.go @@ -100,7 +100,7 @@ func resolveComparison(expr antlr.ParserRuleContext, scope expressionScope) (mod return model.Type{Kind: "Bool"}, true, nil } if condition.IN() != nil { - return model.Type{}, true, fmt.Errorf("typed IN expressions are not yet supported outside WHERE or JOIN predicates") + return model.Type{}, true, fmt.Errorf("typed IN expressions are supported only in WHERE and JOIN predicates; they are not yet supported in projections, CASE, IF, or HAVING") } operands = append(operands, xor.Eq_subexpr()) for _, operand := range condition.AllEq_subexpr() { diff --git a/internal/analyzer/in_context_test.go b/internal/analyzer/in_context_test.go new file mode 100644 index 00000000..2872114e --- /dev/null +++ b/internal/analyzer/in_context_test.go @@ -0,0 +1,54 @@ +package analyzer + +import ( + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestTypedINReportsUnsupportedExpressionContext(t *testing.T) { + schema := []model.Source{{Name: "schema.sql", Text: `CREATE TABLE records (id Uint64 NOT NULL, PRIMARY KEY(id));`}} + const want = "typed IN expressions are supported only in WHERE and JOIN predicates; they are not yet supported in projections, CASE, IF, or HAVING" + for _, tc := range []struct{ name, statement, context string }{ + {"having", "SELECT id FROM records GROUP BY id HAVING id IN $ids;", "cannot resolve HAVING expression"}, + {"projection", "SELECT id IN $ids AS selected FROM records;", ""}, + {"case", "SELECT CASE WHEN id IN $ids THEN 1u ELSE 0u END AS selected FROM records;", "cannot resolve CASE condition"}, + {"if", "SELECT IF(id IN $ids, 1u, 0u) AS selected FROM records;", "cannot resolve argument of IF"}, + } { + t.Run(tc.name, func(t *testing.T) { + query := "-- name: Read :many\nDECLARE $ids AS List;\n" + tc.statement + result, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: query}}) + if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), tc.context) { + t.Fatalf("error = %v; want explicit IN context boundary in %s", err, tc.name) + } + if len(result.Queries) != 0 { + t.Fatal("unsupported IN expression produced an analyzed query") + } + }) + } +} + +func TestTypedINRemainsSupportedInPredicateContexts(t *testing.T) { + schema := []model.Source{{Name: "schema.sql", Text: `CREATE TABLE records (id Uint64 NOT NULL, PRIMARY KEY(id));`}} + for _, statement := range []string{ + "SELECT id FROM records WHERE id IN $ids;", + "SELECT a.id FROM records AS a JOIN records AS b ON a.id = b.id AND a.id IN $ids;", + } { + t.Run(statement, func(t *testing.T) { + query := "-- name: Read :many\nDECLARE $ids AS List;\n" + statement + result, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: query}}) + if err != nil { + t.Fatal(err) + } + analyzed := result.Queries[0] + if analyzed.SQL != query || len(analyzed.Parameters) != 1 || analyzed.Parameters[0].Name != "ids" || analyzed.Parameters[0].Type.String() != "List" { + t.Fatalf("IN predicate changed SQL or parameter type: %+v", analyzed) + } + columns := analyzed.ResultSets[0].Columns + if len(columns) != 1 || columns[0].Type.String() != "Uint64" { + t.Fatalf("IN predicate changed result type: %+v", columns) + } + }) + } +} diff --git a/internal/codegen/java/jooq.go b/internal/codegen/java/jooq.go index 887df9fd..0ba27680 100644 --- a/internal/codegen/java/jooq.go +++ b/internal/codegen/java/jooq.go @@ -348,6 +348,24 @@ func (r *jooqRenderer) expr(n antlr.Tree) string { if c, ok := r.query.Syntax.Columns[node.GetStart().GetTokenIndex()]; ok && node.Unary_casual_subexpr() != nil { return r.col(r.aliases[c.Alias], c.Column.Name) } + if casual := node.Unary_casual_subexpr(); casual != nil && casual.Id_expr() != nil && casual.Unary_subexpr_suffix().GetText() == "" { + identifier := casual.Id_expr().GetText() + if strings.HasPrefix(identifier, "`") { + identifier = strings.ReplaceAll(identifier[1:len(identifier)-1], "``", "`") + } + for parent := node.GetParent(); parent != nil; parent = parent.GetParent() { + if _, ok := parent.(*parser.Order_by_clauseContext); ok { + for _, result := range r.query.ResultSets { + for _, column := range result.Columns { + if identifier == column.ResultName() { + // Result types were validated before rendering the statement. + return "field(name(" + quoted(column.ResultName()) + "), YdbTypes." + strings.ToUpper(column.Type.UnwrapOptional().Kind) + ")" + } + } + } + } + } + } } switch node := n.(type) { case *parser.Cast_exprContext: @@ -637,12 +655,16 @@ func (r *jooqRenderer) selectQuery(core *parser.Select_coreContext) string { } body += "\n .groupBy(" + strings.Join(items, ", ") + ")" } + var ordering []string for _, order := range jooqNodes[*parser.Sort_specificationContext](r.query.Syntax.Root) { expr := r.expr(order.Expr()) if strings.HasSuffix(strings.ToUpper(order.GetText()), "DESC") { expr += ".desc()" } - body += "\n .orderBy(" + expr + ")" + ordering = append(ordering, expr) + } + if len(ordering) != 0 { + body += "\n " + strings.ReplaceAll(jooqCall(".orderBy", ordering), "\n", "\n ") } for _, limit := range jooqNodes[*parser.Select_kind_partialContext](r.query.Syntax.Root) { if limit.LIMIT() == nil { diff --git a/internal/codegen/java/jooq_order_by_test.go b/internal/codegen/java/jooq_order_by_test.go new file mode 100644 index 00000000..5a6dffc6 --- /dev/null +++ b/internal/codegen/java/jooq_order_by_test.go @@ -0,0 +1,213 @@ +package java + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +const jooqOrderBySchema = `CREATE TABLE records (id Uint64 NOT NULL, a Utf8 NOT NULL, column0 Utf8, PRIMARY KEY(id));` + +func TestJooqOrderByResolvedOutputNames(t *testing.T) { + for _, tc := range []struct { + name, sql string + orders []string + }{ + {"implicit", `SELECT 1, 2 FROM records ORDER BY column0 DESC, column1;`, []string{`field(name("column0"), YdbTypes.INT32).desc()`, `field(name("column1"), YdbTypes.INT32)`}}, + {"aggregate", `SELECT a, COUNT(*) AS n FROM records GROUP BY a ORDER BY n DESC;`, []string{`field(name("n"), YdbTypes.UINT64).desc()`}}, + {"explicit and implicit", `SELECT a AS column0, 3 FROM records ORDER BY column1;`, []string{`field(name("column1"), YdbTypes.INT32)`}}, + {"parenthesized", `SELECT 1 FROM records ORDER BY ((column0)) DESC;`, []string{`field(name("column0"), YdbTypes.INT32).desc()`}}, + {"shadowing", `SELECT id IS NOT NULL AS a FROM records ORDER BY a DESC;`, []string{`field(name("a"), YdbTypes.BOOL).desc()`}}, + {"qualified source", `SELECT 1 FROM records AS r ORDER BY r.column0;`, []string{`r.COLUMN0`}}, + {"physical alias", `SELECT a AS column0, 3 FROM records ORDER BY column0;`, []string{`RECORDS.A`}}, + {"renamed collision", `SELECT 1, 2 AS column0 FROM records ORDER BY column1;`, []string{`field(name("column1"), YdbTypes.INT32)`}}, + {"quoted output", "SELECT 1 AS `some-name` FROM records ORDER BY `some-name`;", []string{`field(name("some-name"), YdbTypes.INT32)`}}, + {"quoted keyword", "SELECT id IS NOT NULL AS `true` FROM records ORDER BY `true`;", []string{`field(name("true"), YdbTypes.BOOL)`}}, + } { + t.Run(tc.name, func(t *testing.T) { + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqOrderBySchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + tc.sql}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "orderby", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + var queries string + for _, file := range files { + if file.Name == "Queries.java" { + queries = string(file.Content) + } + } + if queries == "" { + t.Fatal("Queries.java was not generated") + } + if strings.Count(queries, ".orderBy(") != 1 { + t.Fatalf("expected one ORDER BY call:\n%s", queries) + } + ordering := queries[strings.Index(queries, ".orderBy("):] + ordering = ordering[:strings.Index(ordering, ".coerce(")] + for _, order := range tc.orders { + index := strings.Index(ordering, order) + if index < 0 { + t.Errorf("missing typed ORDER BY %s:\n%s", order, queries) + continue + } + ordering = ordering[index+len(order):] + } + }) + } +} + +func TestJooqOrderByDoesNotInventOutputReferences(t *testing.T) { + for _, tc := range []struct{ sql, want string }{ + {`SELECT 1 FROM records ORDER BY missing;`, `unknown column "missing"`}, + {`SELECT 1 AS n FROM records WHERE n = 1;`, `unknown column "n"`}, + {`SELECT 1 AS n FROM records ORDER BY n + 1;`, `ORDER BY expressions referencing projection aliases are not yet supported`}, + } { + t.Run(tc.sql, func(t *testing.T) { + _, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqOrderBySchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\n" + tc.sql}}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqOrderBySchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read :many\nSELECT id IS NOT NULL AS `true` FROM records ORDER BY true;"}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "orderby", Runtime: "jooq"}) + if files != nil || err == nil || err.Error() != `Read: unsupported jOOQ syntax "true"` { + t.Fatalf("literal was treated as a reference to the quoted output: files=%v, error=%v", files, err) + } +} + +const jooqOrderByQueries = `-- name: Implicit :many +SELECT 1, 2 FROM records ORDER BY column0 DESC, column1; +-- name: Aggregate :many +SELECT a, COUNT(*) AS n FROM records GROUP BY a ORDER BY n DESC; +-- name: Mixed :many +SELECT a AS column0, 3 FROM records ORDER BY column1; +-- name: Parenthesized :many +SELECT 1 FROM records ORDER BY ((column0)) DESC; +-- name: Shadow :many +SELECT id IS NOT NULL AS a FROM records ORDER BY a DESC; +-- name: Qualified :many +SELECT 1 FROM records AS r ORDER BY r.column0; +-- name: Physical :many +SELECT a AS column0, 3 FROM records ORDER BY column0; +-- name: Collision :many +SELECT 1, 2 AS column0 FROM records ORDER BY column1;` + +func TestJooqOrderByPublishedSDK(t *testing.T) { + maven := os.Getenv("SQLC_YDB_TEST_MAVEN") + if maven == "" { + t.Skip("set SQLC_YDB_TEST_MAVEN to compile and execute ORDER BY references against the published dialect") + } + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqOrderBySchema}}, []model.Source{{Name: "queries.sql", Text: jooqOrderByQueries}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "orderby", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + classpath := filepath.Join(dir, "classpath") + cmd := exec.Command(maven, "-q", "dependency:build-classpath", "-Dmdep.outputFile="+classpath) + cmd.Dir = filepath.Join("..", "..", "..", "tests", "examples", "java", "jooq") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("SDK classpath: %v\n%s", err, out) + } + cp, err := os.ReadFile(classpath) + if err != nil { + t.Fatal(err) + } + program := `package orderby; +import java.util.*; +import org.jooq.tools.jdbc.*; +import org.jooq.types.ULong; +import tech.ydb.jooq.YDB; +import tech.ydb.jooq.YdbTypes; +import static org.jooq.impl.DSL.*; +public class Main { + public static void main(String[] args) throws Exception { + var orders = List.of("column0 desc, column1", "n desc", "column1", "column0 desc", "a desc", "r.column0", "records.a", "column1"); + var statements = new ArrayList(); + try (var connection = new MockConnection(ctx -> { + String sql = ctx.sql().replace("` + "`" + `", "").replaceAll("\\s+", " ").replace(" asc", "").trim().toLowerCase(Locale.ROOT); + int index = statements.size(); + statements.add(sql); + if (!sql.endsWith(" order by " + orders.get(index))) throw new AssertionError(sql); + if (ctx.bindings().length != 0) throw new AssertionError(Arrays.toString(ctx.bindings())); + var dsl = YDB.using(); + var column0 = field(name("column0"), YdbTypes.INT32); + var column1 = field(name("column1"), YdbTypes.INT32); + var textColumn0 = field(name("column0"), YdbTypes.UTF8); + var a = field(name("a"), YdbTypes.UTF8); + var n = field(name("n"), YdbTypes.UINT64); + var booleanA = field(name("a"), YdbTypes.BOOL); + org.jooq.Result result; + switch (index) { + case 0, 7 -> { + var rows = dsl.newResult(column0, column1); + rows.add(dsl.newRecord(column0, column1).values(index == 0 ? 1 : 2, index == 0 ? 2 : 1)); + result = rows; + } + case 1 -> { + var rows = dsl.newResult(a, n); + rows.add(dsl.newRecord(a, n).values("x", ULong.valueOf(9))); + result = rows; + } + case 2, 6 -> { + var rows = dsl.newResult(textColumn0, column1); + rows.add(dsl.newRecord(textColumn0, column1).values("x", 3)); + result = rows; + } + case 3, 5 -> { + var rows = dsl.newResult(column0); + rows.add(dsl.newRecord(column0).values(1)); + result = rows; + } + case 4 -> { + var rows = dsl.newResult(booleanA); + rows.add(dsl.newRecord(booleanA).values(true)); + result = rows; + } + default -> throw new AssertionError(sql); + } + return new MockResult[]{new MockResult(1, result)}; + })) { + var queries = new Queries(YDB.using(connection)); + if (!queries.implicit().equals(List.of(new ImplicitRow(1, 2)))) throw new AssertionError("implicit"); + if (!queries.aggregate().equals(List.of(new AggregateRow("x", ULong.valueOf(9))))) throw new AssertionError("aggregate"); + if (!queries.mixed().equals(List.of(new MixedRow("x", 3)))) throw new AssertionError("mixed"); + if (!queries.parenthesized().equals(List.of(new ParenthesizedRow(1)))) throw new AssertionError("parenthesized"); + if (!queries.shadow().equals(List.of(new ShadowRow(true)))) throw new AssertionError("shadow"); + if (!queries.qualified().equals(List.of(new QualifiedRow(1)))) throw new AssertionError("qualified"); + if (!queries.physical().equals(List.of(new PhysicalRow("x", 3)))) throw new AssertionError("physical"); + if (!queries.collision().equals(List.of(new CollisionRow(2, 1)))) throw new AssertionError("collision"); + if (statements.size() != orders.size()) throw new AssertionError(statements); + } + } +}` + files = append(files, model.File{Name: "Main.java", Content: []byte(program)}) + compile := []string{"-cp", strings.TrimSpace(string(cp)), "-d", dir} + for _, file := range files { + path := filepath.Join(dir, file.Name) + if err := os.WriteFile(path, file.Content, 0600); err != nil { + t.Fatal(err) + } + compile = append(compile, path) + } + for _, args := range [][]string{append([]string{"javac"}, compile...), {"java", "-cp", dir + string(os.PathListSeparator) + strings.TrimSpace(string(cp)), "orderby.Main"}} { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", args[0], err, out) + } + } +} diff --git a/internal/endtoend/shared_expressions_jooq_live_test.go b/internal/endtoend/shared_expressions_jooq_live_test.go new file mode 100644 index 00000000..0bc44df3 --- /dev/null +++ b/internal/endtoend/shared_expressions_jooq_live_test.go @@ -0,0 +1,130 @@ +package endtoend + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/codegen/java" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func sharedExpressionsJooq(t *testing.T) { + maven := os.Getenv("SQLC_YDB_TEST_MAVEN") + if maven == "" { + t.Skip("set SQLC_YDB_TEST_MAVEN for live generated jOOQ expression checks") + } + table := fmt.Sprintf("sqlc_jooq_expressions_t%d", time.Now().UnixNano()) + collision := table + "_collision" + schema := "CREATE TABLE " + table + " (id Uint64 NOT NULL, a Utf8 NOT NULL, column0 Utf8, PRIMARY KEY(id));" + collisionSchema := "CREATE TABLE " + collision + " (za Utf8 NOT NULL, zb Uint64 NOT NULL, PRIMARY KEY(zb));" + queries := strings.ReplaceAll(sharedExpressionsJooqQueries, "records", table) + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: schema + "\n" + collisionSchema}}, []model.Source{{Name: "queries.sql", Text: queries}}) + if err != nil { + t.Fatal(err) + } + files, err := java.Generate(analysis, java.Options{Package: "expressionlive", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + classpath := filepath.Join(dir, "classpath") + cmd := exec.Command(maven, "-q", "dependency:build-classpath", "-Dmdep.outputFile="+classpath) + cmd.Dir = filepath.Join("..", "..", "tests", "examples", "java", "jooq") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("jOOQ SDK classpath: %v\n%s", err, out) + } + cp, err := os.ReadFile(classpath) + if err != nil { + t.Fatal(err) + } + program := strings.NewReplacer("$TABLE", strconv.Quote(table), "$COLLISION_TABLE", strconv.Quote(collision), "$SCHEMA", strconv.Quote(schema), "$COLLISION_SCHEMA", strconv.Quote(collisionSchema)).Replace(sharedExpressionsJooqProgram) + files = append(files, model.File{Name: "Main.java", Content: []byte(program)}) + compile := []string{"-cp", strings.TrimSpace(string(cp)), "-d", dir} + for _, file := range files { + filename := filepath.Join(dir, file.Name) + if err := os.WriteFile(filename, file.Content, 0600); err != nil { + t.Fatal(err) + } + compile = append(compile, filename) + } + for _, args := range [][]string{append([]string{"javac"}, compile...), {"java", "-cp", dir + string(os.PathListSeparator) + strings.TrimSpace(string(cp)), "expressionlive.Main"}} { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("jOOQ expressions %s: %v\n%s", args[0], err, out) + } + } +} + +const sharedExpressionsJooqQueries = `-- name: Implicit :many +SELECT 1, 2 FROM records ORDER BY column0 DESC, column1; +-- name: Aggregate :many +SELECT a, COUNT(*) AS n FROM records GROUP BY a ORDER BY n DESC; +-- name: Mixed :many +SELECT a AS column0, 3 FROM records ORDER BY column1; +-- name: Parenthesized :many +SELECT 1 FROM records ORDER BY ((column0)) DESC; +-- name: Shadow :many +SELECT id IS NOT NULL AS a FROM records ORDER BY a DESC; +-- name: Qualified :many +SELECT 1 FROM records AS r ORDER BY r.column0; +-- name: Physical :many +SELECT a AS column0, 3 FROM records ORDER BY column0; +-- name: Collision :many +SELECT 1, 2 AS column0 FROM records ORDER BY column1; +-- name: WildcardCollision :one +SELECT t.*, 1, 2 AS column1 FROM records_collision AS t;` + +const sharedExpressionsJooqProgram = `package expressionlive; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import org.jooq.types.ULong; +import tech.ydb.jooq.YDB; +public class Main { + public static void main(String[] args) throws Exception { + var created = new ArrayList(); + try (var connection = DriverManager.getConnection("jdbc:ydb:" + System.getenv("YDB_CONNECTION_STRING"))) { + try { + try (var statement = connection.createStatement()) { + statement.execute($SCHEMA); + created.add($TABLE); + statement.execute($COLLISION_SCHEMA); + created.add($COLLISION_TABLE); + statement.execute("UPSERT INTO " + $TABLE + " (id,a,column0) VALUES (1ul,'x'u,'c'u),(2ul,'x'u,'b'u),(3ul,'y'u,'a'u);"); + statement.execute("UPSERT INTO " + $COLLISION_TABLE + " (za,zb) VALUES ('after'u,18446744073709551615ul);"); + } + var queries = new Queries(YDB.using(connection)); + var implicit = queries.implicit(); + if (!implicit.equals(List.of(new ImplicitRow(1,2), new ImplicitRow(1,2), new ImplicitRow(1,2)))) throw new AssertionError(implicit); + var aggregate = queries.aggregate(); + if (!aggregate.equals(List.of(new AggregateRow("x", ULong.valueOf(2)), new AggregateRow("y", ULong.valueOf(1))))) throw new AssertionError(aggregate); + var mixed = queries.mixed(); + if (mixed.size() != 3 || mixed.stream().anyMatch(r -> r.column1() != 3) || mixed.stream().filter(r -> r.column0().equals("x")).count() != 2 || mixed.stream().filter(r -> r.column0().equals("y")).count() != 1) throw new AssertionError(mixed); + var parenthesized = queries.parenthesized(); + if (!parenthesized.equals(List.of(new ParenthesizedRow(1), new ParenthesizedRow(1), new ParenthesizedRow(1)))) throw new AssertionError(parenthesized); + var shadow = queries.shadow(); + if (!shadow.equals(List.of(new ShadowRow(true), new ShadowRow(true), new ShadowRow(true)))) throw new AssertionError(shadow); + var qualified = queries.qualified(); + if (!qualified.equals(List.of(new QualifiedRow(1), new QualifiedRow(1), new QualifiedRow(1)))) throw new AssertionError(qualified); + var physical = queries.physical(); + if (!physical.equals(List.of(new PhysicalRow("x",3), new PhysicalRow("x",3), new PhysicalRow("y",3)))) throw new AssertionError(physical); + var collision = queries.collision(); + if (!collision.equals(List.of(new CollisionRow(2,1), new CollisionRow(2,1), new CollisionRow(2,1)))) throw new AssertionError(collision); + var wildcard = queries.wildcardCollision().orElseThrow(); + if (!wildcard.equals(new WildcardCollisionRow("after", ULong.MAX, 1, 2))) throw new AssertionError(wildcard); + } finally { + for (String table : created.reversed()) { + try (var statement = connection.createStatement()) { + statement.execute("DROP TABLE " + table); + } + } + } + } + } +}` diff --git a/internal/endtoend/shared_expressions_live_test.go b/internal/endtoend/shared_expressions_live_test.go index 92b2ee47..22f48d74 100644 --- a/internal/endtoend/shared_expressions_live_test.go +++ b/internal/endtoend/shared_expressions_live_test.go @@ -23,8 +23,11 @@ func TestLiveYDBSharedExpressions(t *testing.T) { dir := t.TempDir() table := fmt.Sprintf("sqlc_expressions_%d", time.Now().UnixNano()) schema := "CREATE TABLE " + table + " (id Uint64 NOT NULL, enabled Bool NOT NULL, flag Bool, counter Uint32, text String, PRIMARY KEY(id));" + collisionTable := table + "_collision" + collisionSchema := "CREATE TABLE " + collisionTable + " (za Utf8 NOT NULL, zb Uint64 NOT NULL, PRIMARY KEY(zb));" + combinedSchema := schema + "\n" + collisionSchema queries := strings.ReplaceAll(sharedExpressionsQueries, "records", table) - analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: schema}}, []model.Source{{Name: "queries.sql", Text: queries}}) + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: combinedSchema}}, []model.Source{{Name: "queries.sql", Text: queries}}) if err != nil { t.Fatal(err) } @@ -32,7 +35,7 @@ func TestLiveYDBSharedExpressions(t *testing.T) { for _, runtime := range []string{"ydb", "database/sql"} { configuration += "- engine: ydb\n schema: schema.sql\n queries: queries.sql\n gen:\n go:\n package: records\n out: " + strings.ReplaceAll(runtime, "/", "_") + "\n sql_package: " + runtime + "\n" } - for name, contents := range map[string]string{"schema.sql": schema, "queries.sql": queries, "sqlc.yaml": configuration, "go.mod": "module generated\n\ngo 1.26.0\n\nrequire github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1\n"} { + for name, contents := range map[string]string{"schema.sql": combinedSchema, "queries.sql": queries, "sqlc.yaml": configuration, "go.mod": "module generated\n\ngo 1.26.0\n\nrequire github.com/ydb-platform/ydb-go-sdk/v3 v3.151.1\n"} { if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0600); err != nil { t.Fatal(err) } @@ -49,7 +52,11 @@ func TestLiveYDBSharedExpressions(t *testing.T) { } analysis.Queries = append(analysis.Queries, timezones.Queries...) var metadata strings.Builder + var collisionSQL string for _, query := range analysis.Queries { + if query.Name == "WildcardCollision" { + collisionSQL = query.SQL + } fmt.Fprintf(&metadata, "\n checkMetadata(t,ctx,driver,%q,[]string{", query.SQL) for _, column := range query.ResultSets[0].Columns { fmt.Fprintf(&metadata, "%q,", column.ResultName()) @@ -67,10 +74,11 @@ func TestLiveYDBSharedExpressions(t *testing.T) { setup = "db := sql.OpenDB(ydb.MustConnector(driver)); defer db.Close(); q := New(db)" sqlImport = "\"database/sql\"" } - source := strings.NewReplacer("$DSN", strconv.Quote(dsn), "$SCHEMA", strconv.Quote(schema), "$TABLE", strconv.Quote(table), "$SETUP", setup, "$SQL_IMPORT", sqlImport, "$METADATA", metadata.String()).Replace(sharedExpressionsRuntime) + source := strings.NewReplacer("$DSN", strconv.Quote(dsn), "$SCHEMA", strconv.Quote(schema), "$TABLE", strconv.Quote(table), "$COLLISION_SCHEMA", strconv.Quote(collisionSchema), "$COLLISION_TABLE", strconv.Quote(collisionTable), "$COLLISION_SQL", strconv.Quote(collisionSQL), "$SETUP", setup, "$SQL_IMPORT", sqlImport, "$METADATA", metadata.String()).Replace(sharedExpressionsRuntime) compileTypedDMLPackage(t, dir, "./"+strings.ReplaceAll(runtime, "/", "_"), source, false) }) } + t.Run("jooq", sharedExpressionsJooq) } const sharedExpressionsQueries = `-- name: Logic :many @@ -101,6 +109,9 @@ SELECT 'last' AS z, 2 AS column2, 3, 4; -- name: Mixed :many SELECT t.*, COALESCE(counter, 0u) FROM records AS t ORDER BY column1; +-- name: WildcardCollision :one +SELECT t.*, 1, 2 AS column1 FROM records_collision AS t; + -- name: Builtins :one SELECT CurrentUtcDate() AS today, CurrentUtcDatetime(1) AS seconds, CurrentUtcTimestamp(NULL) AS micros, Random(1) AS random_value, RandomNumber(1) AS random_number, RandomUuid(NULL) AS random_uuid, @@ -141,6 +152,20 @@ func TestSharedExpressions(t *testing.T) { t.Error(err) } }() + collisionTable := $COLLISION_TABLE + if err := driver.Query().Exec(ctx, $COLLISION_SCHEMA); err != nil { + t.Fatal(err) + } + defer func() { + cleanup, done := context.WithTimeout(context.Background(), 10*time.Second) + defer done() + if err := driver.Query().Exec(cleanup, "DROP TABLE "+collisionTable); err != nil { + t.Error(err) + } + }() + if err := driver.Query().Exec(ctx, "UPSERT INTO "+collisionTable+" (za,zb) VALUES ('after'u,18446744073709551615ul);"); err != nil { + t.Fatal(err) + } if err := driver.Query().Exec(ctx, "UPSERT INTO "+table+" (id,enabled,flag,counter,text) VALUES (1ul,false,NULL,NULL,NULL),(2ul,true,true,7u,'{}'),(18446744073709551615ul,true,false,4294967295u,'bad');"); err != nil { t.Fatal(err) } @@ -185,6 +210,16 @@ func TestSharedExpressions(t *testing.T) { if err != nil || len(mixed) != 3 || mixed[0].ID != 1 || mixed[0].Column1 != 0 || mixed[1].ID != 2 || mixed[1].Column1 != 7 { t.Fatalf("wildcard and original implicit ORDER BY name: %+v %v", mixed, err) } + // The authored wildcard and the normalized explicit projection have different + // wire orders; check each independently instead of deriving both from analysis. + rawCollision := "SELECT t.*, 1, 2 AS column1 FROM "+collisionTable+" AS t;" + checkMetadata(t, ctx, driver, rawCollision, []string{"column1", "column2", "za", "zb"}, []string{"Int32", "Int32", "Utf8", "Uint64"}) + checkMetadata(t, ctx, driver, $COLLISION_SQL, []string{"za", "zb", "column2", "column1"}, []string{"Utf8", "Uint64", "Int32", "Int32"}) + checkRawWildcardCollision(t, ctx, driver, rawCollision) + collision, err := q.WildcardCollision(ctx) + if err != nil || collision.Za != "after" || collision.Zb != ^uint64(0) || collision.Column1 != 2 || collision.Column2 != 1 { + t.Fatalf("normalized wildcard collision positional decoding: %+v %v", collision, err) + } before := time.Now().Add(-time.Minute) builtins, err := q.Builtins(ctx) if err != nil || builtins.Today.IsZero() || builtins.Seconds.Before(before) || builtins.Micros.Before(before) || builtins.RandomValue < 0 || builtins.RandomValue >= 1 || len(builtins.Version) == 0 || builtins.Replacement == nil || *builtins.Replacement != 1.5 || len(builtins.TimestampText) == 0 || builtins.TimestampNumber < uint64(before.UnixMicro()) || builtins.InvalidTimestamp != nil { @@ -196,6 +231,38 @@ func TestSharedExpressions(t *testing.T) { } } } +func checkRawWildcardCollision(t *testing.T, ctx context.Context, driver *ydb.Driver, statement string) { + t.Helper() + result, err := driver.Query().Query(ctx, statement) + if err != nil { + t.Fatal(err) + } + defer result.Close(ctx) + set, err := result.NextResultSet(ctx) + if err != nil { + t.Fatal(err) + } + row, err := set.NextRow(ctx) + if err != nil { + t.Fatal(err) + } + var column1, column2 int32 + var za string + var zb uint64 + if err := row.Scan(&column1, &column2, &za, &zb); err != nil { + t.Fatal(err) + } + if column1 != 2 || column2 != 1 || za != "after" || zb != ^uint64(0) { + t.Fatalf("raw wildcard collision positional values: %d %d %q %d", column1, column2, za, zb) + } + t.Logf("raw wildcard collision wire order %v, values %d %d %q %d", set.Columns(), column1, column2, za, zb) + if _, err := set.NextRow(ctx); err != io.EOF { + t.Fatalf("extra collision row: %v", err) + } + if _, err := result.NextResultSet(ctx); err != io.EOF { + t.Fatalf("extra collision result: %v", err) + } +} func checkMetadata(t *testing.T, ctx context.Context, driver *ydb.Driver, statement string, names, types []string) { t.Helper() result, err := driver.Query().Query(ctx, statement)