diff --git a/.agents/architecture.md b/.agents/architecture.md index 1a2eb278..2c7cf448 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -20,6 +20,8 @@ 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. +IN subqueries reuse the SELECT semantic core with an independent relation scope. Outer column, predicate, inference and aggregate traversals stop at nested SELECT boundaries; shared external parameter types remain consistent across scopes. Tuple keys are resolved only for the supported IN operands and are not exposed as a new generated parameter/result contract. Per-SELECT resolved bindings accompany the original ANTLR contexts so the jOOQ renderer can preserve shadowed aliases without reanalyzing SQL. + `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/development.md b/.agents/development.md index 72805cdd..5689b1c4 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, 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. +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, IN-subquery, Go generator and Python generator live tests and uploads their eight 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. Codecov reports line coverage, including partially covered lines; its percentages are not directly comparable to Go's statement coverage. Verify the updated Codecov report for the final PR head before claiming that a coverage regression is fixed. 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. @@ -175,3 +175,5 @@ 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. 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. + +`TestLiveYDBInSubqueries` checks noncorrelated scalar and tuple-key IN/NOT IN subqueries against YDB and both generated Go profiles. It covers single-column subquery contracts, full composite keys, empty inputs, nullable operands, alias scopes and mutations. Run `YDB_CONNECTION_STRING=grpc://localhost:2136/local go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBInSubqueries$' -v` sequentially with the other live suites; set `SQLC_YDB_TEST_MAVEN=mvn` to include jOOQ execution and table mappings. Stable and nightly CI run the suite in its own step; stable CI enables jOOQ and uploads `coverage-in-subqueries-live.out`. `TestInSubqueriesGeneratedGoCompiles` checks the same generated clients without a database. See [YQL evidence](yql-evidence.md) for the pinned-server contracts and any observed server limitations. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 3ecf2df1..eba87f52 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -6,11 +6,13 @@ Current behavior is in [compatibility](../docs/compatibility.md), stage ownershi The following issues track planned work; their examples and acceptance criteria define the scope. Name-mapped INSERT/UPSERT SELECT, trailing Struct commas, verified integer aliases and compatible LIMIT/OFFSET types, secondary-index metadata with VIEW selection, and static absolute TablePathPrefix resolution are implemented; their original scope is in [#25](https://github.com/ydb-platform/sqlc-ydb/issues/25), [#26](https://github.com/ydb-platform/sqlc-ydb/issues/26), [#27](https://github.com/ydb-platform/sqlc-ydb/issues/27), [#28](https://github.com/ydb-platform/sqlc-ydb/issues/28), and [#29](https://github.com/ydb-platform/sqlc-ydb/issues/29). The [compatibility contract](../docs/compatibility.md#current-analyzer-coverage) records their boundaries. Subsequent changes should remain separate, reviewable PRs. +The noncorrelated scalar/tuple IN subquery portion of [#32](https://github.com/ydb-platform/sqlc-ydb/issues/32) is implemented; its remaining scope is listed below. + | Order | Planned capability | Tracking | | --- | --- | --- | | 2 | ALTER COLUMN DROP NOT NULL | [#30](https://github.com/ydb-platform/sqlc-ydb/issues/30) | | 3 | Shared Boolean expressions, conditional aggregates and scalar conversions | [#31](https://github.com/ydb-platform/sqlc-ydb/issues/31) | -| 4 | Scoped tabular expressions, IN subqueries and collection aggregation | [#32](https://github.com/ydb-platform/sqlc-ydb/issues/32) | +| 4 | Derived FROM/JOIN sources, named SELECT bindings and collection aggregation | [#32](https://github.com/ydb-platform/sqlc-ydb/issues/32) | | 5 | Multi-statement query scripts with at most one typed result | [#33](https://github.com/ydb-platform/sqlc-ydb/issues/33) | | 6 | Typed lambdas and JSON/Yson collection transformations | [#34](https://github.com/ydb-platform/sqlc-ydb/issues/34) | | Independent | Typed streaming results with explicit cancellation and ownership | [#35](https://github.com/ydb-platform/sqlc-ydb/issues/35) | diff --git a/.agents/sdk-evidence.md b/.agents/sdk-evidence.md index f34dc21b..4db29e9e 100644 --- a/.agents/sdk-evidence.md +++ b/.agents/sdk-evidence.md @@ -160,3 +160,7 @@ Direct-session validation also observed `SESSION_BUSY` (`Pending previous query Native early-stop cleanup preserves the callback error but can also join `context.Canceled` returned by the pinned SDK's `Close`, depending on whether asynchronous stream cancellation has completed. `TestEarlyStopCleanupCancellation` in the generated runtime harness covers both nil and canceled close results, checks preservation of the callback sentinel and verifies that the caller's original context remains active. Cancellation-before-close is retained to avoid draining an unfinished stream; no extra runtime error-classification policy is introduced. Decimal validation on `:each` uses its existing deferred query-boundary stack wrapper once; `TestEachDecimalValidation` checks exactly one generated query frame. The public `examples/streaming` recipe was generated, compiled and executed on 2026-09-23 against the same Docker image and pinned Go SDK. `TestCallbackExports` in `tests/examples/go/streaming` passed for native client/session/transaction and database/sql client/transaction, checking ordered NDJSON with nullable and Unicode fields, empty results, no-parameter queries and one-call termination on writer failure. `TestLiveYDBEach` passed again after the review changes; both live suites ran sequentially. + +## jOOQ IN subquery projection contract (2026-09-23) + +A compiled probe against published jOOQ 3.21.0, YDB dialect 2.0.0 and JDBC 2.4.1 confirmed that `select(row(id, value))` renders a `row (...)` expression. This is not YQL's single tuple projection `SELECT (id, value)`, and selecting two separate fields also changes the IN subquery contract. The generator therefore supports scalar IN subqueries through typed DSL and rejects tuple-key subqueries in that path with an explicit alternative. Queries with authored DECLARE statements continue to use their existing typed JDBC contract, preserving the tuple SQL and analyzer-resolved table mappings. No new SQL fallback or runtime dependency was introduced. diff --git a/.agents/yql-evidence.md b/.agents/yql-evidence.md index 03ef0460..adb022f5 100644 --- a/.agents/yql-evidence.md +++ b/.agents/yql-evidence.md @@ -87,3 +87,17 @@ The same suite additionally verified the combined wildcard and collision-shift c 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. + +## Non-correlated IN subqueries + +The [YQL IN reference](https://ydb.tech/docs/en/yql/reference/syntax/expressions#in) documents membership against a nested SELECT. On 2026-09-23, direct QueryService probes against the already-running `ydbplatform/local-ydb:26.3.1.16` (`sha256:32687d3bc4b7a3e4200e2142800e5fc2e91d48ba46160ea094e9f1ce56794c12`) accepted scalar membership and a tuple left operand against one tuple-valued SELECT projection, including a declared `List>` passed through AS_TABLE. A SELECT projecting multiple columns was rejected with `expecting single column table source`; mismatched tuple arity and incompatible component types were also rejected. Inner aliases can reuse outer names, but references to unavailable outer relations or columns are rejected rather than correlated. A separate JOIN ON conjunction containing membership was rejected with `JOIN ON expression must be a conjunction of equality predicates`; successful WHERE membership does not establish that JOIN ON form. + +The same probes confirmed three-valued membership semantics: matching a non-null key returns true; a nonmatching key against a set containing NULL returns NULL; NULL against a nonempty set returns NULL; NULL against an empty set returns false. A NULL-only left and right expression is accepted and yields `Optional`. Empty lists and filtered-empty subqueries select no rows with IN; NOT IN over an empty set includes rows with null left values. + +Review follow-up probes on the same image accepted membership in a SELECT projection, grouped HAVING, and CASE/IF expressions inside WHERE when both query scopes read a physical table. For rows 1 and 2 against an inner result containing only 1, the filters returned row 1 and the projection returned required Bool values true and false. These contexts remain outside sqlc-ydb's supported IN-subquery subset; their diagnostics describe compiler limitations rather than a YDB syntax prohibition. The probes used one uniquely named table and removed it after validation. + +A separate raw probe exposed a server filtering discrepancy on that image: with `records(id Uint64 NOT NULL, label Utf8 NOT NULL, PRIMARY KEY(id))` containing `(1,"x"),(2,"y"),(3,"wrong")` and a right-side table containing `(1,"x"),(2,"wrong")`, `WHERE (r.id,r.label) IN (SELECT (k.id,k.label) FROM selected AS k)` returned both rows 1 and 2 instead of row 1 alone. Bound and local AS_TABLE sources reproduced it, as did UPDATE; projection of the membership expression returned the expected Boolean values. Using the full composite primary key `(id,label)` or two non-key tuple components with a separate primary key produced the expected filtered rows. This is an observed version-specific discrepancy, not an established root cause or a client-side semantic policy. The generator preserves the original predicate without an automatic rewrite. + +`TestLiveYDBInSubqueries` passed sequentially through generated native Go and database/sql clients with SDK v3.151.1 on that image. It verifies scalar/tuple membership, empty and duplicate batches, null-containing scalar sets, maximum Uint64, scoped aliases, physical-table right sides, complete composite-key UPDATE/DELETE, nullable composite keys against required tuple inputs, result metadata and raw server rejection of invalid or correlated forms. Its opt-in jOOQ subtest passed with jOOQ 3.21.0, dialect 2.0.0 and JDBC 2.4.1: scalar typed DSL reads, nested membership, same-named aliases, mixed numeric types, inner ordering/aggregation, UPDATE and DELETE RETURNING, plus a declared tuple read over non-key fields with RenderMapping and a second-component mismatch. Undeclared tuple subqueries remain outside the jOOQ DSL subset; the declared JDBC route retains the authored tuple projection. All tests create and drop only their own uniquely named tables without restarting the shared service. + +The checked-in booktest examples also passed sequentially through both generated Go adapters on the same image. They check author filtering by recent books, all-non-key tuple membership that distinguishes book types belonging to the same author, empty results, and deletion by an author-name subquery while retaining unmatched books. Existing example table names were checked to be absent before the run; successful creation established ownership for cleanup. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9fc12e..401286ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,6 +215,11 @@ jobs: 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 scoped IN subqueries 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-in-subqueries-live.out ./internal/endtoend -run '^TestLiveYDBInSubqueries$' -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 @@ -269,12 +274,13 @@ jobs: coverage-python-live.out coverage-each-live.out coverage-shared-expressions-live.out + coverage-in-subqueries-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,./coverage-shared-expressions-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,./coverage-in-subqueries-live.out flags: integration disable_search: true fail_ci_if_error: true @@ -328,6 +334,9 @@ jobs: - 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 + - name: Verify scoped IN subqueries with nightly YDB + timeout-minutes: 5 + run: go test -p 1 -count=1 -timeout=240s ./internal/endtoend -run '^TestLiveYDBInSubqueries$' -v cpp-acceptance: runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e776429..0077efb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Support noncorrelated scalar and tuple-key IN/NOT IN subqueries in SELECT and DML WHERE predicates, resolving inner aliases and parameters independently from the outer relation. Add read and mutation examples across generated runtime profiles. + ## v0.2.6 ### Added diff --git a/docs/compatibility.md b/docs/compatibility.md index d87b07a5..bec62280 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -90,9 +90,13 @@ Negative signed arguments and NULL are passed to YDB unchanged. NULL LIMIT leave Direct values in a column IN list infer the column's type; `column IN $values` and `NOT IN $values` infer `List`. An explicit List declaration is also accepted. In contrast, `column IN ($value)` contains a scalar parameter. A parenthesized direct List parameter such as `column IN ($values)` is rejected with an actionable diagnostic; use the unparenthesized `IN $values` form. These are compiler constraints, independent of a target's list binding support. +Noncorrelated `IN (SELECT ...)` and `NOT IN (SELECT ...)` subqueries are supported in WHERE predicates, including supported DML predicates. Each subquery resolves its own table aliases, column references and parameter uses. The subquery must return one column: scalar keys use `id IN (SELECT id FROM ...)`, while composite keys use `(id, kind) IN (SELECT (id, kind) FROM ...)`. `SELECT id, kind` is not a tuple-valued column and is rejected. Key types are checked component by component; SQL execution, NULL semantics and empty-input behavior remain the server's responsibility. Correlated references, IN subqueries in JOIN ON or HAVING, general scalar subqueries, named SELECT bindings and derived FROM/JOIN sources remain unsupported. See the [booktest examples](../examples/booktest/queries.sql). + +The pinned YDB 26.3.1.16 verification observed a filtering discrepancy for a tuple mixing a primary-key column with a non-key column: a row with a mismatching second component was returned. Full composite primary keys and the all-non-key tuple example passed. The [server evidence and reproducer](../.agents/yql-evidence.md#non-correlated-in-subqueries) record the exact scope of this observation; generated SQL retains the authored predicate. + Predicate comparison uses the supported common-type resolver. Integer kinds reconcile by width and signedness, and Float/Double combinations reconcile within the primitive numeric family. Compared with v0.1.5, `WHERE`, `JOIN ... ON`, `UPDATE ... WHERE` and `DELETE ... WHERE` now undergo operand type validation, so previously generated queries can be rejected. In particular, YDB accepts `String`/`Utf8` and `Decimal(P,S)`/integer comparisons, but the offline resolver currently rejects these mixed pairs. Use explicit CASTs to give the operands a common type. This is an offline-analysis limitation, not a restriction of YQL; the analyzer does not infer broader server-side coercions. -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. +This remains a deliberately limited semantic implementation. General computed projections, the full CAST matrix, CTEs and subquery forms outside the supported IN predicates, 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. 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. diff --git a/docs/java.md b/docs/java.md index b3332514..001b27ac 100644 --- a/docs/java.md +++ b/docs/java.md @@ -62,6 +62,8 @@ gen: Each output contains `Tables.java` (typed fields derived from the local schema), `Queries.java` and projection records. Generation is offline and does not need a second jOOQ schema-generation step or a running database. Table aliases retain typed fields. Parameters are bound with the YDB field types, never interpolated into SQL. The constructor borrows a `YdbDSLContext`: callers own connection, transaction, retry and lifecycle. `:one` returns `Optional` and rejects multiple rows; `:many` returns `List`; `:exec` returns `void`. +The typed DSL supports noncorrelated scalar `IN (SELECT ...)` and `NOT IN (SELECT ...)` predicates with independent inner aliases and table mappings. Tuple-key subqueries require explicit `DECLARE` statements to use the existing typed JDBC path, or `runtime: jdbc`/`ydb`. The pinned jOOQ row projection renders `SELECT row (...)`, which does not preserve YQL's required single-tuple-column form `SELECT (...)`; the DSL rejects this form instead of changing the query. + Secondary-index sources retain `VIEW index` in both DSL and explicitly declared SQL. The base table remains a mapped table reference, and aliases follow the index selection. The [authors example](../examples/authors/README.md) includes ordinary and covering index reads through both paths. For a supported static TablePathPrefix, table constants contain the analyzer's resolved absolute paths. Full path components distinguish generated Java table/model identifiers; paths that normalize to the same Java symbol produce a collision diagnostic. RenderMapping matches these full physical table names. Both the typed DSL and declared SQL paths retain the pragma's execution context: absolute mapped outputs bypass the prefix, while relative mapped outputs resolve under it. The [namespaces example](../examples/namespaces) demonstrates duplicate table basenames, indexed reads and a cross-catalog join. Parameter and column naming rules are unchanged. diff --git a/examples/booktest/README.md b/examples/booktest/README.md index e4506e47..9b543b28 100644 --- a/examples/booktest/README.md +++ b/examples/booktest/README.md @@ -1,7 +1,11 @@ # Booktest -This example adapts sqlc's `booktest` examples at commit `3c2546a4b47fabbcec3e07df420effb1a464728f` to YDB and generates both native YDB SDK and `database/sql` Go packages. +This example adapts sqlc's `booktest` examples at commit `3c2546a4b47fabbcec3e07df420effb1a464728f` to YDB and generates all 18 runtime profiles configured in [`sqlc.yaml`](sqlc.yaml). The adaptation keeps the author and book CRUD queries, title/year lookup, author join, tag-overlap lookup, and greeting query covered by the upstream dialect variants. Create queries take explicit `Uint64` identifiers in place of upstream serial keys. The upstream enum is represented as `Utf8` (`FICTION` or `NONFICTION`), availability uses YDB `Timestamp`, and tags use YDB `Json`. The tag query converts each JSON array to a string list and uses a YQL set to test for overlap, following the PostgreSQL variant rather than the serialized string equality in MySQL/SQLite. `SayHello` uses YQL string concatenation with a required `Utf8` input in place of the PostgreSQL user-defined function. Foreign-key, unique-index, default-value, and auto-increment behavior from the upstream schemas is not declared here because the supported YDB schema subset does not provide those contracts. Callers must validate `book_type` values and supply identifiers, timestamps, and tags explicitly. + +`ListAuthorsWithRecentBooks` uses a scalar `IN (SELECT ...)` to find authors with a book published since a given year. `ListBooksWithRecentEditions` matches the `(author_id, book_type)` pair to find all books in categories for which that author has a recent edition. YQL requires one tuple-valued projection, `SELECT (author_id, book_type)`, rather than two separately projected columns. `DeleteBooksByAuthorName` demonstrates the same scalar membership predicate in a write query. + +The tuple example explicitly declares the year parameter. For jOOQ this uses its existing typed JDBC execution path, which preserves the YQL tuple projection and applies configured table mappings. Undeclared scalar membership queries use the typed jOOQ DSL; undeclared tuple membership queries report an actionable target limitation because the pinned DSL renders a different row-constructor syntax. Each subquery has its own table aliases and does not reference the outer query. diff --git a/examples/booktest/cpp/native/models.hpp b/examples/booktest/cpp/native/models.hpp index 3b0546e4..706a0d81 100644 --- a/examples/booktest/cpp/native/models.hpp +++ b/examples/booktest/cpp/native/models.hpp @@ -63,4 +63,20 @@ struct SayHelloRow final { std::string greeting; }; +struct ListAuthorsWithRecentBooksRow final { + std::uint64_t author_id; + std::string name; +}; + +struct ListBooksWithRecentEditionsRow final { + std::uint64_t book_id; + std::uint64_t author_id; + std::string isbn; + std::string book_type; + std::string title; + std::int32_t publication_year; + TInstant available; + std::string tags; +}; + } // namespace booktest::native diff --git a/examples/booktest/cpp/native/queries.cpp b/examples/booktest/cpp/native/queries.cpp index d7ea161e..a1015593 100644 --- a/examples/booktest/cpp/native/queries.cpp +++ b/examples/booktest/cpp/native/queries.cpp @@ -500,4 +500,139 @@ std::optional Queries::SayHello(const std::string& name) const { return sqlc_row; } +// -- name: ListAuthorsWithRecentBooks :many +std::vector Queries::ListAuthorsWithRecentBooks(std::int32_t since_year) 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("$since_year").Int32(since_year).Build() + .Build(); + auto sqlc_result = sqlc_session.ExecuteQuery( + "SELECT a.author_id, a.name\n" + "FROM authors AS a\n" + "WHERE a.author_id IN (\n" + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n" + ")\n" + "ORDER BY a.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("ListAuthorsWithRecentBooks: 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(ListAuthorsWithRecentBooksRow{ + sqlc_parser.ColumnParser("author_id").GetUint64(), + sqlc_parser.ColumnParser("name").GetUtf8(), + }); + } + return sqlc_rows; +} + +// -- name: ListBooksWithRecentEditions :many +std::vector Queries::ListBooksWithRecentEditions(std::int32_t since_year) 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("$since_year").Int32(since_year).Build() + .Build(); + auto sqlc_result = sqlc_session.ExecuteQuery( + "DECLARE $since_year AS Int32;\n" + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + "FROM books AS b\n" + "WHERE (b.author_id, b.book_type) IN (\n" + " SELECT (recent.author_id, recent.book_type)\n" + " FROM books AS recent\n" + " WHERE recent.publication_year >= $since_year\n" + ")\n" + "ORDER BY b.book_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("ListBooksWithRecentEditions: 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(ListBooksWithRecentEditionsRow{ + sqlc_parser.ColumnParser("book_id").GetUint64(), + sqlc_parser.ColumnParser("author_id").GetUint64(), + sqlc_parser.ColumnParser("isbn").GetUtf8(), + sqlc_parser.ColumnParser("book_type").GetUtf8(), + sqlc_parser.ColumnParser("title").GetUtf8(), + sqlc_parser.ColumnParser("publication_year").GetInt32(), + sqlc_parser.ColumnParser("available").GetTimestamp(), + sqlc_parser.ColumnParser("tags").GetJson(), + }); + } + return sqlc_rows; +} + +// -- name: DeleteBooksByAuthorName :exec +void Queries::DeleteBooksByAuthorName(const std::string& author_name) const { + const auto sqlc_execute = [&](NYdb::NQuery::TSession sqlc_session, const NYdb::NQuery::TTxControl& sqlc_tx) -> NYdb::TStatus { + auto sqlc_params = NYdb::TParamsBuilder() + .AddParam("$author_name").Utf8(author_name).Build() + .Build(); + auto sqlc_result = sqlc_session.ExecuteQuery( + "DELETE FROM books\n" + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);", + sqlc_tx, + sqlc_params, + this->execute_settings_ + ).GetValueSync(); + 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); +} + } // namespace booktest::native diff --git a/examples/booktest/cpp/native/queries.hpp b/examples/booktest/cpp/native/queries.hpp index 3b65fdf5..a325c837 100644 --- a/examples/booktest/cpp/native/queries.hpp +++ b/examples/booktest/cpp/native/queries.hpp @@ -35,6 +35,9 @@ class Queries final { void UpdateBookISBN(const std::string& title, const std::string& tags, const std::string& isbn, std::uint64_t book_id) const; void DeleteAuthorBeforeYear(std::int32_t publication_year, std::uint64_t author_id) const; std::optional SayHello(const std::string& name) const; + std::vector ListAuthorsWithRecentBooks(std::int32_t since_year) const; + std::vector ListBooksWithRecentEditions(std::int32_t since_year) const; + void DeleteBooksByAuthorName(const std::string& author_name) const; private: NYdb::NQuery::TQueryClient* client_; diff --git a/examples/booktest/cpp/userver/models.hpp b/examples/booktest/cpp/userver/models.hpp index da376336..5796e5b2 100644 --- a/examples/booktest/cpp/userver/models.hpp +++ b/examples/booktest/cpp/userver/models.hpp @@ -65,4 +65,20 @@ struct SayHelloRow final { ::userver::ydb::Utf8 greeting; }; +struct ListAuthorsWithRecentBooksRow final { + std::uint64_t author_id; + ::userver::ydb::Utf8 name; +}; + +struct ListBooksWithRecentEditionsRow final { + std::uint64_t book_id; + std::uint64_t author_id; + ::userver::ydb::Utf8 isbn; + ::userver::ydb::Utf8 book_type; + ::userver::ydb::Utf8 title; + std::int32_t publication_year; + std::chrono::system_clock::time_point available; + ::userver::formats::json::Value tags; +}; + } // namespace booktest::userver diff --git a/examples/booktest/cpp/userver/queries.cpp b/examples/booktest/cpp/userver/queries.cpp index 874da38e..3a66c3df 100644 --- a/examples/booktest/cpp/userver/queries.cpp +++ b/examples/booktest/cpp/userver/queries.cpp @@ -280,4 +280,84 @@ std::optional Queries::SayHello(const ::userver::ydb::Utf8& name) c }; } +// -- name: ListAuthorsWithRecentBooks :many +std::vector Queries::ListAuthorsWithRecentBooks(std::int32_t since_year) const { + const auto sqlc_query = ::userver::ydb::Query{ + "SELECT a.author_id, a.name\n" + "FROM authors AS a\n" + "WHERE a.author_id IN (\n" + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n" + ")\n" + "ORDER BY a.author_id;", + ::userver::ydb::Query::Name{"ListAuthorsWithRecentBooks"}, + ::userver::ydb::Query::LogMode::kNameOnly, + }; + auto sqlc_response = + this->transaction_ != nullptr + ? this->transaction_->Execute(this->execute_settings_, sqlc_query, "$since_year", since_year) + : this->client_->ExecuteQuery(this->operation_settings_, sqlc_query, "$since_year", since_year); + 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(ListAuthorsWithRecentBooksRow{ + sqlc_row.Get("author_id"), + sqlc_row.Get<::userver::ydb::Utf8>("name"), + }); + } + return sqlc_rows; +} + +// -- name: ListBooksWithRecentEditions :many +std::vector Queries::ListBooksWithRecentEditions(std::int32_t since_year) const { + const auto sqlc_query = ::userver::ydb::Query{ + "DECLARE $since_year AS Int32;\n" + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + "FROM books AS b\n" + "WHERE (b.author_id, b.book_type) IN (\n" + " SELECT (recent.author_id, recent.book_type)\n" + " FROM books AS recent\n" + " WHERE recent.publication_year >= $since_year\n" + ")\n" + "ORDER BY b.book_id;", + ::userver::ydb::Query::Name{"ListBooksWithRecentEditions"}, + ::userver::ydb::Query::LogMode::kNameOnly, + }; + auto sqlc_response = + this->transaction_ != nullptr + ? this->transaction_->Execute(this->execute_settings_, sqlc_query, "$since_year", since_year) + : this->client_->ExecuteQuery(this->operation_settings_, sqlc_query, "$since_year", since_year); + 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(ListBooksWithRecentEditionsRow{ + sqlc_row.Get("book_id"), + sqlc_row.Get("author_id"), + sqlc_row.Get<::userver::ydb::Utf8>("isbn"), + sqlc_row.Get<::userver::ydb::Utf8>("book_type"), + sqlc_row.Get<::userver::ydb::Utf8>("title"), + sqlc_row.Get("publication_year"), + sqlc_row.Get("available"), + sqlc_row.Get<::userver::formats::json::Value>("tags"), + }); + } + return sqlc_rows; +} + +// -- name: DeleteBooksByAuthorName :exec +void Queries::DeleteBooksByAuthorName(const ::userver::ydb::Utf8& author_name) const { + const auto sqlc_query = ::userver::ydb::Query{ + "DELETE FROM books\n" + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);", + ::userver::ydb::Query::Name{"DeleteBooksByAuthorName"}, + ::userver::ydb::Query::LogMode::kNameOnly, + }; + static_cast( + this->transaction_ != nullptr + ? this->transaction_->Execute(this->execute_settings_, sqlc_query, "$author_name", author_name) + : this->client_->ExecuteQuery(this->operation_settings_, sqlc_query, "$author_name", author_name) + ); +} + } // namespace booktest::userver diff --git a/examples/booktest/cpp/userver/queries.hpp b/examples/booktest/cpp/userver/queries.hpp index 47fb945a..f3d38fcb 100644 --- a/examples/booktest/cpp/userver/queries.hpp +++ b/examples/booktest/cpp/userver/queries.hpp @@ -32,6 +32,9 @@ class Queries final { void UpdateBookISBN(const ::userver::ydb::Utf8& title, const ::userver::formats::json::Value& tags, const ::userver::ydb::Utf8& isbn, std::uint64_t book_id) const; void DeleteAuthorBeforeYear(std::int32_t publication_year, std::uint64_t author_id) const; std::optional SayHello(const ::userver::ydb::Utf8& name) const; + std::vector ListAuthorsWithRecentBooks(std::int32_t since_year) const; + std::vector ListBooksWithRecentEditions(std::int32_t since_year) const; + void DeleteBooksByAuthorName(const ::userver::ydb::Utf8& author_name) const; private: ::userver::ydb::TableClient* client_; diff --git a/examples/booktest/csharp/adonet/Models.cs b/examples/booktest/csharp/adonet/Models.cs new file mode 100644 index 00000000..51af2099 --- /dev/null +++ b/examples/booktest/csharp/adonet/Models.cs @@ -0,0 +1,130 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +#nullable enable +using System; + +namespace Booktest.AdoNet; +public sealed record Authors( + ulong AuthorID, + string Name +); + +public sealed record Books( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); + +public sealed record GetAuthorRow( + ulong AuthorID, + string Name +); + +public sealed record GetBookRow( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); + +public sealed record BooksByTitleYearParams( + string Title, + int PublicationYear +); + +public sealed record BooksByTitleYearRow( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); + +public sealed record BooksByTagsRow( + ulong BookID, + string Title, + string? Name, + string Isbn, + string Tags +); + +public sealed record CreateAuthorParams( + ulong AuthorID, + string Name +); + +public sealed record CreateAuthorRow( + ulong AuthorID, + string Name +); + +public sealed record CreateBookParams( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); + +public sealed record CreateBookRow( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); + +public sealed record UpdateBookParams( + string Title, + string Tags, + ulong BookID +); + +public sealed record UpdateBookISBNParams( + string Title, + string Tags, + string Isbn, + ulong BookID +); + +public sealed record DeleteAuthorBeforeYearParams( + int PublicationYear, + ulong AuthorID +); + +public sealed record SayHelloRow( + string Greeting +); + +public sealed record ListAuthorsWithRecentBooksRow( + ulong AuthorID, + string Name +); + +public sealed record ListBooksWithRecentEditionsRow( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); diff --git a/examples/booktest/csharp/adonet/Queries.cs b/examples/booktest/csharp/adonet/Queries.cs new file mode 100644 index 00000000..0511c133 --- /dev/null +++ b/examples/booktest/csharp/adonet/Queries.cs @@ -0,0 +1,357 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +#nullable enable +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Ydb.Sdk.Ado; +using Ydb.Sdk.Value; + +namespace Booktest.AdoNet; + + +public sealed class Queries +{ + private readonly YdbConnection _connection; + private readonly YdbTransaction? _transaction; + + public Queries(YdbConnection connection, YdbTransaction? transaction = null) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + if (transaction is not null && !ReferenceEquals(transaction.Connection, connection)) + { + throw new ArgumentException("Transaction must belong to the supplied connection.", nameof(transaction)); + } + _transaction = transaction; + } + + public Queries WithTransaction(YdbTransaction transaction) => new(_connection, transaction ?? throw new ArgumentNullException(nameof(transaction))); + + private static DateTime NormalizeTimestamp(DateTime value) => + value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc); + + private static DateTime? NormalizeTimestamp(DateTime? value) => + value.HasValue ? NormalizeTimestamp(value.Value) : null; + + // -- name: GetAuthor :one + public async Task GetAuthorAsync(ulong authorId, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "SELECT author_id, name\n" + + "FROM authors\n" + + "WHERE author_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 GetAuthorRowFrom(reader); + } + + private static GetAuthorRow GetAuthorRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1) + ); + + // -- name: GetBook :one + public async Task GetBookAsync(ulong bookId, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + + "FROM books\n" + + "WHERE book_id = $book_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$book_id", DbType.UInt64, bookId)); + 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 GetBookRowFrom(reader); + } + + private static GetBookRow GetBookRowFrom(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.GetFieldValue(7) + ); + + // -- name: DeleteBook :exec + public async Task DeleteBookAsync(ulong bookId, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DELETE FROM books\n" + + "WHERE book_id = $book_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$book_id", DbType.UInt64, bookId)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + // -- name: BooksByTitleYear :many + public async Task> BooksByTitleYearAsync(BooksByTitleYearParams args, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + + "FROM books\n" + + "WHERE title = $title AND publication_year = $publication_year;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$title", DbType.String, args.Title)); + command.Parameters.Add(new YdbParameter("$publication_year", DbType.Int32, args.PublicationYear)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + rows.Add(BooksByTitleYearRowFrom(reader)); + } + return rows; + } + + private static BooksByTitleYearRow BooksByTitleYearRowFrom(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.GetFieldValue(7) + ); + + // -- name: BooksByTags :many + public async Task> BooksByTagsAsync(string tags, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DECLARE $tags AS Json;\n" + + "SELECT\n" + + " b.book_id,\n" + + " b.title,\n" + + " a.name,\n" + + " b.isbn,\n" + + " b.tags\n" + + "FROM books AS b\n" + + "LEFT JOIN authors AS a ON b.author_id = a.author_id\n" + + "WHERE NOT SetIsDisjoint(\n" + + " ToSet(Yson::ConvertToStringList(b.tags)),\n" + + " Yson::ConvertToStringList($tags)\n" + + ");", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$tags", YdbValue.MakeJson(tags))); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + rows.Add(BooksByTagsRowFrom(reader)); + } + return rows; + } + + private static BooksByTagsRow BooksByTagsRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1), + reader.IsDBNull(2) ? null : reader.GetFieldValue(2), + reader.GetFieldValue(3), + reader.GetFieldValue(4) + ); + + // -- name: CreateAuthor :one + public async Task CreateAuthorAsync(CreateAuthorParams args, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "INSERT INTO authors (author_id, name)\n" + + "VALUES ($author_id, $name)\n" + + "RETURNING author_id, name;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$author_id", DbType.UInt64, args.AuthorID)); + command.Parameters.Add(new YdbParameter("$name", DbType.String, args.Name)); + 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 CreateAuthorRowFrom(reader); + } + + private static CreateAuthorRow CreateAuthorRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1) + ); + + // -- name: CreateBook :one + public async Task CreateBookAsync(CreateBookParams args, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "INSERT INTO books (\n" + + " book_id,\n" + + " author_id,\n" + + " isbn,\n" + + " book_type,\n" + + " title,\n" + + " publication_year,\n" + + " available,\n" + + " tags\n" + + ") VALUES (\n" + + " $book_id,\n" + + " $author_id,\n" + + " $isbn,\n" + + " $book_type,\n" + + " $title,\n" + + " $publication_year,\n" + + " $available,\n" + + " $tags\n" + + ")\n" + + "RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$book_id", DbType.UInt64, args.BookID)); + command.Parameters.Add(new YdbParameter("$author_id", DbType.UInt64, args.AuthorID)); + command.Parameters.Add(new YdbParameter("$isbn", DbType.String, args.Isbn)); + command.Parameters.Add(new YdbParameter("$book_type", DbType.String, args.BookType)); + command.Parameters.Add(new YdbParameter("$title", DbType.String, args.Title)); + command.Parameters.Add(new YdbParameter("$publication_year", DbType.Int32, args.PublicationYear)); + command.Parameters.Add(new YdbParameter("$available", YdbValue.MakeTimestamp(NormalizeTimestamp(args.Available)))); + command.Parameters.Add(new YdbParameter("$tags", YdbValue.MakeJson(args.Tags))); + 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 CreateBookRowFrom(reader); + } + + private static CreateBookRow CreateBookRowFrom(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.GetFieldValue(7) + ); + + // -- name: UpdateBook :exec + public async Task UpdateBookAsync(UpdateBookParams args, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "UPDATE books\n" + + "SET title = $title, tags = $tags\n" + + "WHERE book_id = $book_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$title", DbType.String, args.Title)); + command.Parameters.Add(new YdbParameter("$tags", YdbValue.MakeJson(args.Tags))); + command.Parameters.Add(new YdbParameter("$book_id", DbType.UInt64, args.BookID)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + // -- name: UpdateBookISBN :exec + public async Task UpdateBookISBNAsync(UpdateBookISBNParams args, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "UPDATE books\n" + + "SET title = $title, tags = $tags, isbn = $isbn\n" + + "WHERE book_id = $book_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$title", DbType.String, args.Title)); + command.Parameters.Add(new YdbParameter("$tags", YdbValue.MakeJson(args.Tags))); + command.Parameters.Add(new YdbParameter("$isbn", DbType.String, args.Isbn)); + command.Parameters.Add(new YdbParameter("$book_id", DbType.UInt64, args.BookID)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + // -- name: DeleteAuthorBeforeYear :exec + public async Task DeleteAuthorBeforeYearAsync(DeleteAuthorBeforeYearParams args, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DELETE FROM books\n" + + "WHERE publication_year < $publication_year AND author_id = $author_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$publication_year", DbType.Int32, args.PublicationYear)); + command.Parameters.Add(new YdbParameter("$author_id", DbType.UInt64, args.AuthorID)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + // -- name: SayHello :one + public async Task SayHelloAsync(string name, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "SELECT \"hello \"u || $name AS greeting;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$name", DbType.String, name)); + 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 SayHelloRowFrom(reader); + } + + private static SayHelloRow SayHelloRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0) + ); + + // -- name: ListAuthorsWithRecentBooks :many + public async Task> ListAuthorsWithRecentBooksAsync(int sinceYear, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "SELECT a.author_id, a.name\n" + + "FROM authors AS a\n" + + "WHERE a.author_id IN (\n" + + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n" + + ")\n" + + "ORDER BY a.author_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$since_year", DbType.Int32, sinceYear)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + rows.Add(ListAuthorsWithRecentBooksRowFrom(reader)); + } + return rows; + } + + private static ListAuthorsWithRecentBooksRow ListAuthorsWithRecentBooksRowFrom(DbDataReader reader) => new( + reader.GetFieldValue(0), + reader.GetFieldValue(1) + ); + + // -- name: ListBooksWithRecentEditions :many + public async Task> ListBooksWithRecentEditionsAsync(int sinceYear, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DECLARE $since_year AS Int32;\n" + + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + + "FROM books AS b\n" + + "WHERE (b.author_id, b.book_type) IN (\n" + + " SELECT (recent.author_id, recent.book_type)\n" + + " FROM books AS recent\n" + + " WHERE recent.publication_year >= $since_year\n" + + ")\n" + + "ORDER BY b.book_id;", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$since_year", DbType.Int32, sinceYear)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var rows = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + rows.Add(ListBooksWithRecentEditionsRowFrom(reader)); + } + return rows; + } + + private static ListBooksWithRecentEditionsRow ListBooksWithRecentEditionsRowFrom(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.GetFieldValue(7) + ); + + // -- name: DeleteBooksByAuthorName :exec + public async Task DeleteBooksByAuthorNameAsync(string authorName, CancellationToken cancellationToken = default) + { + await using var command = new YdbCommand( + "DELETE FROM books\n" + + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);", _connection) { Transaction = _transaction }; + command.Parameters.Add(new YdbParameter("$author_name", DbType.String, authorName)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/examples/booktest/csharp/dapper/Models.cs b/examples/booktest/csharp/dapper/Models.cs index 36d5b1f4..97140dcb 100644 --- a/examples/booktest/csharp/dapper/Models.cs +++ b/examples/booktest/csharp/dapper/Models.cs @@ -112,3 +112,19 @@ ulong AuthorID public sealed record SayHelloRow( string Greeting ); + +public sealed record ListAuthorsWithRecentBooksRow( + ulong AuthorID, + string Name +); + +public sealed record ListBooksWithRecentEditionsRow( + ulong BookID, + ulong AuthorID, + string Isbn, + string BookType, + string Title, + int PublicationYear, + DateTime Available, + string Tags +); diff --git a/examples/booktest/csharp/dapper/Queries.cs b/examples/booktest/csharp/dapper/Queries.cs index 51b6246c..2af71841 100644 --- a/examples/booktest/csharp/dapper/Queries.cs +++ b/examples/booktest/csharp/dapper/Queries.cs @@ -69,6 +69,17 @@ static Queries() ["book_type"] = nameof(CreateBookRow.BookType), ["publication_year"] = nameof(CreateBookRow.PublicationYear), })); + SqlMapper.SetTypeMap(typeof(ListAuthorsWithRecentBooksRow), new ColumnTypeMap(typeof(ListAuthorsWithRecentBooksRow), new Dictionary + { + ["author_id"] = nameof(ListAuthorsWithRecentBooksRow.AuthorID), + })); + SqlMapper.SetTypeMap(typeof(ListBooksWithRecentEditionsRow), new ColumnTypeMap(typeof(ListBooksWithRecentEditionsRow), new Dictionary + { + ["book_id"] = nameof(ListBooksWithRecentEditionsRow.BookID), + ["author_id"] = nameof(ListBooksWithRecentEditionsRow.AuthorID), + ["book_type"] = nameof(ListBooksWithRecentEditionsRow.BookType), + ["publication_year"] = nameof(ListBooksWithRecentEditionsRow.PublicationYear), + })); } private sealed class ColumnTypeMap : SqlMapper.ITypeMap @@ -364,6 +375,77 @@ public async Task SayHelloAsync(string name, CancellationToken canc return await _connection.QueryFirstAsync(command).ConfigureAwait(false); } + // -- name: ListAuthorsWithRecentBooks :many + public async Task> ListAuthorsWithRecentBooksAsync(int sinceYear, CancellationToken cancellationToken = default, int? commandTimeout = null) + { + var parameters = new YdbParameters( + new YdbParameter("$since_year", DbType.Int32, sinceYear) + ); + + var command = new CommandDefinition( + commandText: """ + SELECT a.author_id, a.name + FROM authors AS a + WHERE a.author_id IN ( + SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year + ) + ORDER BY a.author_id; + """, + parameters: parameters, + transaction: _transaction, + commandTimeout: commandTimeout, + cancellationToken: cancellationToken); + + return (await _connection.QueryAsync(command).ConfigureAwait(false)).AsList(); + } + + // -- name: ListBooksWithRecentEditions :many + public async Task> ListBooksWithRecentEditionsAsync(int sinceYear, CancellationToken cancellationToken = default, int? commandTimeout = null) + { + var parameters = new YdbParameters( + new YdbParameter("$since_year", DbType.Int32, sinceYear) + ); + + var command = new CommandDefinition( + commandText: """ + DECLARE $since_year AS Int32; + SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags + FROM books AS b + WHERE (b.author_id, b.book_type) IN ( + SELECT (recent.author_id, recent.book_type) + FROM books AS recent + WHERE recent.publication_year >= $since_year + ) + ORDER BY b.book_id; + """, + parameters: parameters, + transaction: _transaction, + commandTimeout: commandTimeout, + cancellationToken: cancellationToken); + + return (await _connection.QueryAsync(command).ConfigureAwait(false)).AsList(); + } + + // -- name: DeleteBooksByAuthorName :exec + public async Task DeleteBooksByAuthorNameAsync(string authorName, CancellationToken cancellationToken = default, int? commandTimeout = null) + { + var parameters = new YdbParameters( + new YdbParameter("$author_name", DbType.String, authorName) + ); + + var command = new CommandDefinition( + commandText: """ + DELETE FROM books + WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name); + """, + parameters: parameters, + transaction: _transaction, + commandTimeout: commandTimeout, + cancellationToken: cancellationToken); + + await _connection.ExecuteAsync(command).ConfigureAwait(false); + } + private sealed class YdbParameters : SqlMapper.IDynamicParameters { private readonly YdbParameter[] _parameters; diff --git a/examples/booktest/go/database/sql/models.go b/examples/booktest/go/database/sql/models.go index 011c6633..07204ba0 100644 --- a/examples/booktest/go/database/sql/models.go +++ b/examples/booktest/go/database/sql/models.go @@ -96,3 +96,19 @@ type DeleteAuthorBeforeYearParams struct { type SayHelloRow struct { Greeting string } + +type ListAuthorsWithRecentBooksRow struct { + AuthorID uint64 + Name string +} + +type ListBooksWithRecentEditionsRow struct { + BookID uint64 + AuthorID uint64 + Isbn string + BookType string + Title string + PublicationYear int32 + Available time.Time + Tags string +} diff --git a/examples/booktest/go/database/sql/queries.sql.go b/examples/booktest/go/database/sql/queries.sql.go index e09c322a..77c4fa6a 100644 --- a/examples/booktest/go/database/sql/queries.sql.go +++ b/examples/booktest/go/database/sql/queries.sql.go @@ -258,3 +258,93 @@ func (q *Queries) SayHello(ctx context.Context, arg string) (SayHelloRow, error) return row, err } + +// -- name: ListAuthorsWithRecentBooks :many +func (q *Queries) ListAuthorsWithRecentBooks(ctx context.Context, arg int32) ([]ListAuthorsWithRecentBooksRow, error) { + rows, err := q.db.QueryContext(ctx, ""+ + "SELECT a.author_id, a.name\n"+ + "FROM authors AS a\n"+ + "WHERE a.author_id IN (\n"+ + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n"+ + ")\n"+ + "ORDER BY a.author_id;", + sql.Named("since_year", arg), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []ListAuthorsWithRecentBooksRow(nil) + for rows.Next() { + var row ListAuthorsWithRecentBooksRow + if err := rows.Scan( + &row.AuthorID, + &row.Name, + ); err != nil { + return nil, err + } + items = append(items, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return items, nil +} + +// -- name: ListBooksWithRecentEditions :many +func (q *Queries) ListBooksWithRecentEditions(ctx context.Context, arg int32) ([]ListBooksWithRecentEditionsRow, error) { + rows, err := q.db.QueryContext(ctx, ""+ + "DECLARE $since_year AS Int32;\n"+ + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n"+ + "FROM books AS b\n"+ + "WHERE (b.author_id, b.book_type) IN (\n"+ + " SELECT (recent.author_id, recent.book_type)\n"+ + " FROM books AS recent\n"+ + " WHERE recent.publication_year >= $since_year\n"+ + ")\n"+ + "ORDER BY b.book_id;", + sql.Named("since_year", arg), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []ListBooksWithRecentEditionsRow(nil) + for rows.Next() { + var row ListBooksWithRecentEditionsRow + if err := rows.Scan( + &row.BookID, + &row.AuthorID, + &row.Isbn, + &row.BookType, + &row.Title, + &row.PublicationYear, + &row.Available, + &row.Tags, + ); err != nil { + return nil, err + } + items = append(items, row) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return items, nil +} + +// -- name: DeleteBooksByAuthorName :exec +func (q *Queries) DeleteBooksByAuthorName(ctx context.Context, arg string) error { + _, err := q.db.ExecContext(ctx, ""+ + "DELETE FROM books\n"+ + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);", + sql.Named("author_name", arg), + ) + + return err +} diff --git a/examples/booktest/go/native/models.go b/examples/booktest/go/native/models.go index c9b1ffb2..a444faf1 100644 --- a/examples/booktest/go/native/models.go +++ b/examples/booktest/go/native/models.go @@ -101,6 +101,22 @@ type SayHelloRow struct { Greeting string `json:"greeting"` } +type ListAuthorsWithRecentBooksRow struct { + AuthorID uint64 `json:"author_id"` + Name string `json:"name"` +} + +type ListBooksWithRecentEditionsRow struct { + BookID uint64 `json:"book_id"` + AuthorID uint64 `json:"author_id"` + Isbn string `json:"isbn"` + BookType string `json:"book_type"` + Title string `json:"title"` + PublicationYear int32 `json:"publication_year"` + Available time.Time `json:"available"` + Tags string `json:"tags"` +} + type Querier interface { GetAuthor(ctx context.Context, arg uint64, opts ...query.ExecuteOption) (GetAuthorRow, error) GetBook(ctx context.Context, arg uint64, opts ...query.ExecuteOption) (GetBookRow, error) @@ -113,4 +129,7 @@ type Querier interface { UpdateBookISBN(ctx context.Context, arg UpdateBookISBNParams, opts ...query.ExecuteOption) error DeleteAuthorBeforeYear(ctx context.Context, arg DeleteAuthorBeforeYearParams, opts ...query.ExecuteOption) error SayHello(ctx context.Context, arg string, opts ...query.ExecuteOption) (SayHelloRow, error) + ListAuthorsWithRecentBooks(ctx context.Context, arg int32, opts ...query.ExecuteOption) ([]ListAuthorsWithRecentBooksRow, error) + ListBooksWithRecentEditions(ctx context.Context, arg int32, opts ...query.ExecuteOption) ([]ListBooksWithRecentEditionsRow, error) + DeleteBooksByAuthorName(ctx context.Context, arg string, opts ...query.ExecuteOption) error } diff --git a/examples/booktest/go/native/queries.sql.go b/examples/booktest/go/native/queries.sql.go index a42bdb9c..391736ea 100644 --- a/examples/booktest/go/native/queries.sql.go +++ b/examples/booktest/go/native/queries.sql.go @@ -390,3 +390,139 @@ func (q *Queries) SayHello(ctx context.Context, arg string, opts ...query.Execut return row, nil } + +// -- name: ListAuthorsWithRecentBooks :many +func (q *Queries) ListAuthorsWithRecentBooks(ctx context.Context, arg int32, opts ...query.ExecuteOption) ([]ListAuthorsWithRecentBooksRow, error) { + parameters := ydb.ParamsBuilder() + parameters = parameters.Param("$since_year").Int32(arg) + + callOptions := append([]query.ExecuteOption(nil), opts...) + callOptions = append(callOptions, query.WithParameters(parameters.Build())) + + result, err := q.db.Query(ctx, ""+ + "SELECT a.author_id, a.name\n"+ + "FROM authors AS a\n"+ + "WHERE a.author_id IN (\n"+ + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n"+ + ")\n"+ + "ORDER BY a.author_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([]ListAuthorsWithRecentBooksRow, 0) + for r, err := range resultSet.Rows(ctx) { + if err != nil { + return nil, xerrors.WithStackTrace(err) + } + var row ListAuthorsWithRecentBooksRow + if err := r.ScanNamed( + query.Named("author_id", &row.AuthorID), + query.Named("name", &row.Name), + ); 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: ListBooksWithRecentEditions :many +func (q *Queries) ListBooksWithRecentEditions(ctx context.Context, arg int32, opts ...query.ExecuteOption) ([]ListBooksWithRecentEditionsRow, error) { + parameters := ydb.ParamsBuilder() + parameters = parameters.Param("$since_year").Int32(arg) + + callOptions := append([]query.ExecuteOption(nil), opts...) + callOptions = append(callOptions, query.WithParameters(parameters.Build())) + + result, err := q.db.Query(ctx, ""+ + "DECLARE $since_year AS Int32;\n"+ + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n"+ + "FROM books AS b\n"+ + "WHERE (b.author_id, b.book_type) IN (\n"+ + " SELECT (recent.author_id, recent.book_type)\n"+ + " FROM books AS recent\n"+ + " WHERE recent.publication_year >= $since_year\n"+ + ")\n"+ + "ORDER BY b.book_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([]ListBooksWithRecentEditionsRow, 0) + for r, err := range resultSet.Rows(ctx) { + if err != nil { + return nil, xerrors.WithStackTrace(err) + } + var row ListBooksWithRecentEditionsRow + if err := r.ScanNamed( + query.Named("book_id", &row.BookID), + query.Named("author_id", &row.AuthorID), + query.Named("isbn", &row.Isbn), + query.Named("book_type", &row.BookType), + query.Named("title", &row.Title), + query.Named("publication_year", &row.PublicationYear), + query.Named("available", &row.Available), + query.Named("tags", &row.Tags), + ); 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: DeleteBooksByAuthorName :exec +func (q *Queries) DeleteBooksByAuthorName(ctx context.Context, arg string, opts ...query.ExecuteOption) error { + parameters := ydb.ParamsBuilder() + parameters = parameters.Param("$author_name").Text(arg) + + callOptions := append([]query.ExecuteOption(nil), opts...) + callOptions = append(callOptions, query.WithParameters(parameters.Build())) + + err := q.db.Exec(ctx, ""+ + "DELETE FROM books\n"+ + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);", + callOptions..., + ) + + return xerrors.WithStackTrace(err) +} diff --git a/examples/booktest/java/jdbc/Authors.java b/examples/booktest/java/jdbc/Authors.java new file mode 100644 index 00000000..7ad10366 --- /dev/null +++ b/examples/booktest/java/jdbc/Authors.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record Authors(long authorId, String name) {} diff --git a/examples/booktest/java/jdbc/Books.java b/examples/booktest/java/jdbc/Books.java new file mode 100644 index 00000000..df40afac --- /dev/null +++ b/examples/booktest/java/jdbc/Books.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record Books(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/jdbc/BooksByTagsRow.java b/examples/booktest/java/jdbc/BooksByTagsRow.java new file mode 100644 index 00000000..d8363db8 --- /dev/null +++ b/examples/booktest/java/jdbc/BooksByTagsRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record BooksByTagsRow(long bookId, String title, String name, String isbn, String tags) {} diff --git a/examples/booktest/java/jdbc/BooksByTitleYearRow.java b/examples/booktest/java/jdbc/BooksByTitleYearRow.java new file mode 100644 index 00000000..84b088ed --- /dev/null +++ b/examples/booktest/java/jdbc/BooksByTitleYearRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record BooksByTitleYearRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/jdbc/CreateAuthorRow.java b/examples/booktest/java/jdbc/CreateAuthorRow.java new file mode 100644 index 00000000..bb6a4d2a --- /dev/null +++ b/examples/booktest/java/jdbc/CreateAuthorRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record CreateAuthorRow(long authorId, String name) {} diff --git a/examples/booktest/java/jdbc/CreateBookRow.java b/examples/booktest/java/jdbc/CreateBookRow.java new file mode 100644 index 00000000..c2bf1d64 --- /dev/null +++ b/examples/booktest/java/jdbc/CreateBookRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record CreateBookRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/jdbc/GetAuthorRow.java b/examples/booktest/java/jdbc/GetAuthorRow.java new file mode 100644 index 00000000..52f2cdd5 --- /dev/null +++ b/examples/booktest/java/jdbc/GetAuthorRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record GetAuthorRow(long authorId, String name) {} diff --git a/examples/booktest/java/jdbc/GetBookRow.java b/examples/booktest/java/jdbc/GetBookRow.java new file mode 100644 index 00000000..dbc5ba77 --- /dev/null +++ b/examples/booktest/java/jdbc/GetBookRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record GetBookRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/jdbc/ListAuthorsWithRecentBooksRow.java b/examples/booktest/java/jdbc/ListAuthorsWithRecentBooksRow.java new file mode 100644 index 00000000..7e8b251e --- /dev/null +++ b/examples/booktest/java/jdbc/ListAuthorsWithRecentBooksRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record ListAuthorsWithRecentBooksRow(long authorId, String name) {} diff --git a/examples/booktest/java/jdbc/ListBooksWithRecentEditionsRow.java b/examples/booktest/java/jdbc/ListBooksWithRecentEditionsRow.java new file mode 100644 index 00000000..13e36002 --- /dev/null +++ b/examples/booktest/java/jdbc/ListBooksWithRecentEditionsRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record ListBooksWithRecentEditionsRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/jdbc/Queries.java b/examples/booktest/java/jdbc/Queries.java new file mode 100644 index 00000000..53f82900 --- /dev/null +++ b/examples/booktest/java/jdbc/Queries.java @@ -0,0 +1,314 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +import tech.ydb.table.values.PrimitiveValue; + +// The caller owns the injected client and its lifecycle. +public final class Queries { + private final java.sql.Connection client; + + public Queries(java.sql.Connection client) { + this.client = java.util.Objects.requireNonNull(client); + } + + // -- name: GetAuthor :one + public java.util.Optional getAuthor(long authorId) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + SELECT author_id, name + FROM authors + WHERE author_id = ?;\ + """)) { + _prepared.setObject(1, PrimitiveValue.newUint64(authorId)); + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getLong(1); + String _value1 = _rows.getString(2); + return java.util.Optional.of(new GetAuthorRow(_value0, _value1)); + } + } + } + + // -- name: GetBook :one + public java.util.Optional getBook(long bookId) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags + FROM books + WHERE book_id = ?;\ + """)) { + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)); + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getLong(1); + long _value1 = _rows.getLong(2); + String _value2 = _rows.getString(3); + String _value3 = _rows.getString(4); + String _value4 = _rows.getString(5); + int _value5 = _rows.getInt(6); + var _value6Raw = _rows.getTimestamp(7); + java.time.Instant _value6 = _value6Raw == null ? null : _value6Raw.toInstant(); + String _value7 = _rows.getString(8); + return java.util.Optional.of(new GetBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + } + } + + // -- name: DeleteBook :exec + public void deleteBook(long bookId) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + DELETE FROM books + WHERE book_id = ?;\ + """)) { + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)); + _prepared.execute(); + } + } + + // -- name: BooksByTitleYear :many + public java.util.List booksByTitleYear(String title, int publicationYear) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags + FROM books + WHERE title = ? AND publication_year = ?;\ + """)) { + _prepared.setString(1, title); + _prepared.setInt(2, publicationYear); + try (var _rows = _prepared.executeQuery()) { + var _items = new java.util.ArrayList(); + while (_rows.next()) { + long _value0 = _rows.getLong(1); + long _value1 = _rows.getLong(2); + String _value2 = _rows.getString(3); + String _value3 = _rows.getString(4); + String _value4 = _rows.getString(5); + int _value5 = _rows.getInt(6); + var _value6Raw = _rows.getTimestamp(7); + java.time.Instant _value6 = _value6Raw == null ? null : _value6Raw.toInstant(); + String _value7 = _rows.getString(8); + _items.add(new BooksByTitleYearRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + return _items; + } + } + } + + // -- name: BooksByTags :many + public java.util.List booksByTags(String tags) throws java.sql.SQLException { + try (var _prepared = client.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $tags AS Json; + SELECT + b.book_id, + b.title, + a.name, + b.isbn, + b.tags + FROM books AS b + LEFT JOIN authors AS a ON b.author_id = a.author_id + WHERE NOT SetIsDisjoint( + ToSet(Yson::ConvertToStringList(b.tags)), + Yson::ConvertToStringList($tags) + );\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setString("tags", tags); + 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); + String _value3 = _rows.getString(4); + String _value4 = _rows.getString(5); + _items.add(new BooksByTagsRow(_value0, _value1, _value2, _value3, _value4)); + } + return _items; + } + } + } + + // -- name: CreateAuthor :one + public java.util.Optional createAuthor(long authorId, String name) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + INSERT INTO authors (author_id, name) + VALUES (?, ?) + RETURNING author_id, name;\ + """)) { + _prepared.setObject(1, PrimitiveValue.newUint64(authorId)); + _prepared.setString(2, name); + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getLong(1); + String _value1 = _rows.getString(2); + return java.util.Optional.of(new CreateAuthorRow(_value0, _value1)); + } + } + } + + // -- name: CreateBook :one + public java.util.Optional createBook(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + INSERT INTO books ( + book_id, + author_id, + isbn, + book_type, + title, + publication_year, + available, + tags + ) VALUES ( + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ? + ) + RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;\ + """)) { + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)); + _prepared.setObject(2, PrimitiveValue.newUint64(authorId)); + _prepared.setString(3, isbn); + _prepared.setString(4, bookType); + _prepared.setString(5, title); + _prepared.setInt(6, publicationYear); + _prepared.setTimestamp(7, java.sql.Timestamp.from(available)); + _prepared.setObject(8, PrimitiveValue.newJson(tags)); + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + long _value0 = _rows.getLong(1); + long _value1 = _rows.getLong(2); + String _value2 = _rows.getString(3); + String _value3 = _rows.getString(4); + String _value4 = _rows.getString(5); + int _value5 = _rows.getInt(6); + var _value6Raw = _rows.getTimestamp(7); + java.time.Instant _value6 = _value6Raw == null ? null : _value6Raw.toInstant(); + String _value7 = _rows.getString(8); + return java.util.Optional.of(new CreateBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + } + } + + // -- name: UpdateBook :exec + public void updateBook(String title, String tags, long bookId) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + UPDATE books + SET title = ?, tags = ? + WHERE book_id = ?;\ + """)) { + _prepared.setString(1, title); + _prepared.setObject(2, PrimitiveValue.newJson(tags)); + _prepared.setObject(3, PrimitiveValue.newUint64(bookId)); + _prepared.execute(); + } + } + + // -- name: UpdateBookISBN :exec + public void updateBookISBN(String title, String tags, String isbn, long bookId) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + UPDATE books + SET title = ?, tags = ?, isbn = ? + WHERE book_id = ?;\ + """)) { + _prepared.setString(1, title); + _prepared.setObject(2, PrimitiveValue.newJson(tags)); + _prepared.setString(3, isbn); + _prepared.setObject(4, PrimitiveValue.newUint64(bookId)); + _prepared.execute(); + } + } + + // -- name: DeleteAuthorBeforeYear :exec + public void deleteAuthorBeforeYear(int publicationYear, long authorId) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + DELETE FROM books + WHERE publication_year < ? AND author_id = ?;\ + """)) { + _prepared.setInt(1, publicationYear); + _prepared.setObject(2, PrimitiveValue.newUint64(authorId)); + _prepared.execute(); + } + } + + // -- name: SayHello :one + public java.util.Optional sayHello(String name) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + SELECT \"hello \"u || ? AS greeting;\ + """)) { + _prepared.setString(1, name); + try (var _rows = _prepared.executeQuery()) { + if (!_rows.next()) return java.util.Optional.empty(); + String _value0 = _rows.getString(1); + return java.util.Optional.of(new SayHelloRow(_value0)); + } + } + } + + // -- name: ListAuthorsWithRecentBooks :many + public java.util.List listAuthorsWithRecentBooks(int sinceYear) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + SELECT a.author_id, a.name + FROM authors AS a + WHERE a.author_id IN ( + SELECT b.author_id FROM books AS b WHERE b.publication_year >= ? + ) + ORDER BY a.author_id;\ + """)) { + _prepared.setInt(1, sinceYear); + try (var _rows = _prepared.executeQuery()) { + var _items = new java.util.ArrayList(); + while (_rows.next()) { + long _value0 = _rows.getLong(1); + String _value1 = _rows.getString(2); + _items.add(new ListAuthorsWithRecentBooksRow(_value0, _value1)); + } + return _items; + } + } + } + + // -- name: ListBooksWithRecentEditions :many + public java.util.List listBooksWithRecentEditions(int sinceYear) throws java.sql.SQLException { + try (var _prepared = client.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $since_year AS Int32; + SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags + FROM books AS b + WHERE (b.author_id, b.book_type) IN ( + SELECT (recent.author_id, recent.book_type) + FROM books AS recent + WHERE recent.publication_year >= $since_year + ) + ORDER BY b.book_id;\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setInt("since_year", sinceYear); + try (var _rows = _prepared.executeQuery()) { + var _items = new java.util.ArrayList(); + while (_rows.next()) { + long _value0 = _rows.getLong(1); + long _value1 = _rows.getLong(2); + String _value2 = _rows.getString(3); + String _value3 = _rows.getString(4); + String _value4 = _rows.getString(5); + int _value5 = _rows.getInt(6); + var _value6Raw = _rows.getTimestamp(7); + java.time.Instant _value6 = _value6Raw == null ? null : _value6Raw.toInstant(); + String _value7 = _rows.getString(8); + _items.add(new ListBooksWithRecentEditionsRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + return _items; + } + } + } + + // -- name: DeleteBooksByAuthorName :exec + public void deleteBooksByAuthorName(String authorName) throws java.sql.SQLException { + try (var _prepared = client.prepareStatement(""" + DELETE FROM books + WHERE author_id IN (SELECT author_id FROM authors WHERE name = ?);\ + """)) { + _prepared.setString(1, authorName); + _prepared.execute(); + } + } +} diff --git a/examples/booktest/java/jdbc/SayHelloRow.java b/examples/booktest/java/jdbc/SayHelloRow.java new file mode 100644 index 00000000..c87456f8 --- /dev/null +++ b/examples/booktest/java/jdbc/SayHelloRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc; + +public record SayHelloRow(String greeting) {} diff --git a/examples/booktest/java/jooq/ListAuthorsWithRecentBooksRow.java b/examples/booktest/java/jooq/ListAuthorsWithRecentBooksRow.java new file mode 100644 index 00000000..234ef298 --- /dev/null +++ b/examples/booktest/java/jooq/ListAuthorsWithRecentBooksRow.java @@ -0,0 +1,6 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jooq; + +import org.jooq.types.ULong; + +public record ListAuthorsWithRecentBooksRow(ULong authorId, String name) {} diff --git a/examples/booktest/java/jooq/ListBooksWithRecentEditionsRow.java b/examples/booktest/java/jooq/ListBooksWithRecentEditionsRow.java new file mode 100644 index 00000000..7af1e99a --- /dev/null +++ b/examples/booktest/java/jooq/ListBooksWithRecentEditionsRow.java @@ -0,0 +1,8 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jooq; + +import java.time.Instant; +import org.jooq.JSON; +import org.jooq.types.ULong; + +public record ListBooksWithRecentEditionsRow(ULong bookId, ULong authorId, String isbn, String bookType, String title, Integer publicationYear, Instant available, JSON tags) {} diff --git a/examples/booktest/java/jooq/Queries.java b/examples/booktest/java/jooq/Queries.java index 8569e7ff..57a20e25 100644 --- a/examples/booktest/java/jooq/Queries.java +++ b/examples/booktest/java/jooq/Queries.java @@ -197,7 +197,9 @@ public void updateBookISBN( // -- name: DeleteAuthorBeforeYear :exec public void deleteAuthorBeforeYear(Integer publicationYear, ULong authorId) { dsl.deleteFrom(BOOKS) - .where(BOOKS.PUBLICATION_YEAR.lt(val(publicationYear, YdbTypes.INT32)).and(BOOKS.AUTHOR_ID.eq(val(authorId, YdbTypes.UINT64)))) + .where( + BOOKS.PUBLICATION_YEAR.lt(val(publicationYear, YdbTypes.INT32)).and(BOOKS.AUTHOR_ID.eq(val(authorId, YdbTypes.UINT64))) + ) .execute(); } @@ -207,4 +209,65 @@ public Optional sayHello(String name) { .coerce(field(name("greeting"), YdbTypes.UTF8)) .fetchOptional(mapping(SayHelloRow::new)); } + + // -- name: ListAuthorsWithRecentBooks :many + public List listAuthorsWithRecentBooks(Integer sinceYear) { + var a = AUTHORS.as("a"); + return dsl.select(a.AUTHOR_ID, a.NAME) + .from(a) + .where( + condition( + "{0} IN ({1})", + a.AUTHOR_ID, + dsl.select(BOOKS.as("b").AUTHOR_ID) + .from(BOOKS.as("b")) + .where(BOOKS.as("b").PUBLICATION_YEAR.ge(val(sinceYear, YdbTypes.INT32))) + ) + ) + .orderBy(a.AUTHOR_ID) + .coerce(field(name("author_id"), YdbTypes.UINT64), field(name("name"), YdbTypes.UTF8)) + .fetch(mapping(ListAuthorsWithRecentBooksRow::new)); + } + + // -- name: ListBooksWithRecentEditions :many + public List listBooksWithRecentEditions(Integer sinceYear) { + return dsl.connectionResult(_connection -> { + try (var _prepared = _connection.unwrap(tech.ydb.jdbc.YdbConnection.class).prepareStatement(""" + DECLARE $since_year AS Int32; + SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags + FROM\s\ + """ + dsl.render(BOOKS) + """ + AS b + WHERE (b.author_id, b.book_type) IN ( + SELECT (recent.author_id, recent.book_type) + FROM\s\ + """ + dsl.render(BOOKS) + """ + AS recent + WHERE recent.publication_year >= $since_year + ) + ORDER BY b.book_id;\ + """, tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY)) { + _prepared.setInt("since_year", sinceYear); + try (var _rows = _prepared.executeQuery()) { + var _result = dsl.fetch(_rows, YdbTypes.UINT64, YdbTypes.UINT64, YdbTypes.UTF8, YdbTypes.UTF8, YdbTypes.UTF8, YdbTypes.INT32, YdbTypes.TIMESTAMP, YdbTypes.JSON).map(_row -> new ListBooksWithRecentEditionsRow(_row.get(0, org.jooq.types.ULong.class), _row.get(1, org.jooq.types.ULong.class), _row.get(2, String.class), _row.get(3, String.class), _row.get(4, String.class), _row.get(5, Integer.class), _row.get(6, java.time.Instant.class), _row.get(7, org.jooq.JSON.class))); + return _result; + } + } + }); + } + + // -- name: DeleteBooksByAuthorName :exec + public void deleteBooksByAuthorName(String authorName) { + dsl.deleteFrom(BOOKS) + .where( + condition( + "{0} IN ({1})", + BOOKS.AUTHOR_ID, + dsl.select(AUTHORS.AUTHOR_ID) + .from(AUTHORS) + .where(AUTHORS.NAME.eq(val(authorName, YdbTypes.UTF8))) + ) + ) + .execute(); + } } diff --git a/examples/booktest/java/native/Authors.java b/examples/booktest/java/native/Authors.java new file mode 100644 index 00000000..45961700 --- /dev/null +++ b/examples/booktest/java/native/Authors.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record Authors(long authorId, String name) {} diff --git a/examples/booktest/java/native/Books.java b/examples/booktest/java/native/Books.java new file mode 100644 index 00000000..6240fb97 --- /dev/null +++ b/examples/booktest/java/native/Books.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record Books(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/native/BooksByTagsRow.java b/examples/booktest/java/native/BooksByTagsRow.java new file mode 100644 index 00000000..346b6502 --- /dev/null +++ b/examples/booktest/java/native/BooksByTagsRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record BooksByTagsRow(long bookId, String title, String name, String isbn, String tags) {} diff --git a/examples/booktest/java/native/BooksByTitleYearRow.java b/examples/booktest/java/native/BooksByTitleYearRow.java new file mode 100644 index 00000000..028bd71d --- /dev/null +++ b/examples/booktest/java/native/BooksByTitleYearRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record BooksByTitleYearRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/native/CreateAuthorRow.java b/examples/booktest/java/native/CreateAuthorRow.java new file mode 100644 index 00000000..a10dd954 --- /dev/null +++ b/examples/booktest/java/native/CreateAuthorRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record CreateAuthorRow(long authorId, String name) {} diff --git a/examples/booktest/java/native/CreateBookRow.java b/examples/booktest/java/native/CreateBookRow.java new file mode 100644 index 00000000..30f4063f --- /dev/null +++ b/examples/booktest/java/native/CreateBookRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record CreateBookRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/native/GetAuthorRow.java b/examples/booktest/java/native/GetAuthorRow.java new file mode 100644 index 00000000..4b25d8f5 --- /dev/null +++ b/examples/booktest/java/native/GetAuthorRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record GetAuthorRow(long authorId, String name) {} diff --git a/examples/booktest/java/native/GetBookRow.java b/examples/booktest/java/native/GetBookRow.java new file mode 100644 index 00000000..3929b25f --- /dev/null +++ b/examples/booktest/java/native/GetBookRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record GetBookRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/native/ListAuthorsWithRecentBooksRow.java b/examples/booktest/java/native/ListAuthorsWithRecentBooksRow.java new file mode 100644 index 00000000..a86fd858 --- /dev/null +++ b/examples/booktest/java/native/ListAuthorsWithRecentBooksRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record ListAuthorsWithRecentBooksRow(long authorId, String name) {} diff --git a/examples/booktest/java/native/ListBooksWithRecentEditionsRow.java b/examples/booktest/java/native/ListBooksWithRecentEditionsRow.java new file mode 100644 index 00000000..c14b2e7f --- /dev/null +++ b/examples/booktest/java/native/ListBooksWithRecentEditionsRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record ListBooksWithRecentEditionsRow(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) {} diff --git a/examples/booktest/java/native/Queries.java b/examples/booktest/java/native/Queries.java new file mode 100644 index 00000000..88fa8cac --- /dev/null +++ b/examples/booktest/java/native/Queries.java @@ -0,0 +1,317 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +import tech.ydb.query.QueryTransaction; +import tech.ydb.query.tools.QueryReader; +import tech.ydb.table.query.Params; +import tech.ydb.table.values.PrimitiveValue; + +// The caller owns the injected client and its lifecycle. +public final class Queries { + private final QueryTransaction client; + + public Queries(QueryTransaction client) { + this.client = java.util.Objects.requireNonNull(client); + } + + // -- name: GetAuthor :one + public java.util.Optional getAuthor(long authorId) { + var _params = Params.create(); + _params.put("$author_id", PrimitiveValue.newUint64(authorId)); + var _query = QueryReader.readFrom( + client.createQuery(""" + SELECT author_id, name + FROM authors + WHERE author_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(); + String _value1 = _rows.getColumn(1).getText(); + return java.util.Optional.of(new GetAuthorRow(_value0, _value1)); + } + + // -- name: GetBook :one + public java.util.Optional getBook(long bookId) { + var _params = Params.create(); + _params.put("$book_id", PrimitiveValue.newUint64(bookId)); + var _query = QueryReader.readFrom( + client.createQuery(""" + SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags + FROM books + WHERE book_id = $book_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(); + long _value1 = _rows.getColumn(1).getUint64(); + String _value2 = _rows.getColumn(2).getText(); + String _value3 = _rows.getColumn(3).getText(); + String _value4 = _rows.getColumn(4).getText(); + int _value5 = _rows.getColumn(5).getInt32(); + java.time.Instant _value6 = _rows.getColumn(6).getTimestamp(); + String _value7 = _rows.getColumn(7).getJson(); + return java.util.Optional.of(new GetBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + + // -- name: DeleteBook :exec + public void deleteBook(long bookId) { + var _params = Params.create(); + _params.put("$book_id", PrimitiveValue.newUint64(bookId)); + client.createQuery(""" + DELETE FROM books + WHERE book_id = $book_id;\ + """, _params).execute().join().getStatus().expectSuccess(); + } + + // -- name: BooksByTitleYear :many + public java.util.List booksByTitleYear(String title, int publicationYear) { + var _params = Params.create(); + _params.put("$title", PrimitiveValue.newText(title)); + _params.put("$publication_year", PrimitiveValue.newInt32(publicationYear)); + var _query = QueryReader.readFrom( + client.createQuery(""" + SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags + FROM books + WHERE title = $title AND publication_year = $publication_year;\ + """, _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(); + long _value1 = _rows.getColumn(1).getUint64(); + String _value2 = _rows.getColumn(2).getText(); + String _value3 = _rows.getColumn(3).getText(); + String _value4 = _rows.getColumn(4).getText(); + int _value5 = _rows.getColumn(5).getInt32(); + java.time.Instant _value6 = _rows.getColumn(6).getTimestamp(); + String _value7 = _rows.getColumn(7).getJson(); + _items.add(new BooksByTitleYearRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + return _items; + } + + // -- name: BooksByTags :many + public java.util.List booksByTags(String tags) { + var _params = Params.create(); + _params.put("$tags", PrimitiveValue.newJson(tags)); + var _query = QueryReader.readFrom( + client.createQuery(""" + DECLARE $tags AS Json; + SELECT + b.book_id, + b.title, + a.name, + b.isbn, + b.tags + FROM books AS b + LEFT JOIN authors AS a ON b.author_id = a.author_id + WHERE NOT SetIsDisjoint( + ToSet(Yson::ConvertToStringList(b.tags)), + Yson::ConvertToStringList($tags) + );\ + """, _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(); + String _value3 = _rows.getColumn(3).getText(); + String _value4 = _rows.getColumn(4).getJson(); + _items.add(new BooksByTagsRow(_value0, _value1, _value2, _value3, _value4)); + } + return _items; + } + + // -- name: CreateAuthor :one + public java.util.Optional createAuthor(long authorId, String name) { + var _params = Params.create(); + _params.put("$author_id", PrimitiveValue.newUint64(authorId)); + _params.put("$name", PrimitiveValue.newText(name)); + var _query = QueryReader.readFrom( + client.createQuery(""" + INSERT INTO authors (author_id, name) + VALUES ($author_id, $name) + RETURNING author_id, name;\ + """, _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(); + String _value1 = _rows.getColumn(1).getText(); + return java.util.Optional.of(new CreateAuthorRow(_value0, _value1)); + } + + // -- name: CreateBook :one + public java.util.Optional createBook(long bookId, long authorId, String isbn, String bookType, String title, int publicationYear, java.time.Instant available, String tags) { + var _params = Params.create(); + _params.put("$book_id", PrimitiveValue.newUint64(bookId)); + _params.put("$author_id", PrimitiveValue.newUint64(authorId)); + _params.put("$isbn", PrimitiveValue.newText(isbn)); + _params.put("$book_type", PrimitiveValue.newText(bookType)); + _params.put("$title", PrimitiveValue.newText(title)); + _params.put("$publication_year", PrimitiveValue.newInt32(publicationYear)); + _params.put("$available", PrimitiveValue.newTimestamp(available)); + _params.put("$tags", PrimitiveValue.newJson(tags)); + var _query = QueryReader.readFrom( + client.createQuery(""" + INSERT INTO books ( + book_id, + author_id, + isbn, + book_type, + title, + publication_year, + available, + tags + ) VALUES ( + $book_id, + $author_id, + $isbn, + $book_type, + $title, + $publication_year, + $available, + $tags + ) + RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;\ + """, _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(); + String _value2 = _rows.getColumn(2).getText(); + String _value3 = _rows.getColumn(3).getText(); + String _value4 = _rows.getColumn(4).getText(); + int _value5 = _rows.getColumn(5).getInt32(); + java.time.Instant _value6 = _rows.getColumn(6).getTimestamp(); + String _value7 = _rows.getColumn(7).getJson(); + return java.util.Optional.of(new CreateBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + + // -- name: UpdateBook :exec + public void updateBook(String title, String tags, long bookId) { + var _params = Params.create(); + _params.put("$title", PrimitiveValue.newText(title)); + _params.put("$tags", PrimitiveValue.newJson(tags)); + _params.put("$book_id", PrimitiveValue.newUint64(bookId)); + client.createQuery(""" + UPDATE books + SET title = $title, tags = $tags + WHERE book_id = $book_id;\ + """, _params).execute().join().getStatus().expectSuccess(); + } + + // -- name: UpdateBookISBN :exec + public void updateBookISBN(String title, String tags, String isbn, long bookId) { + var _params = Params.create(); + _params.put("$title", PrimitiveValue.newText(title)); + _params.put("$tags", PrimitiveValue.newJson(tags)); + _params.put("$isbn", PrimitiveValue.newText(isbn)); + _params.put("$book_id", PrimitiveValue.newUint64(bookId)); + client.createQuery(""" + UPDATE books + SET title = $title, tags = $tags, isbn = $isbn + WHERE book_id = $book_id;\ + """, _params).execute().join().getStatus().expectSuccess(); + } + + // -- name: DeleteAuthorBeforeYear :exec + public void deleteAuthorBeforeYear(int publicationYear, long authorId) { + var _params = Params.create(); + _params.put("$publication_year", PrimitiveValue.newInt32(publicationYear)); + _params.put("$author_id", PrimitiveValue.newUint64(authorId)); + client.createQuery(""" + DELETE FROM books + WHERE publication_year < $publication_year AND author_id = $author_id;\ + """, _params).execute().join().getStatus().expectSuccess(); + } + + // -- name: SayHello :one + public java.util.Optional sayHello(String name) { + var _params = Params.create(); + _params.put("$name", PrimitiveValue.newText(name)); + var _query = QueryReader.readFrom( + client.createQuery(""" + SELECT \"hello \"u || $name AS greeting;\ + """, _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(); + String _value0 = _rows.getColumn(0).getText(); + return java.util.Optional.of(new SayHelloRow(_value0)); + } + + // -- name: ListAuthorsWithRecentBooks :many + public java.util.List listAuthorsWithRecentBooks(int sinceYear) { + var _params = Params.create(); + _params.put("$since_year", PrimitiveValue.newInt32(sinceYear)); + var _query = QueryReader.readFrom( + client.createQuery(""" + SELECT a.author_id, a.name + FROM authors AS a + WHERE a.author_id IN ( + SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year + ) + ORDER BY a.author_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(); + _items.add(new ListAuthorsWithRecentBooksRow(_value0, _value1)); + } + return _items; + } + + // -- name: ListBooksWithRecentEditions :many + public java.util.List listBooksWithRecentEditions(int sinceYear) { + var _params = Params.create(); + _params.put("$since_year", PrimitiveValue.newInt32(sinceYear)); + var _query = QueryReader.readFrom( + client.createQuery(""" + DECLARE $since_year AS Int32; + SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags + FROM books AS b + WHERE (b.author_id, b.book_type) IN ( + SELECT (recent.author_id, recent.book_type) + FROM books AS recent + WHERE recent.publication_year >= $since_year + ) + ORDER BY b.book_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(); + long _value1 = _rows.getColumn(1).getUint64(); + String _value2 = _rows.getColumn(2).getText(); + String _value3 = _rows.getColumn(3).getText(); + String _value4 = _rows.getColumn(4).getText(); + int _value5 = _rows.getColumn(5).getInt32(); + java.time.Instant _value6 = _rows.getColumn(6).getTimestamp(); + String _value7 = _rows.getColumn(7).getJson(); + _items.add(new ListBooksWithRecentEditionsRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)); + } + return _items; + } + + // -- name: DeleteBooksByAuthorName :exec + public void deleteBooksByAuthorName(String authorName) { + var _params = Params.create(); + _params.put("$author_name", PrimitiveValue.newText(authorName)); + client.createQuery(""" + DELETE FROM books + WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);\ + """, _params).execute().join().getStatus().expectSuccess(); + } +} diff --git a/examples/booktest/java/native/SayHelloRow.java b/examples/booktest/java/native/SayHelloRow.java new file mode 100644 index 00000000..c3524e13 --- /dev/null +++ b/examples/booktest/java/native/SayHelloRow.java @@ -0,0 +1,4 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi; + +public record SayHelloRow(String greeting) {} diff --git a/examples/booktest/kotlin/exposed/Authors.kt b/examples/booktest/kotlin/exposed/Authors.kt new file mode 100644 index 00000000..2032d8f9 --- /dev/null +++ b/examples/booktest/kotlin/exposed/Authors.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class Authors( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/exposed/Books.kt b/examples/booktest/kotlin/exposed/Books.kt new file mode 100644 index 00000000..0d8e90eb --- /dev/null +++ b/examples/booktest/kotlin/exposed/Books.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class Books( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/exposed/BooksByTagsRow.kt b/examples/booktest/kotlin/exposed/BooksByTagsRow.kt new file mode 100644 index 00000000..a085f087 --- /dev/null +++ b/examples/booktest/kotlin/exposed/BooksByTagsRow.kt @@ -0,0 +1,10 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class BooksByTagsRow( + val bookId: Long, + val title: String, + val name: String?, + val isbn: String, + val tags: String +) diff --git a/examples/booktest/kotlin/exposed/BooksByTitleYearRow.kt b/examples/booktest/kotlin/exposed/BooksByTitleYearRow.kt new file mode 100644 index 00000000..14094d2a --- /dev/null +++ b/examples/booktest/kotlin/exposed/BooksByTitleYearRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class BooksByTitleYearRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/exposed/CreateAuthorRow.kt b/examples/booktest/kotlin/exposed/CreateAuthorRow.kt new file mode 100644 index 00000000..8392ed56 --- /dev/null +++ b/examples/booktest/kotlin/exposed/CreateAuthorRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class CreateAuthorRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/exposed/CreateBookRow.kt b/examples/booktest/kotlin/exposed/CreateBookRow.kt new file mode 100644 index 00000000..f8e4e840 --- /dev/null +++ b/examples/booktest/kotlin/exposed/CreateBookRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class CreateBookRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/exposed/GetAuthorRow.kt b/examples/booktest/kotlin/exposed/GetAuthorRow.kt new file mode 100644 index 00000000..c866beaa --- /dev/null +++ b/examples/booktest/kotlin/exposed/GetAuthorRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class GetAuthorRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/exposed/GetBookRow.kt b/examples/booktest/kotlin/exposed/GetBookRow.kt new file mode 100644 index 00000000..06ba0ebc --- /dev/null +++ b/examples/booktest/kotlin/exposed/GetBookRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class GetBookRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/exposed/ListAuthorsWithRecentBooksRow.kt b/examples/booktest/kotlin/exposed/ListAuthorsWithRecentBooksRow.kt new file mode 100644 index 00000000..8fe0467a --- /dev/null +++ b/examples/booktest/kotlin/exposed/ListAuthorsWithRecentBooksRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class ListAuthorsWithRecentBooksRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/exposed/ListBooksWithRecentEditionsRow.kt b/examples/booktest/kotlin/exposed/ListBooksWithRecentEditionsRow.kt new file mode 100644 index 00000000..4fb0a3fb --- /dev/null +++ b/examples/booktest/kotlin/exposed/ListBooksWithRecentEditionsRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class ListBooksWithRecentEditionsRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/exposed/Queries.kt b/examples/booktest/kotlin/exposed/Queries.kt new file mode 100644 index 00000000..3f12cc9f --- /dev/null +++ b/examples/booktest/kotlin/exposed/Queries.kt @@ -0,0 +1,307 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +import tech.ydb.table.values.PrimitiveValue +import tech.ydb.table.values.PrimitiveType +import tech.ydb.table.values.OptionalType + +// The caller owns the injected client and its lifecycle. +class Queries(private val client: org.jetbrains.exposed.v1.jdbc.JdbcTransaction) { + + // -- name: GetAuthor :one + fun getAuthor(authorId: Long): GetAuthorRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "SELECT author_id, name\n" + + "FROM authors\n" + + "WHERE author_id = ?;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(authorId)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + return GetAuthorRow(_value0, _value1) + } + } + } + + // -- name: GetBook :one + fun getBook(bookId: Long): GetBookRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + + "FROM books\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + return GetBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } + } + } + + // -- name: DeleteBook :exec + fun deleteBook(bookId: Long): Unit { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "DELETE FROM books\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)) + _prepared.execute() + } + } + + // -- name: BooksByTitleYear :many + fun booksByTitleYear(title: String, publicationYear: Int): List { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + + "FROM books\n" + + "WHERE title = ? AND publication_year = ?;").use { _prepared -> + _prepared.setString(1, title) + _prepared.setInt(2, publicationYear) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + _items.add(BooksByTitleYearRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)) + } + return _items + } + } + } + + // -- name: BooksByTags :many + fun booksByTags(tags: String): List { + val _connection = client.connection.connection as java.sql.Connection + _connection.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$tags AS Json;\n" + + "SELECT\n" + + " b.book_id,\n" + + " b.title,\n" + + " a.name,\n" + + " b.isbn,\n" + + " b.tags\n" + + "FROM books AS b\n" + + "LEFT JOIN authors AS a ON b.author_id = a.author_id\n" + + "WHERE NOT SetIsDisjoint(\n" + + " ToSet(Yson::ConvertToStringList(b.tags)),\n" + + " Yson::ConvertToStringList(\$tags)\n" + + ");", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setString("tags", tags) + _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: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + _items.add(BooksByTagsRow(_value0, _value1, _value2, _value3, _value4)) + } + return _items + } + } + } + + // -- name: CreateAuthor :one + fun createAuthor(authorId: Long, name: String): CreateAuthorRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "INSERT INTO authors (author_id, name)\n" + + "VALUES (?, ?)\n" + + "RETURNING author_id, name;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(authorId)) + _prepared.setString(2, name) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + return CreateAuthorRow(_value0, _value1) + } + } + } + + // -- name: CreateBook :one + fun createBook(bookId: Long, authorId: Long, isbn: String, bookType: String, title: String, publicationYear: Int, available: java.time.Instant, tags: String): CreateBookRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "INSERT INTO books (\n" + + " book_id,\n" + + " author_id,\n" + + " isbn,\n" + + " book_type,\n" + + " title,\n" + + " publication_year,\n" + + " available,\n" + + " tags\n" + + ") VALUES (\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?\n" + + ")\n" + + "RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)) + _prepared.setObject(2, PrimitiveValue.newUint64(authorId)) + _prepared.setString(3, isbn) + _prepared.setString(4, bookType) + _prepared.setString(5, title) + _prepared.setInt(6, publicationYear) + _prepared.setTimestamp(7, java.sql.Timestamp.from(available)) + _prepared.setObject(8, PrimitiveValue.newJson(tags)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + return CreateBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } + } + } + + // -- name: UpdateBook :exec + fun updateBook(title: String, tags: String, bookId: Long): Unit { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "UPDATE books\n" + + "SET title = ?, tags = ?\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setString(1, title) + _prepared.setObject(2, PrimitiveValue.newJson(tags)) + _prepared.setObject(3, PrimitiveValue.newUint64(bookId)) + _prepared.execute() + } + } + + // -- name: UpdateBookISBN :exec + fun updateBookISBN(title: String, tags: String, isbn: String, bookId: Long): Unit { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "UPDATE books\n" + + "SET title = ?, tags = ?, isbn = ?\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setString(1, title) + _prepared.setObject(2, PrimitiveValue.newJson(tags)) + _prepared.setString(3, isbn) + _prepared.setObject(4, PrimitiveValue.newUint64(bookId)) + _prepared.execute() + } + } + + // -- name: DeleteAuthorBeforeYear :exec + fun deleteAuthorBeforeYear(publicationYear: Int, authorId: Long): Unit { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "DELETE FROM books\n" + + "WHERE publication_year < ? AND author_id = ?;").use { _prepared -> + _prepared.setInt(1, publicationYear) + _prepared.setObject(2, PrimitiveValue.newUint64(authorId)) + _prepared.execute() + } + } + + // -- name: SayHello :one + fun sayHello(name: String): SayHelloRow? { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "SELECT \"hello \"u || ? AS greeting;").use { _prepared -> + _prepared.setString(1, name) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: String = _rows.getString(1) + return SayHelloRow(_value0) + } + } + } + + // -- name: ListAuthorsWithRecentBooks :many + fun listAuthorsWithRecentBooks(sinceYear: Int): List { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "SELECT a.author_id, a.name\n" + + "FROM authors AS a\n" + + "WHERE a.author_id IN (\n" + + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= ?\n" + + ")\n" + + "ORDER BY a.author_id;").use { _prepared -> + _prepared.setInt(1, sinceYear) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + _items.add(ListAuthorsWithRecentBooksRow(_value0, _value1)) + } + return _items + } + } + } + + // -- name: ListBooksWithRecentEditions :many + fun listBooksWithRecentEditions(sinceYear: Int): List { + val _connection = client.connection.connection as java.sql.Connection + _connection.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$since_year AS Int32;\n" + + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + + "FROM books AS b\n" + + "WHERE (b.author_id, b.book_type) IN (\n" + + " SELECT (recent.author_id, recent.book_type)\n" + + " FROM books AS recent\n" + + " WHERE recent.publication_year >= \$since_year\n" + + ")\n" + + "ORDER BY b.book_id;", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setInt("since_year", sinceYear) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + _items.add(ListBooksWithRecentEditionsRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)) + } + return _items + } + } + } + + // -- name: DeleteBooksByAuthorName :exec + fun deleteBooksByAuthorName(authorName: String): Unit { + val _connection = client.connection.connection as java.sql.Connection + _connection.prepareStatement( + "DELETE FROM books\n" + + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = ?);").use { _prepared -> + _prepared.setString(1, authorName) + _prepared.execute() + } + } +} diff --git a/examples/booktest/kotlin/exposed/SayHelloRow.kt b/examples/booktest/kotlin/exposed/SayHelloRow.kt new file mode 100644 index 00000000..bb0f5346 --- /dev/null +++ b/examples/booktest/kotlin/exposed/SayHelloRow.kt @@ -0,0 +1,6 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.exposed + +data class SayHelloRow( + val greeting: String +) diff --git a/examples/booktest/kotlin/jdbc/Authors.kt b/examples/booktest/kotlin/jdbc/Authors.kt new file mode 100644 index 00000000..ec5bdaa4 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/Authors.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class Authors( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/jdbc/Books.kt b/examples/booktest/kotlin/jdbc/Books.kt new file mode 100644 index 00000000..9d2b63dd --- /dev/null +++ b/examples/booktest/kotlin/jdbc/Books.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class Books( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/jdbc/BooksByTagsRow.kt b/examples/booktest/kotlin/jdbc/BooksByTagsRow.kt new file mode 100644 index 00000000..d2acd4a1 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/BooksByTagsRow.kt @@ -0,0 +1,10 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class BooksByTagsRow( + val bookId: Long, + val title: String, + val name: String?, + val isbn: String, + val tags: String +) diff --git a/examples/booktest/kotlin/jdbc/BooksByTitleYearRow.kt b/examples/booktest/kotlin/jdbc/BooksByTitleYearRow.kt new file mode 100644 index 00000000..45c3d433 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/BooksByTitleYearRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class BooksByTitleYearRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/jdbc/CreateAuthorRow.kt b/examples/booktest/kotlin/jdbc/CreateAuthorRow.kt new file mode 100644 index 00000000..b689870f --- /dev/null +++ b/examples/booktest/kotlin/jdbc/CreateAuthorRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class CreateAuthorRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/jdbc/CreateBookRow.kt b/examples/booktest/kotlin/jdbc/CreateBookRow.kt new file mode 100644 index 00000000..04a4cef9 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/CreateBookRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class CreateBookRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/jdbc/GetAuthorRow.kt b/examples/booktest/kotlin/jdbc/GetAuthorRow.kt new file mode 100644 index 00000000..3624c3b1 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/GetAuthorRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class GetAuthorRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/jdbc/GetBookRow.kt b/examples/booktest/kotlin/jdbc/GetBookRow.kt new file mode 100644 index 00000000..45ccfb86 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/GetBookRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class GetBookRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/jdbc/ListAuthorsWithRecentBooksRow.kt b/examples/booktest/kotlin/jdbc/ListAuthorsWithRecentBooksRow.kt new file mode 100644 index 00000000..3ec6621f --- /dev/null +++ b/examples/booktest/kotlin/jdbc/ListAuthorsWithRecentBooksRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class ListAuthorsWithRecentBooksRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/jdbc/ListBooksWithRecentEditionsRow.kt b/examples/booktest/kotlin/jdbc/ListBooksWithRecentEditionsRow.kt new file mode 100644 index 00000000..8b9fdbd6 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/ListBooksWithRecentEditionsRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class ListBooksWithRecentEditionsRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/jdbc/Queries.kt b/examples/booktest/kotlin/jdbc/Queries.kt new file mode 100644 index 00000000..109df5f3 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/Queries.kt @@ -0,0 +1,293 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +import tech.ydb.table.values.PrimitiveValue +import tech.ydb.table.values.PrimitiveType +import tech.ydb.table.values.OptionalType + +// The caller owns the injected client and its lifecycle. +class Queries(private val client: java.sql.Connection) { + + // -- name: GetAuthor :one + fun getAuthor(authorId: Long): GetAuthorRow? { + client.prepareStatement( + "SELECT author_id, name\n" + + "FROM authors\n" + + "WHERE author_id = ?;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(authorId)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + return GetAuthorRow(_value0, _value1) + } + } + } + + // -- name: GetBook :one + fun getBook(bookId: Long): GetBookRow? { + client.prepareStatement( + "SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + + "FROM books\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + return GetBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } + } + } + + // -- name: DeleteBook :exec + fun deleteBook(bookId: Long): Unit { + client.prepareStatement( + "DELETE FROM books\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)) + _prepared.execute() + } + } + + // -- name: BooksByTitleYear :many + fun booksByTitleYear(title: String, publicationYear: Int): List { + client.prepareStatement( + "SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + + "FROM books\n" + + "WHERE title = ? AND publication_year = ?;").use { _prepared -> + _prepared.setString(1, title) + _prepared.setInt(2, publicationYear) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + _items.add(BooksByTitleYearRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)) + } + return _items + } + } + } + + // -- name: BooksByTags :many + fun booksByTags(tags: String): List { + client.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$tags AS Json;\n" + + "SELECT\n" + + " b.book_id,\n" + + " b.title,\n" + + " a.name,\n" + + " b.isbn,\n" + + " b.tags\n" + + "FROM books AS b\n" + + "LEFT JOIN authors AS a ON b.author_id = a.author_id\n" + + "WHERE NOT SetIsDisjoint(\n" + + " ToSet(Yson::ConvertToStringList(b.tags)),\n" + + " Yson::ConvertToStringList(\$tags)\n" + + ");", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setString("tags", tags) + _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: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + _items.add(BooksByTagsRow(_value0, _value1, _value2, _value3, _value4)) + } + return _items + } + } + } + + // -- name: CreateAuthor :one + fun createAuthor(authorId: Long, name: String): CreateAuthorRow? { + client.prepareStatement( + "INSERT INTO authors (author_id, name)\n" + + "VALUES (?, ?)\n" + + "RETURNING author_id, name;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(authorId)) + _prepared.setString(2, name) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + return CreateAuthorRow(_value0, _value1) + } + } + } + + // -- name: CreateBook :one + fun createBook(bookId: Long, authorId: Long, isbn: String, bookType: String, title: String, publicationYear: Int, available: java.time.Instant, tags: String): CreateBookRow? { + client.prepareStatement( + "INSERT INTO books (\n" + + " book_id,\n" + + " author_id,\n" + + " isbn,\n" + + " book_type,\n" + + " title,\n" + + " publication_year,\n" + + " available,\n" + + " tags\n" + + ") VALUES (\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?,\n" + + " ?\n" + + ")\n" + + "RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;").use { _prepared -> + _prepared.setObject(1, PrimitiveValue.newUint64(bookId)) + _prepared.setObject(2, PrimitiveValue.newUint64(authorId)) + _prepared.setString(3, isbn) + _prepared.setString(4, bookType) + _prepared.setString(5, title) + _prepared.setInt(6, publicationYear) + _prepared.setTimestamp(7, java.sql.Timestamp.from(available)) + _prepared.setObject(8, PrimitiveValue.newJson(tags)) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + return CreateBookRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7) + } + } + } + + // -- name: UpdateBook :exec + fun updateBook(title: String, tags: String, bookId: Long): Unit { + client.prepareStatement( + "UPDATE books\n" + + "SET title = ?, tags = ?\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setString(1, title) + _prepared.setObject(2, PrimitiveValue.newJson(tags)) + _prepared.setObject(3, PrimitiveValue.newUint64(bookId)) + _prepared.execute() + } + } + + // -- name: UpdateBookISBN :exec + fun updateBookISBN(title: String, tags: String, isbn: String, bookId: Long): Unit { + client.prepareStatement( + "UPDATE books\n" + + "SET title = ?, tags = ?, isbn = ?\n" + + "WHERE book_id = ?;").use { _prepared -> + _prepared.setString(1, title) + _prepared.setObject(2, PrimitiveValue.newJson(tags)) + _prepared.setString(3, isbn) + _prepared.setObject(4, PrimitiveValue.newUint64(bookId)) + _prepared.execute() + } + } + + // -- name: DeleteAuthorBeforeYear :exec + fun deleteAuthorBeforeYear(publicationYear: Int, authorId: Long): Unit { + client.prepareStatement( + "DELETE FROM books\n" + + "WHERE publication_year < ? AND author_id = ?;").use { _prepared -> + _prepared.setInt(1, publicationYear) + _prepared.setObject(2, PrimitiveValue.newUint64(authorId)) + _prepared.execute() + } + } + + // -- name: SayHello :one + fun sayHello(name: String): SayHelloRow? { + client.prepareStatement( + "SELECT \"hello \"u || ? AS greeting;").use { _prepared -> + _prepared.setString(1, name) + _prepared.executeQuery().use { _rows -> + if (!_rows.next()) return null + val _value0: String = _rows.getString(1) + return SayHelloRow(_value0) + } + } + } + + // -- name: ListAuthorsWithRecentBooks :many + fun listAuthorsWithRecentBooks(sinceYear: Int): List { + client.prepareStatement( + "SELECT a.author_id, a.name\n" + + "FROM authors AS a\n" + + "WHERE a.author_id IN (\n" + + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= ?\n" + + ")\n" + + "ORDER BY a.author_id;").use { _prepared -> + _prepared.setInt(1, sinceYear) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: String = _rows.getString(2) + _items.add(ListAuthorsWithRecentBooksRow(_value0, _value1)) + } + return _items + } + } + } + + // -- name: ListBooksWithRecentEditions :many + fun listBooksWithRecentEditions(sinceYear: Int): List { + client.unwrap(tech.ydb.jdbc.YdbConnection::class.java).prepareStatement( + "DECLARE \$since_year AS Int32;\n" + + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + + "FROM books AS b\n" + + "WHERE (b.author_id, b.book_type) IN (\n" + + " SELECT (recent.author_id, recent.book_type)\n" + + " FROM books AS recent\n" + + " WHERE recent.publication_year >= \$since_year\n" + + ")\n" + + "ORDER BY b.book_id;", tech.ydb.jdbc.YdbPrepareMode.DATA_QUERY).use { _prepared -> + _prepared.setInt("since_year", sinceYear) + _prepared.executeQuery().use { _rows -> + val _items = ArrayList() + while (_rows.next()) { + val _value0: Long = _rows.getLong(1) + val _value1: Long = _rows.getLong(2) + val _value2: String = _rows.getString(3) + val _value3: String = _rows.getString(4) + val _value4: String = _rows.getString(5) + val _value5: Int = _rows.getInt(6) + val _value6: java.time.Instant = _rows.getTimestamp(7).toInstant() + val _value7: String = _rows.getString(8) + _items.add(ListBooksWithRecentEditionsRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)) + } + return _items + } + } + } + + // -- name: DeleteBooksByAuthorName :exec + fun deleteBooksByAuthorName(authorName: String): Unit { + client.prepareStatement( + "DELETE FROM books\n" + + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = ?);").use { _prepared -> + _prepared.setString(1, authorName) + _prepared.execute() + } + } +} diff --git a/examples/booktest/kotlin/jdbc/SayHelloRow.kt b/examples/booktest/kotlin/jdbc/SayHelloRow.kt new file mode 100644 index 00000000..5babeb74 --- /dev/null +++ b/examples/booktest/kotlin/jdbc/SayHelloRow.kt @@ -0,0 +1,6 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.jdbc + +data class SayHelloRow( + val greeting: String +) diff --git a/examples/booktest/kotlin/nativeapi/ListAuthorsWithRecentBooksRow.kt b/examples/booktest/kotlin/nativeapi/ListAuthorsWithRecentBooksRow.kt new file mode 100644 index 00000000..d1100b66 --- /dev/null +++ b/examples/booktest/kotlin/nativeapi/ListAuthorsWithRecentBooksRow.kt @@ -0,0 +1,7 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi + +data class ListAuthorsWithRecentBooksRow( + val authorId: Long, + val name: String +) diff --git a/examples/booktest/kotlin/nativeapi/ListBooksWithRecentEditionsRow.kt b/examples/booktest/kotlin/nativeapi/ListBooksWithRecentEditionsRow.kt new file mode 100644 index 00000000..7db862b6 --- /dev/null +++ b/examples/booktest/kotlin/nativeapi/ListBooksWithRecentEditionsRow.kt @@ -0,0 +1,13 @@ +// Code generated by sqlc-ydb. DO NOT EDIT. +package booktest.nativeapi + +data class ListBooksWithRecentEditionsRow( + val bookId: Long, + val authorId: Long, + val isbn: String, + val bookType: String, + val title: String, + val publicationYear: Int, + val available: java.time.Instant, + val tags: String +) diff --git a/examples/booktest/kotlin/nativeapi/Queries.kt b/examples/booktest/kotlin/nativeapi/Queries.kt index e9d17427..71deaabf 100644 --- a/examples/booktest/kotlin/nativeapi/Queries.kt +++ b/examples/booktest/kotlin/nativeapi/Queries.kt @@ -362,4 +362,101 @@ class Queries { val _value0: String = _rows.getColumn(0).getText() return SayHelloRow(_value0) } + + // -- name: ListAuthorsWithRecentBooks :many + fun listAuthorsWithRecentBooks(sinceYear: Int): List { + val _params = Params.create() + _params.put("\$since_year", PrimitiveValue.newInt32(sinceYear)) + val _query = if (transaction != null) { + QueryReader.readFrom(transaction.createQuery( + "SELECT a.author_id, a.name\n" + + "FROM authors AS a\n" + + "WHERE a.author_id IN (\n" + + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= \$since_year\n" + + ")\n" + + "ORDER BY a.author_id;", _params)).join().getValue() + } else { + client!!.supplyResult { _session -> + QueryReader.readFrom(_session.createQuery( + "SELECT a.author_id, a.name\n" + + "FROM authors AS a\n" + + "WHERE a.author_id IN (\n" + + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= \$since_year\n" + + ")\n" + + "ORDER BY a.author_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() + _items.add(ListAuthorsWithRecentBooksRow(_value0, _value1)) + } + return _items + } + + // -- name: ListBooksWithRecentEditions :many + fun listBooksWithRecentEditions(sinceYear: Int): List { + val _params = Params.create() + _params.put("\$since_year", PrimitiveValue.newInt32(sinceYear)) + val _query = if (transaction != null) { + QueryReader.readFrom(transaction.createQuery( + "DECLARE \$since_year AS Int32;\n" + + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + + "FROM books AS b\n" + + "WHERE (b.author_id, b.book_type) IN (\n" + + " SELECT (recent.author_id, recent.book_type)\n" + + " FROM books AS recent\n" + + " WHERE recent.publication_year >= \$since_year\n" + + ")\n" + + "ORDER BY b.book_id;", _params)).join().getValue() + } else { + client!!.supplyResult { _session -> + QueryReader.readFrom(_session.createQuery( + "DECLARE \$since_year AS Int32;\n" + + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + + "FROM books AS b\n" + + "WHERE (b.author_id, b.book_type) IN (\n" + + " SELECT (recent.author_id, recent.book_type)\n" + + " FROM books AS recent\n" + + " WHERE recent.publication_year >= \$since_year\n" + + ")\n" + + "ORDER BY b.book_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: Long = _rows.getColumn(1).getUint64() + val _value2: String = _rows.getColumn(2).getText() + val _value3: String = _rows.getColumn(3).getText() + val _value4: String = _rows.getColumn(4).getText() + val _value5: Int = _rows.getColumn(5).getInt32() + val _value6: java.time.Instant = _rows.getColumn(6).getTimestamp() + val _value7: String = _rows.getColumn(7).getJson() + _items.add(ListBooksWithRecentEditionsRow(_value0, _value1, _value2, _value3, _value4, _value5, _value6, _value7)) + } + return _items + } + + // -- name: DeleteBooksByAuthorName :exec + fun deleteBooksByAuthorName(authorName: String): Unit { + val _params = Params.create() + _params.put("\$author_name", PrimitiveValue.newText(authorName)) + if (transaction != null) { + transaction.createQuery( + "DELETE FROM books\n" + + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = \$author_name);", _params).execute().join().getStatus().expectSuccess() + } else { + client!!.supplyResult { _session -> + _session.createQuery( + "DELETE FROM books\n" + + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = \$author_name);", TxMode.SERIALIZABLE_RW, _params).execute() + }.join().getStatus().expectSuccess() + } + } } diff --git a/examples/booktest/php/native/ListAuthorsWithRecentBooksRow.php b/examples/booktest/php/native/ListAuthorsWithRecentBooksRow.php new file mode 100644 index 00000000..a3a7d6e5 --- /dev/null +++ b/examples/booktest/php/native/ListAuthorsWithRecentBooksRow.php @@ -0,0 +1,13 @@ + */ + // -- name: ListAuthorsWithRecentBooks :many + public function listAuthorsWithRecentBooks(int $sinceYear): array + { + $parameters = [ + '$since_year' => YdbValueCodec::typedInt32($sinceYear, 'since_year'), + ]; + + $result = $this->execute(function (Session $session) use ($parameters): ExecuteQueryResult { + $query = $session->newQuery(<<<'SQLC_YDB_YQL' + SELECT a.author_id, a.name + FROM authors AS a + WHERE a.author_id IN ( + SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year + ) + ORDER BY a.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, + 'ListAuthorsWithRecentBooks', + [ + ['author_id', PrimitiveTypeId::UINT64, false], + ['name', PrimitiveTypeId::UTF8, false], + ], + static fn($items): ListAuthorsWithRecentBooksRow => new ListAuthorsWithRecentBooksRow( + YdbValueCodec::uint64($items->offsetGet(0), 'ListAuthorsWithRecentBooks.author_id'), + YdbValueCodec::utf8($items->offsetGet(1), 'ListAuthorsWithRecentBooks.name'), + ), + ); + + return $rows; + } + + /** @return list */ + // -- name: ListBooksWithRecentEditions :many + public function listBooksWithRecentEditions(int $sinceYear): array + { + $parameters = [ + '$since_year' => YdbValueCodec::typedInt32($sinceYear, 'since_year'), + ]; + + $result = $this->execute(function (Session $session) use ($parameters): ExecuteQueryResult { + $query = $session->newQuery(<<<'SQLC_YDB_YQL' + DECLARE $since_year AS Int32; + SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags + FROM books AS b + WHERE (b.author_id, b.book_type) IN ( + SELECT (recent.author_id, recent.book_type) + FROM books AS recent + WHERE recent.publication_year >= $since_year + ) + ORDER BY b.book_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, + 'ListBooksWithRecentEditions', + [ + ['book_id', PrimitiveTypeId::UINT64, false], + ['author_id', PrimitiveTypeId::UINT64, false], + ['isbn', PrimitiveTypeId::UTF8, false], + ['book_type', PrimitiveTypeId::UTF8, false], + ['title', PrimitiveTypeId::UTF8, false], + ['publication_year', PrimitiveTypeId::INT32, false], + ['available', PrimitiveTypeId::TIMESTAMP, false], + ['tags', PrimitiveTypeId::JSON, false], + ], + static fn($items): ListBooksWithRecentEditionsRow => new ListBooksWithRecentEditionsRow( + YdbValueCodec::uint64($items->offsetGet(0), 'ListBooksWithRecentEditions.book_id'), + YdbValueCodec::uint64($items->offsetGet(1), 'ListBooksWithRecentEditions.author_id'), + YdbValueCodec::utf8($items->offsetGet(2), 'ListBooksWithRecentEditions.isbn'), + YdbValueCodec::utf8($items->offsetGet(3), 'ListBooksWithRecentEditions.book_type'), + YdbValueCodec::utf8($items->offsetGet(4), 'ListBooksWithRecentEditions.title'), + YdbValueCodec::int32($items->offsetGet(5), 'ListBooksWithRecentEditions.publication_year'), + YdbValueCodec::timestamp($items->offsetGet(6), 'ListBooksWithRecentEditions.available'), + YdbValueCodec::json($items->offsetGet(7), 'ListBooksWithRecentEditions.tags'), + ), + ); + + return $rows; + } + + // -- name: DeleteBooksByAuthorName :exec + public function deleteBooksByAuthorName(string $authorName): void + { + $parameters = [ + '$author_name' => YdbValueCodec::typedUtf8($authorName, 'author_name'), + ]; + + $this->execute(function (Session $session) use ($parameters): ExecuteQueryResult { + $query = $session->newQuery(<<<'SQLC_YDB_YQL' + DELETE FROM books + WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name); + 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); + }); + } + /** * @param array $expectedColumns * @return list diff --git a/examples/booktest/python/dbapi/__init__.py b/examples/booktest/python/dbapi/__init__.py new file mode 100644 index 00000000..f7188d56 --- /dev/null +++ b/examples/booktest/python/dbapi/__init__.py @@ -0,0 +1 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. diff --git a/examples/booktest/python/dbapi/models.py b/examples/booktest/python/dbapi/models.py new file mode 100644 index 00000000..fcafeb64 --- /dev/null +++ b/examples/booktest/python/dbapi/models.py @@ -0,0 +1,33 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from uuid import UUID +from typing import Optional + +@dataclass +class Authors: + author_id: int + name: str + +@dataclass +class Books: + book_id: int + author_id: int + isbn: str + book_type: str + title: str + publication_year: int + available: datetime + tags: str + +@dataclass +class BooksByTagsRow: + book_id: int + title: str + name: Optional[str] + isbn: str + tags: str + +@dataclass +class SayHelloRow: + greeting: str diff --git a/examples/booktest/python/dbapi/queries.py b/examples/booktest/python/dbapi/queries.py new file mode 100644 index 00000000..12091252 --- /dev/null +++ b/examples/booktest/python/dbapi/queries.py @@ -0,0 +1,367 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. +from __future__ import annotations +from typing import Optional +from datetime import datetime +from . import models as _models +import ydb as _ydb + + +class Querier: + def __init__(self, connection): + self._connection = connection + + # -- name: GetAuthor :one + def get_author(self, author_id: int) -> Optional[_models.Authors]: + parameters = { + "$author_id": (author_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("SELECT author_id, name\n" + "FROM authors\n" + "WHERE author_id = $author_id;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.Authors( + author_id=row[0], + name=row[1], + ) + finally: + cursor.close() + + # -- name: GetBook :one + def get_book(self, book_id: int) -> Optional[_models.Books]: + parameters = { + "$book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + "FROM books\n" + "WHERE book_id = $book_id;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.Books( + book_id=row[0], + author_id=row[1], + isbn=row[2], + book_type=row[3], + title=row[4], + publication_year=row[5], + available=row[6], + tags=row[7], + ) + finally: + cursor.close() + + # -- name: DeleteBook :exec + def delete_book(self, book_id: int) -> None: + parameters = { + "$book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DELETE FROM books\n" + "WHERE book_id = $book_id;"), + parameters, + ) + return None + finally: + cursor.close() + + # -- name: BooksByTitleYear :many + def books_by_title_year(self, title: str, publication_year: int) -> list[_models.Books]: + parameters = { + "$title": (title, _ydb.PrimitiveType.Utf8), + "$publication_year": (publication_year, _ydb.PrimitiveType.Int32), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + "FROM books\n" + "WHERE title = $title AND publication_year = $publication_year;"), + parameters, + ) + rows = cursor.fetchall() + return [_models.Books( + book_id=row[0], + author_id=row[1], + isbn=row[2], + book_type=row[3], + title=row[4], + publication_year=row[5], + available=row[6], + tags=row[7], + ) for row in rows] + finally: + cursor.close() + + # -- name: BooksByTags :many + def books_by_tags(self, tags: str) -> list[_models.BooksByTagsRow]: + parameters = { + "$tags": (tags, _ydb.PrimitiveType.Json), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DECLARE $tags AS Json;\n" + "SELECT\n" + " b.book_id,\n" + " b.title,\n" + " a.name,\n" + " b.isbn,\n" + " b.tags\n" + "FROM books AS b\n" + "LEFT JOIN authors AS a ON b.author_id = a.author_id\n" + "WHERE NOT SetIsDisjoint(\n" + " ToSet(Yson::ConvertToStringList(b.tags)),\n" + " Yson::ConvertToStringList($tags)\n" + ");"), + parameters, + ) + rows = cursor.fetchall() + return [_models.BooksByTagsRow( + book_id=row[0], + title=row[1], + name=row[2], + isbn=row[3], + tags=row[4], + ) for row in rows] + finally: + cursor.close() + + # -- name: CreateAuthor :one + def create_author(self, author_id: int, name: str) -> Optional[_models.Authors]: + parameters = { + "$author_id": (author_id, _ydb.PrimitiveType.Uint64), + "$name": (name, _ydb.PrimitiveType.Utf8), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("INSERT INTO authors (author_id, name)\n" + "VALUES ($author_id, $name)\n" + "RETURNING author_id, name;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.Authors( + author_id=row[0], + name=row[1], + ) + finally: + cursor.close() + + # -- name: CreateBook :one + def create_book(self, book_id: int, author_id: int, isbn: str, book_type: str, title: str, publication_year: int, available: datetime, tags: str) -> Optional[_models.Books]: + parameters = { + "$book_id": (book_id, _ydb.PrimitiveType.Uint64), + "$author_id": (author_id, _ydb.PrimitiveType.Uint64), + "$isbn": (isbn, _ydb.PrimitiveType.Utf8), + "$book_type": (book_type, _ydb.PrimitiveType.Utf8), + "$title": (title, _ydb.PrimitiveType.Utf8), + "$publication_year": (publication_year, _ydb.PrimitiveType.Int32), + "$available": (available, _ydb.PrimitiveType.Timestamp), + "$tags": (tags, _ydb.PrimitiveType.Json), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("INSERT INTO books (\n" + " book_id,\n" + " author_id,\n" + " isbn,\n" + " book_type,\n" + " title,\n" + " publication_year,\n" + " available,\n" + " tags\n" + ") VALUES (\n" + " $book_id,\n" + " $author_id,\n" + " $isbn,\n" + " $book_type,\n" + " $title,\n" + " $publication_year,\n" + " $available,\n" + " $tags\n" + ")\n" + "RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.Books( + book_id=row[0], + author_id=row[1], + isbn=row[2], + book_type=row[3], + title=row[4], + publication_year=row[5], + available=row[6], + tags=row[7], + ) + finally: + cursor.close() + + # -- name: UpdateBook :exec + def update_book(self, title: str, tags: str, book_id: int) -> None: + parameters = { + "$title": (title, _ydb.PrimitiveType.Utf8), + "$tags": (tags, _ydb.PrimitiveType.Json), + "$book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("UPDATE books\n" + "SET title = $title, tags = $tags\n" + "WHERE book_id = $book_id;"), + parameters, + ) + return None + finally: + cursor.close() + + # -- name: UpdateBookISBN :exec + def update_book_i_s_b_n(self, title: str, tags: str, isbn: str, book_id: int) -> None: + parameters = { + "$title": (title, _ydb.PrimitiveType.Utf8), + "$tags": (tags, _ydb.PrimitiveType.Json), + "$isbn": (isbn, _ydb.PrimitiveType.Utf8), + "$book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("UPDATE books\n" + "SET title = $title, tags = $tags, isbn = $isbn\n" + "WHERE book_id = $book_id;"), + parameters, + ) + return None + finally: + cursor.close() + + # -- name: DeleteAuthorBeforeYear :exec + def delete_author_before_year(self, publication_year: int, author_id: int) -> None: + parameters = { + "$publication_year": (publication_year, _ydb.PrimitiveType.Int32), + "$author_id": (author_id, _ydb.PrimitiveType.Uint64), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DELETE FROM books\n" + "WHERE publication_year < $publication_year AND author_id = $author_id;"), + parameters, + ) + return None + finally: + cursor.close() + + # -- name: SayHello :one + def say_hello(self, name: str) -> Optional[_models.SayHelloRow]: + parameters = { + "$name": (name, _ydb.PrimitiveType.Utf8), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("SELECT \"hello \"u || $name AS greeting;"), + parameters, + ) + row = cursor.fetchone() + if row is None: + return None + return _models.SayHelloRow( + greeting=row[0], + ) + finally: + cursor.close() + + # -- name: ListAuthorsWithRecentBooks :many + def list_authors_with_recent_books(self, since_year: int) -> list[_models.Authors]: + parameters = { + "$since_year": (since_year, _ydb.PrimitiveType.Int32), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("SELECT a.author_id, a.name\n" + "FROM authors AS a\n" + "WHERE a.author_id IN (\n" + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n" + ")\n" + "ORDER BY a.author_id;"), + parameters, + ) + rows = cursor.fetchall() + return [_models.Authors( + author_id=row[0], + name=row[1], + ) for row in rows] + finally: + cursor.close() + + # -- name: ListBooksWithRecentEditions :many + def list_books_with_recent_editions(self, since_year: int) -> list[_models.Books]: + parameters = { + "$since_year": (since_year, _ydb.PrimitiveType.Int32), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DECLARE $since_year AS Int32;\n" + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + "FROM books AS b\n" + "WHERE (b.author_id, b.book_type) IN (\n" + " SELECT (recent.author_id, recent.book_type)\n" + " FROM books AS recent\n" + " WHERE recent.publication_year >= $since_year\n" + ")\n" + "ORDER BY b.book_id;"), + parameters, + ) + rows = cursor.fetchall() + return [_models.Books( + book_id=row[0], + author_id=row[1], + isbn=row[2], + book_type=row[3], + title=row[4], + publication_year=row[5], + available=row[6], + tags=row[7], + ) for row in rows] + finally: + cursor.close() + + # -- name: DeleteBooksByAuthorName :exec + def delete_books_by_author_name(self, author_name: str) -> None: + parameters = { + "$author_name": (author_name, _ydb.PrimitiveType.Utf8), + } + cursor = self._connection.cursor() + try: + cursor.execute( + ("DELETE FROM books\n" + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);"), + parameters, + ) + return None + finally: + cursor.close() diff --git a/examples/booktest/python/native/__init__.py b/examples/booktest/python/native/__init__.py new file mode 100644 index 00000000..f7188d56 --- /dev/null +++ b/examples/booktest/python/native/__init__.py @@ -0,0 +1 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. diff --git a/examples/booktest/python/native/models.py b/examples/booktest/python/native/models.py new file mode 100644 index 00000000..36d8c43d --- /dev/null +++ b/examples/booktest/python/native/models.py @@ -0,0 +1,37 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. +from dataclasses import dataclass +from datetime import date, datetime, timedelta +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: + author_id: int + name: str + +@dataclass +class Books: + book_id: int + author_id: int + isbn: str + book_type: str + title: str + publication_year: int + available: datetime + tags: JSONValue + +@dataclass +class BooksByTagsRow: + book_id: int + title: str + name: Optional[str] + isbn: str + tags: JSONValue + +@dataclass +class SayHelloRow: + greeting: str diff --git a/examples/booktest/python/native/queries.py b/examples/booktest/python/native/queries.py new file mode 100644 index 00000000..1bf3db61 --- /dev/null +++ b/examples/booktest/python/native/queries.py @@ -0,0 +1,344 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. +from __future__ import annotations +from typing import Optional, Union +from datetime import datetime +from . import models as _models +import ydb as _ydb + + +class Querier: + def __init__(self, executor: Union[_ydb.QuerySessionPool, _ydb.QueryTxContext], *, retry_settings: Optional[_ydb.RetrySettings] = None): + if retry_settings is not None and not isinstance(executor, _ydb.QuerySessionPool): + raise ValueError("retry_settings belongs to the pool; configure retries around the whole transaction") + self._executor = executor + self._retry_settings = retry_settings + if isinstance(executor, _ydb.QuerySessionPool) and retry_settings is None: + self._retry_settings = _ydb.RetrySettings(max_retries=0) + + def _execute(self, query: str, parameters: dict): + if isinstance(self._executor, _ydb.QuerySessionPool): + return self._executor.execute_with_retries(query, parameters, retry_settings=self._retry_settings) + return list(self._executor.execute(query, parameters)) + + # -- name: GetAuthor :one + def get_author(self, author_id: int) -> Optional[_models.Authors]: + parameters = { + "$author_id": _ydb.TypedValue(author_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("SELECT author_id, name\n" + "FROM authors\n" + "WHERE author_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.Authors( + author_id=row["author_id"], + name=row["name"], + ) + + # -- name: GetBook :one + def get_book(self, book_id: int) -> Optional[_models.Books]: + parameters = { + "$book_id": _ydb.TypedValue(book_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + "FROM books\n" + "WHERE book_id = $book_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.Books( + book_id=row["book_id"], + author_id=row["author_id"], + isbn=row["isbn"], + book_type=row["book_type"], + title=row["title"], + publication_year=row["publication_year"], + available=row["available"], + tags=row["tags"], + ) + + # -- name: DeleteBook :exec + def delete_book(self, book_id: int) -> None: + parameters = { + "$book_id": _ydb.TypedValue(book_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("DELETE FROM books\n" + "WHERE book_id = $book_id;"), + parameters, + ) + return None + + # -- name: BooksByTitleYear :many + def books_by_title_year(self, title: str, publication_year: int) -> list[_models.Books]: + parameters = { + "$title": _ydb.TypedValue(title, _ydb.PrimitiveType.Utf8), + "$publication_year": _ydb.TypedValue(publication_year, _ydb.PrimitiveType.Int32), + } + result_sets = self._execute( + ("SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + "FROM books\n" + "WHERE title = $title AND publication_year = $publication_year;"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + return [_models.Books( + book_id=row["book_id"], + author_id=row["author_id"], + isbn=row["isbn"], + book_type=row["book_type"], + title=row["title"], + publication_year=row["publication_year"], + available=row["available"], + tags=row["tags"], + ) for row in rows] + + # -- name: BooksByTags :many + def books_by_tags(self, tags: str) -> list[_models.BooksByTagsRow]: + parameters = { + "$tags": _ydb.TypedValue(tags, _ydb.PrimitiveType.Json), + } + result_sets = self._execute( + ("DECLARE $tags AS Json;\n" + "SELECT\n" + " b.book_id,\n" + " b.title,\n" + " a.name,\n" + " b.isbn,\n" + " b.tags\n" + "FROM books AS b\n" + "LEFT JOIN authors AS a ON b.author_id = a.author_id\n" + "WHERE NOT SetIsDisjoint(\n" + " ToSet(Yson::ConvertToStringList(b.tags)),\n" + " Yson::ConvertToStringList($tags)\n" + ");"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + return [_models.BooksByTagsRow( + book_id=row["b.book_id"], + title=row["b.title"], + name=row["a.name"], + isbn=row["b.isbn"], + tags=row["b.tags"], + ) for row in rows] + + # -- name: CreateAuthor :one + def create_author(self, author_id: int, name: str) -> Optional[_models.Authors]: + parameters = { + "$author_id": _ydb.TypedValue(author_id, _ydb.PrimitiveType.Uint64), + "$name": _ydb.TypedValue(name, _ydb.PrimitiveType.Utf8), + } + result_sets = self._execute( + ("INSERT INTO authors (author_id, name)\n" + "VALUES ($author_id, $name)\n" + "RETURNING author_id, name;"), + 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.Authors( + author_id=row["author_id"], + name=row["name"], + ) + + # -- name: CreateBook :one + def create_book(self, book_id: int, author_id: int, isbn: str, book_type: str, title: str, publication_year: int, available: datetime, tags: str) -> Optional[_models.Books]: + parameters = { + "$book_id": _ydb.TypedValue(book_id, _ydb.PrimitiveType.Uint64), + "$author_id": _ydb.TypedValue(author_id, _ydb.PrimitiveType.Uint64), + "$isbn": _ydb.TypedValue(isbn, _ydb.PrimitiveType.Utf8), + "$book_type": _ydb.TypedValue(book_type, _ydb.PrimitiveType.Utf8), + "$title": _ydb.TypedValue(title, _ydb.PrimitiveType.Utf8), + "$publication_year": _ydb.TypedValue(publication_year, _ydb.PrimitiveType.Int32), + "$available": _ydb.TypedValue(available, _ydb.PrimitiveType.Timestamp), + "$tags": _ydb.TypedValue(tags, _ydb.PrimitiveType.Json), + } + result_sets = self._execute( + ("INSERT INTO books (\n" + " book_id,\n" + " author_id,\n" + " isbn,\n" + " book_type,\n" + " title,\n" + " publication_year,\n" + " available,\n" + " tags\n" + ") VALUES (\n" + " $book_id,\n" + " $author_id,\n" + " $isbn,\n" + " $book_type,\n" + " $title,\n" + " $publication_year,\n" + " $available,\n" + " $tags\n" + ")\n" + "RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;"), + 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.Books( + book_id=row["book_id"], + author_id=row["author_id"], + isbn=row["isbn"], + book_type=row["book_type"], + title=row["title"], + publication_year=row["publication_year"], + available=row["available"], + tags=row["tags"], + ) + + # -- name: UpdateBook :exec + def update_book(self, title: str, tags: str, book_id: int) -> None: + parameters = { + "$title": _ydb.TypedValue(title, _ydb.PrimitiveType.Utf8), + "$tags": _ydb.TypedValue(tags, _ydb.PrimitiveType.Json), + "$book_id": _ydb.TypedValue(book_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("UPDATE books\n" + "SET title = $title, tags = $tags\n" + "WHERE book_id = $book_id;"), + parameters, + ) + return None + + # -- name: UpdateBookISBN :exec + def update_book_i_s_b_n(self, title: str, tags: str, isbn: str, book_id: int) -> None: + parameters = { + "$title": _ydb.TypedValue(title, _ydb.PrimitiveType.Utf8), + "$tags": _ydb.TypedValue(tags, _ydb.PrimitiveType.Json), + "$isbn": _ydb.TypedValue(isbn, _ydb.PrimitiveType.Utf8), + "$book_id": _ydb.TypedValue(book_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("UPDATE books\n" + "SET title = $title, tags = $tags, isbn = $isbn\n" + "WHERE book_id = $book_id;"), + parameters, + ) + return None + + # -- name: DeleteAuthorBeforeYear :exec + def delete_author_before_year(self, publication_year: int, author_id: int) -> None: + parameters = { + "$publication_year": _ydb.TypedValue(publication_year, _ydb.PrimitiveType.Int32), + "$author_id": _ydb.TypedValue(author_id, _ydb.PrimitiveType.Uint64), + } + result_sets = self._execute( + ("DELETE FROM books\n" + "WHERE publication_year < $publication_year AND author_id = $author_id;"), + parameters, + ) + return None + + # -- name: SayHello :one + def say_hello(self, name: str) -> Optional[_models.SayHelloRow]: + parameters = { + "$name": _ydb.TypedValue(name, _ydb.PrimitiveType.Utf8), + } + result_sets = self._execute( + ("SELECT \"hello \"u || $name AS greeting;"), + 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.SayHelloRow( + greeting=row["greeting"], + ) + + # -- name: ListAuthorsWithRecentBooks :many + def list_authors_with_recent_books(self, since_year: int) -> list[_models.Authors]: + parameters = { + "$since_year": _ydb.TypedValue(since_year, _ydb.PrimitiveType.Int32), + } + result_sets = self._execute( + ("SELECT a.author_id, a.name\n" + "FROM authors AS a\n" + "WHERE a.author_id IN (\n" + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n" + ")\n" + "ORDER BY a.author_id;"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + return [_models.Authors( + author_id=row["author_id"], + name=row["name"], + ) for row in rows] + + # -- name: ListBooksWithRecentEditions :many + def list_books_with_recent_editions(self, since_year: int) -> list[_models.Books]: + parameters = { + "$since_year": _ydb.TypedValue(since_year, _ydb.PrimitiveType.Int32), + } + result_sets = self._execute( + ("DECLARE $since_year AS Int32;\n" + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + "FROM books AS b\n" + "WHERE (b.author_id, b.book_type) IN (\n" + " SELECT (recent.author_id, recent.book_type)\n" + " FROM books AS recent\n" + " WHERE recent.publication_year >= $since_year\n" + ")\n" + "ORDER BY b.book_id;"), + parameters, + ) + if len(result_sets) != 1: + raise ValueError("expected exactly one YDB result set") + rows = result_sets[0].rows + return [_models.Books( + book_id=row["book_id"], + author_id=row["author_id"], + isbn=row["isbn"], + book_type=row["book_type"], + title=row["title"], + publication_year=row["publication_year"], + available=row["available"], + tags=row["tags"], + ) for row in rows] + + # -- name: DeleteBooksByAuthorName :exec + def delete_books_by_author_name(self, author_name: str) -> None: + parameters = { + "$author_name": _ydb.TypedValue(author_name, _ydb.PrimitiveType.Utf8), + } + result_sets = self._execute( + ("DELETE FROM books\n" + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);"), + parameters, + ) + return None diff --git a/examples/booktest/python/sqlalchemy/__init__.py b/examples/booktest/python/sqlalchemy/__init__.py new file mode 100644 index 00000000..f7188d56 --- /dev/null +++ b/examples/booktest/python/sqlalchemy/__init__.py @@ -0,0 +1 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. diff --git a/examples/booktest/python/sqlalchemy/models.py b/examples/booktest/python/sqlalchemy/models.py new file mode 100644 index 00000000..fcafeb64 --- /dev/null +++ b/examples/booktest/python/sqlalchemy/models.py @@ -0,0 +1,33 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from uuid import UUID +from typing import Optional + +@dataclass +class Authors: + author_id: int + name: str + +@dataclass +class Books: + book_id: int + author_id: int + isbn: str + book_type: str + title: str + publication_year: int + available: datetime + tags: str + +@dataclass +class BooksByTagsRow: + book_id: int + title: str + name: Optional[str] + isbn: str + tags: str + +@dataclass +class SayHelloRow: + greeting: str diff --git a/examples/booktest/python/sqlalchemy/queries.py b/examples/booktest/python/sqlalchemy/queries.py new file mode 100644 index 00000000..117f82b8 --- /dev/null +++ b/examples/booktest/python/sqlalchemy/queries.py @@ -0,0 +1,373 @@ +# Code generated by sqlc-ydb. DO NOT EDIT. +from __future__ import annotations +from typing import Optional +from datetime import datetime +from . import models as _models +import ydb as _ydb +from sqlalchemy import text as _text +from sqlalchemy.engine import Connection + + +class Querier: + def __init__(self, connection: Connection): + self._connection = connection + + # -- name: GetAuthor :one + def get_author(self, author_id: int) -> Optional[_models.Authors]: + parameters = { + "author_id": (author_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("SELECT author_id, name\n" + "FROM authors\n" + "WHERE author_id = :author_id;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.Authors( + author_id=row._mapping["author_id"], + name=row._mapping["name"], + ) + + # -- name: GetBook :one + def get_book(self, book_id: int) -> Optional[_models.Books]: + parameters = { + "book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + "FROM books\n" + "WHERE book_id = :book_id;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.Books( + book_id=row._mapping["book_id"], + author_id=row._mapping["author_id"], + isbn=row._mapping["isbn"], + book_type=row._mapping["book_type"], + title=row._mapping["title"], + publication_year=row._mapping["publication_year"], + available=row._mapping["available"], + tags=row._mapping["tags"], + ) + + # -- name: DeleteBook :exec + def delete_book(self, book_id: int) -> None: + parameters = { + "book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("DELETE FROM books\n" + "WHERE book_id = :book_id;") + ), + parameters, + ) + result.close() + return None + + # -- name: BooksByTitleYear :many + def books_by_title_year(self, title: str, publication_year: int) -> list[_models.Books]: + parameters = { + "title": (title, _ydb.PrimitiveType.Utf8), + "publication_year": (publication_year, _ydb.PrimitiveType.Int32), + } + result = self._connection.execute( + _text( + ("SELECT book_id, author_id, isbn, book_type, title, publication_year, available, tags\n" + "FROM books\n" + "WHERE title = :title AND publication_year = :publication_year;") + ), + parameters, + ) + try: + rows = result.fetchall() + finally: + result.close() + return [_models.Books( + book_id=row._mapping["book_id"], + author_id=row._mapping["author_id"], + isbn=row._mapping["isbn"], + book_type=row._mapping["book_type"], + title=row._mapping["title"], + publication_year=row._mapping["publication_year"], + available=row._mapping["available"], + tags=row._mapping["tags"], + ) for row in rows] + + # -- name: BooksByTags :many + def books_by_tags(self, tags: str) -> list[_models.BooksByTagsRow]: + parameters = { + "tags": (tags, _ydb.PrimitiveType.Json), + } + result = self._connection.execute( + _text( + ("DECLARE $tags AS Json;\n" + "SELECT\n" + " b.book_id,\n" + " b.title,\n" + " a.name,\n" + " b.isbn,\n" + " b.tags\n" + "FROM books AS b\n" + "LEFT JOIN authors AS a ON b.author_id = a.author_id\n" + "WHERE NOT SetIsDisjoint(\n" + " ToSet(Yson\\:\\:ConvertToStringList(b.tags)),\n" + " Yson\\:\\:ConvertToStringList(:tags)\n" + ");") + ), + parameters, + ) + try: + rows = result.fetchall() + finally: + result.close() + return [_models.BooksByTagsRow( + book_id=row._mapping["b.book_id"], + title=row._mapping["b.title"], + name=row._mapping["a.name"], + isbn=row._mapping["b.isbn"], + tags=row._mapping["b.tags"], + ) for row in rows] + + # -- name: CreateAuthor :one + def create_author(self, author_id: int, name: str) -> Optional[_models.Authors]: + parameters = { + "author_id": (author_id, _ydb.PrimitiveType.Uint64), + "name": (name, _ydb.PrimitiveType.Utf8), + } + result = self._connection.execute( + _text( + ("INSERT INTO authors (author_id, name)\n" + "VALUES (:author_id, :name)\n" + "RETURNING author_id, name;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.Authors( + author_id=row._mapping["author_id"], + name=row._mapping["name"], + ) + + # -- name: CreateBook :one + def create_book(self, book_id: int, author_id: int, isbn: str, book_type: str, title: str, publication_year: int, available: datetime, tags: str) -> Optional[_models.Books]: + parameters = { + "book_id": (book_id, _ydb.PrimitiveType.Uint64), + "author_id": (author_id, _ydb.PrimitiveType.Uint64), + "isbn": (isbn, _ydb.PrimitiveType.Utf8), + "book_type": (book_type, _ydb.PrimitiveType.Utf8), + "title": (title, _ydb.PrimitiveType.Utf8), + "publication_year": (publication_year, _ydb.PrimitiveType.Int32), + "available": (available, _ydb.PrimitiveType.Timestamp), + "tags": (tags, _ydb.PrimitiveType.Json), + } + result = self._connection.execute( + _text( + ("INSERT INTO books (\n" + " book_id,\n" + " author_id,\n" + " isbn,\n" + " book_type,\n" + " title,\n" + " publication_year,\n" + " available,\n" + " tags\n" + ") VALUES (\n" + " :book_id,\n" + " :author_id,\n" + " :isbn,\n" + " :book_type,\n" + " :title,\n" + " :publication_year,\n" + " :available,\n" + " :tags\n" + ")\n" + "RETURNING book_id, author_id, isbn, book_type, title, publication_year, available, tags;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.Books( + book_id=row._mapping["book_id"], + author_id=row._mapping["author_id"], + isbn=row._mapping["isbn"], + book_type=row._mapping["book_type"], + title=row._mapping["title"], + publication_year=row._mapping["publication_year"], + available=row._mapping["available"], + tags=row._mapping["tags"], + ) + + # -- name: UpdateBook :exec + def update_book(self, title: str, tags: str, book_id: int) -> None: + parameters = { + "title": (title, _ydb.PrimitiveType.Utf8), + "tags": (tags, _ydb.PrimitiveType.Json), + "book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("UPDATE books\n" + "SET title = :title, tags = :tags\n" + "WHERE book_id = :book_id;") + ), + parameters, + ) + result.close() + return None + + # -- name: UpdateBookISBN :exec + def update_book_i_s_b_n(self, title: str, tags: str, isbn: str, book_id: int) -> None: + parameters = { + "title": (title, _ydb.PrimitiveType.Utf8), + "tags": (tags, _ydb.PrimitiveType.Json), + "isbn": (isbn, _ydb.PrimitiveType.Utf8), + "book_id": (book_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("UPDATE books\n" + "SET title = :title, tags = :tags, isbn = :isbn\n" + "WHERE book_id = :book_id;") + ), + parameters, + ) + result.close() + return None + + # -- name: DeleteAuthorBeforeYear :exec + def delete_author_before_year(self, publication_year: int, author_id: int) -> None: + parameters = { + "publication_year": (publication_year, _ydb.PrimitiveType.Int32), + "author_id": (author_id, _ydb.PrimitiveType.Uint64), + } + result = self._connection.execute( + _text( + ("DELETE FROM books\n" + "WHERE publication_year < :publication_year AND author_id = :author_id;") + ), + parameters, + ) + result.close() + return None + + # -- name: SayHello :one + def say_hello(self, name: str) -> Optional[_models.SayHelloRow]: + parameters = { + "name": (name, _ydb.PrimitiveType.Utf8), + } + result = self._connection.execute( + _text( + ("SELECT \"hello \"u || :name AS greeting;") + ), + parameters, + ) + try: + row = result.fetchone() + finally: + result.close() + if row is None: + return None + return _models.SayHelloRow( + greeting=row._mapping["greeting"], + ) + + # -- name: ListAuthorsWithRecentBooks :many + def list_authors_with_recent_books(self, since_year: int) -> list[_models.Authors]: + parameters = { + "since_year": (since_year, _ydb.PrimitiveType.Int32), + } + result = self._connection.execute( + _text( + ("SELECT a.author_id, a.name\n" + "FROM authors AS a\n" + "WHERE a.author_id IN (\n" + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= :since_year\n" + ")\n" + "ORDER BY a.author_id;") + ), + parameters, + ) + try: + rows = result.fetchall() + finally: + result.close() + return [_models.Authors( + author_id=row._mapping["author_id"], + name=row._mapping["name"], + ) for row in rows] + + # -- name: ListBooksWithRecentEditions :many + def list_books_with_recent_editions(self, since_year: int) -> list[_models.Books]: + parameters = { + "since_year": (since_year, _ydb.PrimitiveType.Int32), + } + result = self._connection.execute( + _text( + ("DECLARE $since_year AS Int32;\n" + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + "FROM books AS b\n" + "WHERE (b.author_id, b.book_type) IN (\n" + " SELECT (recent.author_id, recent.book_type)\n" + " FROM books AS recent\n" + " WHERE recent.publication_year >= :since_year\n" + ")\n" + "ORDER BY b.book_id;") + ), + parameters, + ) + try: + rows = result.fetchall() + finally: + result.close() + return [_models.Books( + book_id=row._mapping["book_id"], + author_id=row._mapping["author_id"], + isbn=row._mapping["isbn"], + book_type=row._mapping["book_type"], + title=row._mapping["title"], + publication_year=row._mapping["publication_year"], + available=row._mapping["available"], + tags=row._mapping["tags"], + ) for row in rows] + + # -- name: DeleteBooksByAuthorName :exec + def delete_books_by_author_name(self, author_name: str) -> None: + parameters = { + "author_name": (author_name, _ydb.PrimitiveType.Utf8), + } + result = self._connection.execute( + _text( + ("DELETE FROM books\n" + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = :author_name);") + ), + parameters, + ) + result.close() + return None diff --git a/examples/booktest/queries.sql b/examples/booktest/queries.sql index a792048c..3c55d325 100644 --- a/examples/booktest/queries.sql +++ b/examples/booktest/queries.sql @@ -75,3 +75,26 @@ WHERE publication_year < $publication_year AND author_id = $author_id; -- name: SayHello :one SELECT "hello "u || $name AS greeting; + +-- name: ListAuthorsWithRecentBooks :many +SELECT a.author_id, a.name +FROM authors AS a +WHERE a.author_id IN ( + SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year +) +ORDER BY a.author_id; + +-- name: ListBooksWithRecentEditions :many +DECLARE $since_year AS Int32; +SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags +FROM books AS b +WHERE (b.author_id, b.book_type) IN ( + SELECT (recent.author_id, recent.book_type) + FROM books AS recent + WHERE recent.publication_year >= $since_year +) +ORDER BY b.book_id; + +-- name: DeleteBooksByAuthorName :exec +DELETE FROM books +WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name); diff --git a/examples/booktest/rust/native/models.rs b/examples/booktest/rust/native/models.rs index d107e343..6174f124 100644 --- a/examples/booktest/rust/native/models.rs +++ b/examples/booktest/rust/native/models.rs @@ -61,3 +61,21 @@ pub struct CreateBookRow { pub struct SayHelloRow { pub greeting: String, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ListAuthorsWithRecentBooksRow { + pub author_id: u64, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ListBooksWithRecentEditionsRow { + pub book_id: u64, + pub author_id: u64, + pub isbn: String, + pub book_type: String, + pub title: String, + pub publication_year: i32, + pub available: std::time::SystemTime, + pub tags: String, +} diff --git a/examples/booktest/rust/native/queries.rs b/examples/booktest/rust/native/queries.rs index e6ae5b78..54c4134c 100644 --- a/examples/booktest/rust/native/queries.rs +++ b/examples/booktest/rust/native/queries.rs @@ -293,4 +293,79 @@ impl<'a, E: ydb::QueryExecutor> Queries<'a, E> { greeting: row.remove_field(0)?.try_into()?, }) } + + // -- name: ListAuthorsWithRecentBooks :many + #[builder(on(String, into))] + pub async fn list_authors_with_recent_books( + &mut self, + since_year: i32, + ) -> ydb::YdbResult> { + self.client + .query_result_set(concat!( + "SELECT a.author_id, a.name\n", + "FROM authors AS a\n", + "WHERE a.author_id IN (\n", + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n", + ")\n", + "ORDER BY a.author_id;", + )) + .param("$since_year", since_year) + .await? + .rows() + .map(|mut row| { + Ok(ListAuthorsWithRecentBooksRow { + author_id: row.remove_field(0)?.try_into()?, + name: row.remove_field(1)?.try_into()?, + }) + }) + .collect() + } + + // -- name: ListBooksWithRecentEditions :many + #[builder(on(String, into))] + pub async fn list_books_with_recent_editions( + &mut self, + since_year: i32, + ) -> ydb::YdbResult> { + self.client + .query_result_set(concat!( + "DECLARE $since_year AS Int32;\n", + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n", + "FROM books AS b\n", + "WHERE (b.author_id, b.book_type) IN (\n", + " SELECT (recent.author_id, recent.book_type)\n", + " FROM books AS recent\n", + " WHERE recent.publication_year >= $since_year\n", + ")\n", + "ORDER BY b.book_id;", + )) + .param("$since_year", since_year) + .await? + .rows() + .map(|mut row| { + Ok(ListBooksWithRecentEditionsRow { + book_id: row.remove_field(0)?.try_into()?, + author_id: row.remove_field(1)?.try_into()?, + isbn: row.remove_field(2)?.try_into()?, + book_type: row.remove_field(3)?.try_into()?, + title: row.remove_field(4)?.try_into()?, + publication_year: row.remove_field(5)?.try_into()?, + available: row.remove_field(6)?.try_into()?, + tags: row.remove_field(7)?.try_into()?, + }) + }) + .collect() + } + + // -- name: DeleteBooksByAuthorName :exec + #[builder(on(String, into))] + pub async fn delete_books_by_author_name(&mut self, author_name: String) -> ydb::YdbResult<()> { + self.client + .exec(concat!( + "DELETE FROM books\n", + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);", + )) + .param("$author_name", author_name) + .await + } } diff --git a/examples/booktest/sqlc.yaml b/examples/booktest/sqlc.yaml index 2cc6081a..8ea57d4b 100644 --- a/examples/booktest/sqlc.yaml +++ b/examples/booktest/sqlc.yaml @@ -5,6 +5,17 @@ sql: schema: schema.sql queries: queries.sql gen: + python: + out: python/native + runtime: ydb + csharp: + namespace: Booktest.AdoNet + out: csharp/adonet + runtime: adonet + java: + package: booktest.nativeapi + out: java/native + runtime: ydb kotlin: package: booktest.nativeapi out: kotlin/nativeapi @@ -31,6 +42,17 @@ sql: schema: schema.sql queries: queries.sql gen: + python: + out: python/dbapi + runtime: dbapi + java: + package: booktest.jdbc + out: java/jdbc + runtime: jdbc + kotlin: + package: booktest.jdbc + out: kotlin/jdbc + runtime: jdbc go: package: booktest out: go/database/sql @@ -71,3 +93,21 @@ sql: namespace: booktest::userver out: cpp/userver runtime: userver + - name: exposed + engine: ydb + schema: schema.sql + queries: queries.sql + gen: + kotlin: + package: booktest.exposed + out: kotlin/exposed + runtime: exposed + - name: sqlalchemy + engine: ydb + schema: schema.sql + queries: queries.sql + gen: + python: + out: python/sqlalchemy + runtime: sqlalchemy + emit_sync_querier: true diff --git a/examples/booktest/typescript/native/queries.ts b/examples/booktest/typescript/native/queries.ts index 89b28d5d..fd8bd58a 100644 --- a/examples/booktest/typescript/native/queries.ts +++ b/examples/booktest/typescript/native/queries.ts @@ -99,6 +99,22 @@ export type SayHelloRow = { readonly greeting: string; }; +export type ListAuthorsWithRecentBooksRow = { + readonly author_id: bigint; + readonly name: string; +}; + +export type ListBooksWithRecentEditionsRow = { + readonly book_id: bigint; + readonly author_id: bigint; + readonly isbn: string; + readonly book_type: string; + readonly title: string; + readonly publication_year: number; + readonly available: Date; + readonly tags: JSValue; +}; + export class Queries { readonly #sql: SQL; @@ -293,4 +309,55 @@ export class Queries { return rows[0] ?? null; } + + // -- name: ListAuthorsWithRecentBooks :many + async listAuthorsWithRecentBooks(sinceYear: number, configure?: ConfigureQuery): Promise { + const stmt = this.#sql<[ListAuthorsWithRecentBooksRow]>( + "SELECT a.author_id, a.name\n" + + "FROM authors AS a\n" + + "WHERE a.author_id IN (\n" + + " SELECT b.author_id FROM books AS b WHERE b.publication_year >= $since_year\n" + + ")\n" + + "ORDER BY a.author_id;" + ) + .parameter("since_year", new Int32(sinceYear)); + configure?.(stmt); + const [rows] = await stmt; + + return rows; + } + + // -- name: ListBooksWithRecentEditions :many + async listBooksWithRecentEditions(sinceYear: number, configure?: ConfigureQuery): Promise { + const stmt = this.#sql<[ListBooksWithRecentEditionsRow]>( + "DECLARE $since_year AS Int32;\n" + + "SELECT b.book_id, b.author_id, b.isbn, b.book_type, b.title, b.publication_year, b.available, b.tags\n" + + "FROM books AS b\n" + + "WHERE (b.author_id, b.book_type) IN (\n" + + " SELECT (recent.author_id, recent.book_type)\n" + + " FROM books AS recent\n" + + " WHERE recent.publication_year >= $since_year\n" + + ")\n" + + "ORDER BY b.book_id;" + ); + // Keep explicit DECLARE statements; the SDK otherwise prepends duplicates. + Object.defineProperty(stmt, "text", { value: stmt.text, writable: false }); + stmt + .parameter("since_year", new Int32(sinceYear)); + configure?.(stmt); + const [rows] = await stmt; + + return rows; + } + + // -- name: DeleteBooksByAuthorName :exec + async deleteBooksByAuthorName(authorName: string, configure?: ConfigureQuery): Promise { + const stmt = this.#sql( + "DELETE FROM books\n" + + "WHERE author_id IN (SELECT author_id FROM authors WHERE name = $author_name);" + ) + .parameter("author_name", new Utf8(authorName)); + configure?.(stmt); + await stmt; + } } diff --git a/internal/analyzer/dml_test.go b/internal/analyzer/dml_test.go index 924b2bdf..dc1244c0 100644 --- a/internal/analyzer/dml_test.go +++ b/internal/analyzer/dml_test.go @@ -64,10 +64,9 @@ 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>"}, - {"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\""}, + {"in subquery", ":many", "SELECT id FROM records WHERE id IN (SELECT r.id FROM records r UNION SELECT r.id FROM records r);", "CTEs, UNION and INTERSECT are unsupported"}, {"array expression", ":one", "SELECT [1, 2] AS values;", "unsupported result expression"}, - {"exists expression", ":one", "SELECT EXISTS (SELECT id FROM records) AS present;", "computed result expression"}, + {"exists expression", ":one", "SELECT EXISTS (SELECT id FROM records) AS present;", "unsupported result expression"}, } { t.Run(tt.name, func(t *testing.T) { got, err := Analyze(schema, []model.Source{{Name: "query.sql", Text: "-- name: Invalid " + tt.command + "\n" + tt.sql}}) diff --git a/internal/analyzer/expression.go b/internal/analyzer/expression.go index f63ba545..aec4af83 100644 --- a/internal/analyzer/expression.go +++ b/internal/analyzer/expression.go @@ -11,10 +11,11 @@ import ( ) type expressionScope struct { - relations []relation - bindings map[string]model.Type - grouped bool - functions *builtins.Registry + relations []relation + bindings map[string]model.Type + grouped bool + functions *builtins.Registry + inSubqueries map[int]model.Type } func resolveExpression(expr parser.IExprContext, scope expressionScope) (model.Type, error) { @@ -100,6 +101,9 @@ func resolveComparison(expr antlr.ParserRuleContext, scope expressionScope) (mod return model.Type{Kind: "Bool"}, true, nil } if condition.IN() != nil { + if inSubquery(condition.In_expr()) != nil { + return model.Type{}, true, fmt.Errorf("IN subqueries are supported only in WHERE predicates; they are not yet supported in projections, CASE, IF, or HAVING") + } 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()) diff --git a/internal/analyzer/in_subquery.go b/internal/analyzer/in_subquery.go new file mode 100644 index 00000000..fd1a3b72 --- /dev/null +++ b/internal/analyzer/in_subquery.go @@ -0,0 +1,239 @@ +package analyzer + +import ( + "fmt" + + "github.com/antlr4-go/antlr/v4" + "github.com/ydb-platform/sqlc-ydb/internal/model" + "github.com/ydb-platform/sqlc-ydb/internal/yql/builtins" + parser "github.com/ydb-platform/yql-parsers/go" +) + +// Each SELECT resolves its own columns, parameters and aggregates. The outer +// pass must not interpret a nested SELECT using the outer relation set. +func scopeDescendants(root antlr.Tree, visit func(antlr.Tree)) { + if root == nil { + return + } + visit(root) + for _, child := range root.GetChildren() { + if _, nested := child.(*parser.Select_kind_partialContext); nested { + continue + } + scopeDescendants(child, visit) + } +} + +func inSubquery(expr parser.IIn_exprContext) parser.ISelect_subexprContext { + if expr.In_unary_subexpr().In_unary_casual_subexpr() == nil { + return nil + } + casual := expr.In_unary_subexpr().In_unary_casual_subexpr() + if casual.Unary_subexpr_suffix().GetText() != "" || casual.In_atom_expr() == nil || casual.In_atom_expr().Lambda() == nil { + return nil + } + lambda := casual.In_atom_expr().Lambda() + if lambda.ARROW() != nil { + return nil + } + sub := lambda.Smart_parenthesis().Select_subexpr() + if sub == nil { + return nil + } + for _, intersect := range sub.Select_subexpr_core().AllSelect_subexpr_intersect() { + for _, source := range intersect.AllSelect_or_expr() { + if source.Select_kind_partial() != nil { + return sub + } + } + } + return nil +} + +func containsINSubquery(root antlr.Tree) bool { + found := false + descendants(root, func(node antlr.Tree) { + if in, ok := node.(*parser.In_exprContext); ok && inSubquery(in) != nil { + found = true + } + }) + return found +} + +func validateINSubqueryContexts(block queryBlock, root antlr.Tree) []model.Diagnostic { + var diagnostics []model.Diagnostic + descendants(root, func(node antlr.Tree) { + in, ok := node.(*parser.In_exprContext) + if !ok || inSubquery(in) == nil { + return + } + allowed := false + message := "IN subqueries are supported only in WHERE predicates; they are not yet supported in projections, CASE, IF, or HAVING" + context: + for child, parent := antlr.Tree(in), in.GetParent(); parent != nil; child, parent = parent, parent.GetParent() { + switch ctx := parent.(type) { + case *parser.Case_exprContext, *parser.Invoke_exprContext, *parser.Cast_exprContext, *parser.Bitcast_exprContext: + break context + case *parser.Join_constraintContext: + message = "IN subqueries are supported only in WHERE predicates; JOIN ON membership is unsupported" + break context + case *parser.Select_coreContext: + allowed = ctx.WHERE() != nil && child == ctx.Expr(0) + break context + case *parser.Update_stmtContext: + allowed = ctx.WHERE() != nil && child == ctx.Expr() + break context + case *parser.Delete_stmtContext: + allowed = ctx.WHERE() != nil && child == ctx.Expr() + break context + } + } + if !allowed { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, in, message)) + } + }) + return diagnostics +} + +func analyzeINSubqueries(catalog model.Catalog, block queryBlock, root antlr.Tree, outer []relation, bindings, inferred map[string]model.Type, syntax *model.QuerySyntax) (map[int]model.Type, []model.Diagnostic) { + types := map[int]model.Type{} + var diagnostics []model.Diagnostic + scopeDescendants(root, func(node antlr.Tree) { + in, ok := node.(*parser.In_exprContext) + if !ok { + return + } + sub := inSubquery(in) + if sub == nil { + return + } + intersects := sub.Select_subexpr_core().AllSelect_subexpr_intersect() + if sub.Cte_with_clause() != nil || len(intersects) != 1 || len(intersects[0].AllSelect_or_expr()) != 1 { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, in, "IN subqueries currently require one SELECT; CTEs, UNION and INTERSECT are unsupported")) + return + } + partial := intersects[0].Select_or_expr(0).Select_kind_partial() + kind := partial.Select_kind() + core, ok := kind.Select_core().(*parser.Select_coreContext) + if !ok || kind.DISCARD() != nil || kind.INTO() != nil { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, in, "IN subqueries require a SELECT without DISCARD or INTO RESULT")) + return + } + columns, ds := analyzeSelectCore(catalog, block, core, partial, bindings, inferred, syntax, selectINProjection) + diagnostics = append(diagnostics, ds...) + if _, resolved := syntax.Selects[core.GetStart().GetTokenIndex()]; resolved && len(ds) != 0 { + for _, ref := range columnRefs(core) { + if _, bound := syntax.Columns[ref.ctx.GetStart().GetTokenIndex()]; bound { + continue + } + output := false + if ref.qualifier == "" && isOrderByReference(ref.ctx) { + for _, column := range columns { + output = output || column.ResultName() == ref.name + } + } + if !output { + if _, err := resolveColumn(outer, ref); err == nil { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, ref.ctx, fmt.Sprintf("correlated IN subqueries are unsupported: %q may refer to an outer column; use only the subquery's own sources", qualifiedName(ref)))) + return + } + } + } + } + if len(ds) != 0 { + return + } + if len(columns) != 1 { + diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, core, "IN subquery must return exactly one column; use SELECT (key1, key2) for a tuple key")) + return + } + types[in.GetStart().GetTokenIndex()] = columns[0].Type + }) + return types, diagnostics +} + +func selectINProjection(block queryBlock, core *parser.Select_coreContext, relations []relation, bindings map[string]model.Type) ([]model.Column, []model.Diagnostic) { + results := core.AllResult_column() + if len(results) == 1 && results[0].Expr() != nil && len(tupleExpressions(results[0].Expr())) > 1 && core.Without_column_list() == nil { + result := results[0] + typ, err := resolveINOperand(result.Expr(), expressionScope{relations: relations, bindings: bindings, grouped: core.Group_by_clause() != nil, functions: block.functions}) + if err != nil { + return nil, []model.Diagnostic{diagnosticAt(block.file, block.line-1, result, err.Error())} + } + name := "column0" + 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()) + } + return []model.Column{{Name: name, Type: typ}}, nil + } + return selectProjection(block, core, relations, bindings) +} + +func tupleExpressions(root antlr.ParserRuleContext) []parser.IExprContext { + var expressions []parser.IExprContext + scopeDescendants(root, func(node antlr.Tree) { + lambda, ok := node.(*parser.LambdaContext) + if !ok || !sameSpan(root, lambda) || lambda.ARROW() != nil { + return + } + sub := lambda.Smart_parenthesis().Select_subexpr() + if sub == nil || sub.Cte_with_clause() != nil { + return + } + intersects := sub.Select_subexpr_core().AllSelect_subexpr_intersect() + if len(intersects) != 1 || len(intersects[0].AllSelect_or_expr()) != 1 { + return + } + tuple := intersects[0].Select_or_expr(0).Tuple_or_expr() + if tuple == nil || tuple.An_id_or_type() != nil || len(tuple.AllNamed_expr()) == 0 { + return + } + expressions = append(expressions, tuple.Expr()) + for _, item := range tuple.AllNamed_expr() { + if item.AS() != nil { + expressions = nil + return + } + expressions = append(expressions, item.Expr()) + } + }) + return expressions +} + +func resolveINOperand(root antlr.ParserRuleContext, scope expressionScope) (model.Type, error) { + items := tupleExpressions(root) + if len(items) == 0 { + return resolveScalarNode(root, scope) + } + typ := model.Type{Kind: "Tuple"} + for _, item := range items { + value, err := resolveExpression(item, scope) + if err != nil { + return model.Type{}, fmt.Errorf("cannot resolve IN tuple item: %w", err) + } + typ.Items = append(typ.Items, value) + } + return typ, nil +} + +func validateINSubqueryTypes(left, right model.Type) error { + if left.Kind == "Null" && right.Kind == "Null" { + return nil + } + if left.Kind == "Tuple" && right.Kind == "Tuple" { + if len(left.Items) != len(right.Items) { + return fmt.Errorf("tuple arity differs: %d and %d", len(left.Items), len(right.Items)) + } + for i, item := range left.Items { + if err := validateINSubqueryTypes(item, right.Items[i]); err != nil { + return fmt.Errorf("tuple component %d: %w", i+1, err) + } + } + return nil + } + _, err := builtins.CommonType(left, right) + return err +} diff --git a/internal/analyzer/in_subquery_test.go b/internal/analyzer/in_subquery_test.go new file mode 100644 index 00000000..bb76c3af --- /dev/null +++ b/internal/analyzer/in_subquery_test.go @@ -0,0 +1,315 @@ +package analyzer + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +const inSubquerySchema = `CREATE TABLE records ( + tenant Uint64 NOT NULL, + code Utf8 NOT NULL, + enabled Bool, + PRIMARY KEY (tenant, code) +); +CREATE TABLE allowed (tenant Uint64 NOT NULL, code Utf8 NOT NULL, PRIMARY KEY (tenant, code)); +CREATE TABLE numeric_codes (code Uint64 NOT NULL, PRIMARY KEY(code));` + +func TestINSubqueryResolvesIndependentScopes(t *testing.T) { + for _, tc := range []struct { + name, command, sql string + wantParameter string + }{ + {"declared list source", ":many", `DECLARE $keys AS List>; +SELECT code FROM records WHERE code IN (SELECT code FROM AS_TABLE($keys));`, "List>"}, + {"catalog inner filter", ":many", `SELECT r.code FROM records AS r WHERE r.code IN (SELECT a.code FROM allowed AS a WHERE a.tenant = $tenant);`, "Uint64"}, + {"tuple update", ":exec", `DECLARE $keys AS List>; +UPDATE records SET enabled = true WHERE (tenant, code) IN (SELECT (k.tenant, k.code) FROM AS_TABLE($keys) AS k);`, "List>"}, + } { + t.Run(tc.name, func(t *testing.T) { + sql := "-- name: Read " + tc.command + "\n" + tc.sql + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + if len(result.Queries) != 1 { + t.Fatalf("queries = %#v", result.Queries) + } + query := result.Queries[0] + if query.SQL != sql { + t.Fatalf("SQL changed: %q", query.SQL) + } + want, err := parseType(tc.wantParameter) + if err != nil { + t.Fatal(err) + } + if len(query.Parameters) != 1 || !query.Parameters[0].Type.Equal(want) { + t.Fatalf("parameters = %+v, want %s", query.Parameters, tc.wantParameter) + } + if tc.command == ":many" { + if len(query.ResultSets) != 1 || len(query.ResultSets[0].Columns) != 1 || query.ResultSets[0].Columns[0].Type.String() != "Utf8" { + t.Fatalf("outer result = %+v", query.ResultSets) + } + } else if len(query.ResultSets) != 0 { + t.Fatalf("inner SELECT leaked into result: %+v", query.ResultSets) + } + }) + } +} + +func TestINSubqueryPredicateForms(t *testing.T) { + for _, tc := range []struct { + name, command, sql string + columns int + }{ + {"shadowed alias", ":many", `SELECT r.code FROM records AS r WHERE r.tenant IN (SELECT r.code FROM numeric_codes AS r WHERE r.code = $selected);`, 1}, + {"inner aggregate", ":many", `SELECT code FROM records WHERE tenant IN (SELECT COUNT(*) FROM allowed);`, 1}, + {"parenthesized scalar key", ":many", `SELECT code FROM records WHERE (tenant) IN (SELECT (tenant) FROM allowed);`, 1}, + {"nested membership", ":many", `SELECT code FROM records WHERE tenant IN (SELECT tenant FROM allowed WHERE tenant IN (SELECT code FROM numeric_codes));`, 1}, + {"scoped wildcards", ":many", `SELECT r.* FROM records AS r WHERE r.tenant IN (SELECT * FROM numeric_codes);`, 3}, + {"nullable key", ":many", `DECLARE $keys AS List>; SELECT code FROM records WHERE code NOT IN (SELECT code FROM AS_TABLE($keys));`, 1}, + {"nullable tuple key", ":many", `DECLARE $keys AS List>; SELECT code FROM records WHERE (tenant,code) IN (SELECT (k.tenant,k.code) FROM AS_TABLE($keys) AS k);`, 1}, + {"tuple null component", ":many", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (tenant,NULL) FROM allowed);`, 1}, + {"scalar delete not in", ":exec", `DELETE FROM records WHERE tenant NOT IN (SELECT a.tenant FROM allowed AS a);`, 0}, + {"null projection", ":many", `SELECT code FROM records WHERE code IN (SELECT NULL FROM allowed);`, 1}, + {"null only membership", ":many", `SELECT code FROM records WHERE NULL IN (SELECT NULL FROM allowed);`, 1}, + {"null tuple components", ":many", `SELECT code FROM records WHERE (tenant,NULL) IN (SELECT (tenant,NULL) FROM allowed);`, 1}, + {"tuple projection alias", ":many", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (tenant,code) AS pair FROM allowed);`, 1}, + {"tuple implicit projection alias", ":many", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (tenant,code) pair FROM allowed);`, 1}, + {"computed keys", ":many", `SELECT code FROM records WHERE (tenant + 1ul, code || ""u) IN (SELECT (a.tenant, CAST(a.code AS Utf8)) FROM allowed AS a);`, 1}, + {"boolean nesting", ":many", `SELECT code FROM records WHERE NOT (tenant IN (SELECT code FROM numeric_codes)) OR (code IN (SELECT code FROM allowed) AND enabled);`, 1}, + {"delete predicate", ":exec", `DELETE FROM records WHERE (tenant, code) IN (SELECT (tenant, code) FROM allowed);`, 0}, + {"insert select predicate", ":exec", `INSERT INTO records (tenant, code, enabled) SELECT tenant, code, true FROM allowed WHERE tenant IN (SELECT code FROM numeric_codes);`, 0}, + {"update select predicate", ":exec", `UPDATE records ON SELECT tenant, code FROM allowed WHERE tenant IN (SELECT code FROM numeric_codes);`, 0}, + {"delete select predicate", ":exec", `DELETE FROM records ON SELECT tenant, code FROM allowed WHERE tenant IN (SELECT code FROM numeric_codes);`, 0}, + {"parameter lhs", ":many", `DECLARE $key AS Uint64; SELECT code FROM records WHERE $key IN (SELECT code FROM numeric_codes);`, 1}, + {"grouped subquery", ":many", `SELECT code FROM records WHERE tenant IN (SELECT tenant FROM allowed GROUP BY tenant HAVING COUNT(*) > 0ul);`, 1}, + {"inner output alias shadows outer", ":many", `SELECT code FROM records WHERE tenant IN (SELECT tenant AS enabled FROM allowed ORDER BY enabled);`, 1}, + {"tuple grouping", ":many", `SELECT code FROM records WHERE (tenant,code) IN (SELECT DISTINCT (tenant,code) FROM allowed GROUP BY tenant,code);`, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + sql := "-- name: Read " + tc.command + "\n" + tc.sql + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []model.Source{{Name: "query.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + q := result.Queries[0] + if tc.columns == 0 { + if len(q.ResultSets) != 0 { + t.Fatalf("unexpected results: %+v", q.ResultSets) + } + } else if len(q.ResultSets) != 1 || len(q.ResultSets[0].Columns) != tc.columns { + t.Fatalf("results: %+v", q.ResultSets) + } + if tc.name == "shadowed alias" { + if len(q.Parameters) != 1 || q.Parameters[0].Type.Kind != "Uint64" || q.ResultSets[0].Columns[0].Type.Kind != "Utf8" { + t.Fatalf("scope leaked: %+v", q) + } + if len(q.Syntax.Selects) != 2 || len(q.Syntax.Relations) != 1 { + t.Fatalf("scopes=%+v, outer relations=%+v", q.Syntax.Selects, q.Syntax.Relations) + } + } + if tc.name == "scoped wildcards" && strings.Contains(q.SQL, "*") { + t.Fatalf("wildcard was not normalized: %s", q.SQL) + } + }) + } +} + +func TestINSubqueryRejectsInvalidScopesAndKeys(t *testing.T) { + for _, tc := range []struct{ name, sql, want string }{ + {"qualified correlation", `SELECT r.code FROM records AS r WHERE r.tenant IN (SELECT a.tenant FROM allowed AS a WHERE a.code = r.code);`, "correlated IN subqueries are unsupported"}, + {"unqualified correlation", `SELECT code FROM records WHERE tenant IN (SELECT code FROM numeric_codes WHERE enabled);`, "correlated IN subqueries are unsupported"}, + {"unknown inner", `SELECT code FROM records WHERE tenant IN (SELECT missing FROM allowed);`, "unknown column"}, + {"incompatible scalar", `SELECT code FROM records WHERE tenant IN (SELECT code FROM allowed);`, "IN subquery key types are incompatible"}, + {"tuple arity", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (tenant,code,tenant) FROM allowed);`, "IN subquery key types are incompatible"}, + {"tuple field type", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (code,tenant) FROM allowed);`, "IN subquery key types are incompatible"}, + {"multiple columns", `SELECT code FROM records WHERE (tenant,code) IN (SELECT tenant,code FROM allowed);`, "must return exactly one column"}, + {"unknown tuple component", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (missing,code) FROM allowed);`, "unknown column"}, + {"discard subquery", `SELECT code FROM records WHERE tenant IN (DISCARD SELECT tenant FROM allowed);`, "without DISCARD or INTO RESULT"}, + {"multiple wildcard columns", `SELECT code FROM records WHERE tenant IN (SELECT * FROM allowed);`, "must return exactly one column"}, + {"union", `SELECT code FROM records WHERE tenant IN (SELECT tenant FROM allowed UNION ALL SELECT code FROM numeric_codes);`, "CTEs, UNION and INTERSECT are unsupported"}, + {"derived source", `SELECT code FROM records WHERE tenant IN (SELECT tenant FROM (SELECT tenant FROM allowed));`, "only named catalog tables are supported"}, + {"shared external conflict", `SELECT r.code FROM records AS r WHERE r.code = $selected AND r.tenant IN (SELECT r.code FROM numeric_codes AS r WHERE r.code = $selected);`, "incompatible"}, + {"outer aggregate rejected", `SELECT code FROM records WHERE COUNT(*) IN (SELECT code FROM numeric_codes);`, "aggregate functions are not allowed in WHERE"}, + {"inner aggregate predicate rejected", `SELECT code FROM records WHERE tenant IN (SELECT tenant FROM allowed WHERE COUNT(*) > 0ul);`, "aggregate functions are not allowed in WHERE"}, + {"projection unsupported", `SELECT tenant IN (SELECT code FROM numeric_codes) AS value FROM records;`, "IN subqueries are supported only in WHERE predicates"}, + {"join predicate unsupported", `SELECT r.code FROM records AS r JOIN allowed AS a ON r.tenant = a.tenant AND r.code IN (SELECT code FROM allowed);`, "IN subqueries are supported only in WHERE predicates"}, + {"tuple outside IN", `SELECT code FROM records WHERE (tenant,code) IS NULL;`, "unsupported scalar expression"}, + {"tuple group violation", `SELECT code FROM records WHERE (tenant,code) IN (SELECT (tenant,code) FROM allowed GROUP BY tenant);`, "must appear in GROUP BY or an aggregate function"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []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) + } + }) + } +} + +func TestINSubqueryDatabaseDiscoveryAndPrefixes(t *testing.T) { + sql := "-- name: Read :many\nPRAGMA TablePathPrefix = '/local/app'; SELECT id FROM outer_records WHERE id IN (SELECT id FROM inner_records VIEW by_id);" + table := model.Table{Columns: []model.Column{{Name: "id", Type: model.Type{Kind: "Uint64"}}}, PrimaryKey: []string{"id"}} + indexed := table + indexed.Indexes = []model.Index{{Name: "by_id", Kind: "GlobalSync", Columns: []string{"id"}}} + db := &fakeAnalysisDatabase{tables: map[string]model.Table{"/local/app/outer_records": table, "/local/app/inner_records": indexed}} + result, err := AnalyzeWithDatabase(context.Background(), nil, []model.Source{{Name: "query.sql", Text: sql}}, Options{}, db) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(db.described, []string{"/local/app/outer_records", "/local/app/inner_records"}) { + t.Fatalf("described=%v", db.described) + } + if result.Queries[0].SQL != sql || len(result.Queries[0].Syntax.Selects) != 2 { + t.Fatalf("query=%+v", result.Queries[0]) + } +} + +func TestINSubquerySharesExternalParameterConstraints(t *testing.T) { + for _, tc := range []struct{ name, command, sql string }{ + {"outer comparison to inner projection", ":many", `SELECT tenant FROM records WHERE tenant = $selected AND tenant IN (SELECT $selected);`}, + {"inner comparison to outer projection", ":many", `SELECT $selected AS selected FROM records WHERE tenant IN (SELECT tenant FROM allowed WHERE tenant = $selected);`}, + {"outer comparison to nested projection", ":many", `SELECT tenant FROM records WHERE tenant = $selected AND tenant IN (SELECT tenant FROM allowed WHERE tenant IN (SELECT $selected));`}, + {"update outer comparison", ":exec", `UPDATE records SET enabled = true WHERE tenant = $selected AND tenant IN (SELECT $selected);`}, + {"delete outer comparison", ":exec", `DELETE FROM records WHERE tenant = $selected AND tenant IN (SELECT $selected);`}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read " + tc.command + "\n" + tc.sql}}) + if err != nil { + t.Fatal(err) + } + query := result.Queries[0] + if len(query.Parameters) != 1 || query.Parameters[0].Name != "selected" || query.Parameters[0].Type.Kind != "Uint64" { + t.Fatalf("parameters = %+v", query.Parameters) + } + if tc.command == ":many" && (len(query.ResultSets) != 1 || len(query.ResultSets[0].Columns) != 1 || query.ResultSets[0].Columns[0].Type.Kind != "Uint64") { + t.Fatalf("results = %+v", query.ResultSets) + } + }) + } +} + +func TestINSubqueryPreservesInnerTypeForSharedLimit(t *testing.T) { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: `CREATE TABLE small_keys (id Uint32 NOT NULL, PRIMARY KEY(id));`}}, []model.Source{{Name: "query.sql", Text: `-- name: Read :many +SELECT id FROM small_keys WHERE id IN (SELECT id FROM small_keys WHERE id = $count) LIMIT $count;`}}) + if err != nil { + t.Fatal(err) + } + parameters := result.Queries[0].Parameters + if len(parameters) != 1 || parameters[0].Name != "count" || parameters[0].Type.Kind != "Uint32" { + t.Fatalf("parameters = %+v", parameters) + } +} + +func TestINSubqueryPreservesUsefulDiagnostics(t *testing.T) { + for _, tc := range []struct { + name, sql, want, absent string + }{ + { + name: "invalid inner predicate with shadowing output alias", + sql: `SELECT code FROM records WHERE tenant IN ( + SELECT tenant + 1ul AS enabled FROM allowed WHERE code = 1ul ORDER BY enabled +);`, + want: "comparison operands have incompatible types", + absent: "correlated IN subqueries", + }, + { + name: "unknown outer key", + sql: `SELECT code FROM records WHERE missing_key IN (SELECT tenant FROM allowed);`, + want: `unknown column "missing_key"`, + absent: "correlated IN subqueries", + }, + { + name: "named tuple projection", + sql: `SELECT code FROM records WHERE (tenant, code) IN (SELECT (tenant, code AS key) FROM allowed);`, + want: `computed result expression "(tenant,codeASkey)" is not supported`, + absent: "correlated IN subqueries", + }, + { + name: "empty tuple key", + sql: `SELECT code FROM records WHERE () IN (SELECT tenant FROM allowed);`, + want: `unsupported scalar expression "()"`, + }, + { + name: "empty parenthesized IN operand", + sql: `SELECT code FROM records WHERE tenant IN ();`, + want: `unsupported IN operand "()"`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []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) + } + if tc.absent != "" && strings.Contains(err.Error(), tc.absent) { + t.Fatalf("error = %v must not contain %q", err, tc.absent) + } + }) + } +} + +func TestINSubqueryRejectsContextsBeforeResolvingInnerQuery(t *testing.T) { + const contextError = "IN subqueries are supported only in WHERE predicates; they are not yet supported in projections, CASE, IF, or HAVING" + for _, tc := range []struct{ name, command, sql, want string }{ + {"join", ":many", `SELECT r.tenant FROM records AS r JOIN allowed AS a ON r.tenant = a.tenant AND r.tenant IN (SELECT $unknown);`, "IN subqueries are supported only in WHERE predicates; JOIN ON membership is unsupported"}, + {"projection inference conflict", ":many", `SELECT tenant IN (SELECT code FROM allowed WHERE code = $selected) AS found FROM records WHERE tenant = $selected;`, contextError}, + {"projection unknown parameter", ":many", `SELECT tenant IN (SELECT $unknown) AS found FROM records;`, contextError}, + {"having inference conflict", ":many", `SELECT tenant FROM records WHERE tenant = $selected GROUP BY tenant HAVING tenant IN (SELECT code FROM allowed WHERE code = $selected);`, contextError}, + {"case in where", ":many", `SELECT tenant FROM records WHERE CASE WHEN tenant IN (SELECT $unknown) THEN true ELSE false END;`, contextError}, + {"if in where", ":many", `SELECT tenant FROM records WHERE IF(tenant IN (SELECT $unknown), true, false);`, contextError}, + {"order by", ":many", `SELECT tenant FROM records ORDER BY tenant IN (SELECT $unknown);`, contextError}, + {"update value", ":exec", `UPDATE records SET enabled = tenant IN (SELECT $unknown);`, contextError}, + {"nested projection", ":many", `SELECT tenant FROM records WHERE tenant IN (SELECT tenant IN (SELECT $unknown) AS found FROM allowed);`, contextError}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read " + tc.command + "\n" + tc.sql}}) + if err == nil || len(result.Diagnostics) != 1 || result.Diagnostics[0].Message != tc.want { + t.Fatalf("diagnostics = %+v, want exactly %q", result.Diagnostics, tc.want) + } + }) + } +} + +func TestINSubqueryFailuresDoNotCascade(t *testing.T) { + for _, tc := range []struct{ name, command, sql, want string }{ + {"union with unknown parameter", ":many", `SELECT tenant FROM records WHERE tenant IN (SELECT $unknown UNION ALL SELECT 1ul);`, "IN subqueries currently require one SELECT; CTEs, UNION and INTERSECT are unsupported"}, + {"unknown inner parameter", ":many", `SELECT tenant FROM records WHERE tenant IN (SELECT $unknown FROM allowed);`, "cannot resolve type of parameter $unknown; add DECLARE"}, + {"inner function with inferred parameter", ":many", `SELECT tenant FROM records WHERE tenant IN (SELECT MissingFunction(tenant) FROM allowed WHERE code = $selected);`, `unsupported YQL function "MissingFunction"`}, + {"update union", ":exec", `UPDATE records SET enabled = true WHERE tenant IN (SELECT $unknown UNION ALL SELECT 1ul);`, "IN subqueries currently require one SELECT; CTEs, UNION and INTERSECT are unsupported"}, + {"delete unknown inner parameter", ":exec", `DELETE FROM records WHERE tenant IN (SELECT $unknown FROM allowed);`, "cannot resolve type of parameter $unknown; add DECLARE"}, + {"insert select unknown inner parameter", ":exec", `INSERT INTO records (tenant, code, enabled) SELECT tenant, code, true FROM allowed WHERE tenant IN (SELECT $unknown);`, "cannot resolve type of parameter $unknown; add DECLARE"}, + {"update select unknown inner parameter", ":exec", `UPDATE records ON SELECT tenant, code FROM allowed WHERE tenant IN (SELECT $unknown);`, "cannot resolve type of parameter $unknown; add DECLARE"}, + {"delete select unknown inner parameter", ":exec", `DELETE FROM records ON SELECT tenant, code FROM allowed WHERE tenant IN (SELECT $unknown);`, "cannot resolve type of parameter $unknown; add DECLARE"}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []model.Source{{Name: "query.sql", Text: "-- name: Read " + tc.command + "\n" + tc.sql}}) + if err == nil || len(result.Diagnostics) != 1 || result.Diagnostics[0].Message != tc.want { + t.Fatalf("diagnostics = %+v, want exactly %q", result.Diagnostics, tc.want) + } + }) + } +} + +func TestINSubqueryCorrelationHintPreservesInnerErrors(t *testing.T) { + result, err := Analyze([]model.Source{{Name: "schema.sql", Text: inSubquerySchema}}, []model.Source{{Name: "query.sql", Text: `-- name: Read :many +SELECT tenant FROM records WHERE tenant IN (SELECT MissingFunction(tenant) FROM allowed WHERE enabled);`}}) + if err == nil { + t.Fatal("expected invalid inner query diagnostics") + } + var messages []string + for _, diagnostic := range result.Diagnostics { + messages = append(messages, diagnostic.Message) + } + want := []string{ + `unsupported YQL function "MissingFunction"`, + `unknown column "enabled"`, + `invalid predicate: cannot resolve predicate operand "enabled": unknown column "enabled"`, + `correlated IN subqueries are unsupported: "enabled" may refer to an outer column; use only the subquery's own sources`, + } + if !reflect.DeepEqual(messages, want) { + t.Fatalf("diagnostics = %#v, want %#v", messages, want) + } +} diff --git a/internal/analyzer/insert_select.go b/internal/analyzer/insert_select.go index 8ae90786..a1cbfb75 100644 --- a/internal/analyzer/insert_select.go +++ b/internal/analyzer/insert_select.go @@ -78,9 +78,18 @@ func analyzeSelectCore(catalog model.Catalog, block queryBlock, core *parser.Sel 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) - inferFromInLists(tree.conds, relations, inferred) + inferFromComparisons(core, relations, inferred) + inferFromInLists(core, relations, inferred) + for name, typ := range inferred { + if _, ok := bindings[name]; !ok && typ.Kind != "" { + bindings[name] = typ + } + } + subqueries, ds := analyzeINSubqueries(catalog, block, core, relations, bindings, inferred, syntax) + diagnostics = append(diagnostics, ds...) + if len(ds) != 0 { + return nil, diagnostics + } inferFromExpressionContexts(core, bindings, inferred) if partial != nil { inferLimitOffset(partial, bindings, inferred) @@ -97,8 +106,11 @@ func analyzeSelectCore(catalog model.Catalog, block queryBlock, core *parser.Sel 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, validatePredicateContexts(block, core, relations, bindings, subqueries)...) diagnostics = append(diagnostics, validateGrouping(block, core, relations, bindings)...) + resolved := syntax.Selects[core.GetStart().GetTokenIndex()] + resolved.Columns = columns + syntax.Selects[core.GetStart().GetTokenIndex()] = resolved return columns, diagnostics } diff --git a/internal/analyzer/order_by.go b/internal/analyzer/order_by.go index 95bdd6d2..fc303d10 100644 --- a/internal/analyzer/order_by.go +++ b/internal/analyzer/order_by.go @@ -46,7 +46,7 @@ func resolveOrderByProjections(block queryBlock, core *parser.Select_coreContext outputs[name] = syntax.Columns[ref.ctx.GetStart().GetTokenIndex()] } var diagnostics []model.Diagnostic - descendants(core, func(node antlr.Tree) { + scopeDescendants(core, func(node antlr.Tree) { order, ok := node.(*parser.Sort_specificationContext) if !ok { return diff --git a/internal/analyzer/predicate.go b/internal/analyzer/predicate.go index 910bed5e..63b3d326 100644 --- a/internal/analyzer/predicate.go +++ b/internal/analyzer/predicate.go @@ -9,9 +9,9 @@ import ( parser "github.com/ydb-platform/yql-parsers/go" ) -func validatePredicateContexts(block queryBlock, root antlr.Tree, relations []relation, bindings map[string]model.Type) []model.Diagnostic { +func validatePredicateContexts(block queryBlock, root antlr.Tree, relations []relation, bindings map[string]model.Type, subqueries map[int]model.Type) []model.Diagnostic { var predicates []parser.IExprContext - descendants(root, func(node antlr.Tree) { + scopeDescendants(root, func(node antlr.Tree) { switch ctx := node.(type) { case *parser.Select_coreContext: if ctx.WHERE() != nil { @@ -42,7 +42,7 @@ func validatePredicateContexts(block queryBlock, root antlr.Tree, relations []re 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 { + if err := validatePredicate(predicate, expressionScope{relations: relations, bindings: bindings, functions: block.functions, inSubqueries: subqueries}); err != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, predicate, fmt.Sprintf("invalid predicate: %v", err))) } } @@ -71,7 +71,13 @@ func validatePredicate(expr parser.IExprContext, scope expressionScope) error { func validatePredicateAtom(atom *parser.Xor_subexprContext, scope expressionScope) error { if condition := atom.Cond_expr(); condition != nil { - left, err := resolveScalarNode(atom.Eq_subexpr(), scope) + var left model.Type + var err error + if condition.IN() != nil { + left, err = resolveINOperand(atom.Eq_subexpr(), scope) + } else { + left, err = resolveScalarNode(atom.Eq_subexpr(), scope) + } if err != nil { return fmt.Errorf("cannot resolve predicate operand %q: %w", atom.Eq_subexpr().GetText(), err) } @@ -81,6 +87,12 @@ func validatePredicateAtom(atom *parser.Xor_subexprContext, scope expressionScop if condition.IN() != nil && condition.In_expr() != nil { types := []model.Type{left} inExpr := condition.In_expr() + if subquery, ok := scope.inSubqueries[inExpr.GetStart().GetTokenIndex()]; ok { + if err := validateINSubqueryTypes(left, subquery); err != nil { + return fmt.Errorf("IN subquery key types are incompatible: %s and %s: %w", left.String(), subquery.String(), err) + } + return nil + } if bind := directBind(inExpr); bind != nil && inExpr.GetText() == bind.GetText() { typeValue, ok := scope.bindings[bindName(bind)] if !ok || typeValue.Kind != "List" || typeValue.Elem == nil { diff --git a/internal/analyzer/select_semantic.go b/internal/analyzer/select_semantic.go index c24bf7d6..1e2e3c19 100644 --- a/internal/analyzer/select_semantic.go +++ b/internal/analyzer/select_semantic.go @@ -239,7 +239,7 @@ func selectHavingExpression(core *parser.Select_coreContext) parser.IExprContext func containsAggregate(root antlr.Tree) bool { found := false - descendants(root, func(node antlr.Tree) { + scopeDescendants(root, func(node antlr.Tree) { unary, ok := node.(*parser.Unary_subexprContext) if !ok { return @@ -252,7 +252,7 @@ func containsAggregate(root antlr.Tree) bool { func unaggregatedColumnRefs(root antlr.Tree) []columnRef { var aggregateSpans [][2]int - descendants(root, func(node antlr.Tree) { + scopeDescendants(root, func(node antlr.Tree) { unary, ok := node.(*parser.Unary_subexprContext) if !ok { return @@ -344,23 +344,27 @@ func validateLimitOffset(block queryBlock, partial parser.ISelect_kind_partialCo return diagnostics } -func inferFromInLists(conditions []*parser.Cond_exprContext, relations []relation, inferred map[string]model.Type) { - for _, condition := range conditions { +func inferFromInLists(root antlr.Tree, relations []relation, inferred map[string]model.Type) { + scopeDescendants(root, func(node antlr.Tree) { + condition, ok := node.(*parser.Cond_exprContext) + if !ok { + return + } if condition.IN() == nil || condition.In_expr() == nil { - continue + return } text := condition.In_expr().GetText() refs := columnRefs(condition.GetParent()) if len(refs) != 1 { - continue + return } column, err := resolveColumn(relations, refs[0]) if err != nil { - continue + return } if strings.HasPrefix(text, "$") { var direct parser.IBind_parameterContext - descendants(condition.In_expr(), func(node antlr.Tree) { + scopeDescendants(condition.In_expr(), func(node antlr.Tree) { if bind, ok := node.(parser.IBind_parameterContext); ok && bind.GetText() == text { direct = bind } @@ -369,14 +373,14 @@ func inferFromInLists(conditions []*parser.Cond_exprContext, relations []relatio elem := column.Type.UnwrapOptional() inferParameter(inferred, bindName(direct), model.Type{Kind: "List", Elem: &elem}) } - continue + return } if !strings.HasPrefix(text, "(") || !strings.HasSuffix(text, ")") { - continue + return } var binds []parser.IBind_parameterContext var directPositions = map[int]bool{} - descendants(condition.In_expr(), func(node antlr.Tree) { + scopeDescendants(condition.In_expr(), func(node antlr.Tree) { switch ctx := node.(type) { case parser.IBind_parameterContext: binds = append(binds, ctx) @@ -387,12 +391,12 @@ func inferFromInLists(conditions []*parser.Cond_exprContext, relations []relatio } }) if len(binds) == 0 || len(binds) != len(directPositions) { - continue + return } for _, bind := range binds { if directPositions[bind.GetStart().GetStart()] { inferParameter(inferred, bindName(bind), column.Type) } } - } + }) } diff --git a/internal/analyzer/semantic.go b/internal/analyzer/semantic.go index 1d10bcab..b8a105b8 100644 --- a/internal/analyzer/semantic.go +++ b/internal/analyzer/semantic.go @@ -19,9 +19,6 @@ type queryTree struct { updates []*parser.Update_stmtContext deletes []*parser.Delete_stmtContext binds []parser.IBind_parameterContext - conds []*parser.Cond_exprContext - eqs []*parser.Eq_subexprContext - xors []*parser.Xor_subexprContext } func collectQueryTree(tree antlr.Tree) queryTree { @@ -42,12 +39,6 @@ func collectQueryTree(tree antlr.Tree) queryTree { out.deletes = append(out.deletes, ctx) case *parser.Bind_parameterContext: out.binds = append(out.binds, ctx) - case *parser.Cond_exprContext: - out.conds = append(out.conds, ctx) - case *parser.Eq_subexprContext: - out.eqs = append(out.eqs, ctx) - case *parser.Xor_subexprContext: - out.xors = append(out.xors, ctx) } }) return out @@ -72,12 +63,15 @@ func analyzeQuery(catalog model.Catalog, block queryBlock) (model.AnalyzedQuery, if len(diagnostics) != 0 { return query, diagnostics } - query.Syntax = &model.QuerySyntax{Root: parsed.tree, Columns: map[int]model.ColumnBinding{}, Tables: resolvedTableReferences(parsed.tree, block.tablePathPrefix), TablePathPrefix: block.tablePathPrefix} + query.Syntax = &model.QuerySyntax{Root: parsed.tree, Columns: map[int]model.ColumnBinding{}, Selects: map[int]model.SelectBinding{}, Tables: resolvedTableReferences(parsed.tree, block.tablePathPrefix), TablePathPrefix: block.tablePathPrefix} tree := collectQueryTree(parsed.tree) if diagnostics = unsupportedSQLCMacroDiagnostics(block, parsed.tokens); len(diagnostics) != 0 { return query, diagnostics } diagnostics = append(diagnostics, validateQueryStatements(block, tree)...) + if contextDiagnostics := validateINSubqueryContexts(block, parsed.tree); len(contextDiagnostics) != 0 { + return query, append(diagnostics, contextDiagnostics...) + } selectStatement := topLevelSelect(tree.statements) dataStatements := len(tree.insert) + len(tree.updates) + len(tree.deletes) if selectStatement != nil { @@ -125,6 +119,9 @@ func analyzeQuery(catalog model.Catalog, block queryBlock) (model.AnalyzedQuery, columns, armDiagnostics := analyzeSelectCore(catalog, block, core, partials[i], bindings, inferred, query.Syntax, selectProjection) diagnostics = append(diagnostics, armDiagnostics...) if len(armDiagnostics) != 0 { + if columns == nil && containsINSubquery(core) { + return query, diagnostics + } continue } arms = append(arms, columns) @@ -153,18 +150,27 @@ 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, nil)...) - inferFromComparisons(tree, relations, inferred) - inferFromInLists(tree.conds, relations, inferred) + inferFromComparisons(parsed.tree, relations, inferred) + inferFromInLists(parsed.tree, relations, inferred) for name, typeValue := range inferred { if _, exists := bindings[name]; !exists && typeValue.Kind != "" { bindings[name] = typeValue } } - diagnostics = append(diagnostics, validatePredicateContexts(block, parsed.tree, relations, bindings)...) + subqueries, ds := analyzeINSubqueries(catalog, block, parsed.tree, relations, bindings, inferred, query.Syntax) + diagnostics = append(diagnostics, ds...) + if len(ds) != 0 { + return query, diagnostics + } + diagnostics = append(diagnostics, validatePredicateContexts(block, parsed.tree, relations, bindings, subqueries)...) } if target != nil && len(tree.insert) == 1 { if stmt := insertSelect(tree.insert[0]); stmt != nil { - diagnostics = append(diagnostics, analyzeInsertSelect(catalog, block, tree.insert[0], target, bindings, inferred, query.Syntax)...) + ds := analyzeInsertSelect(catalog, block, tree.insert[0], target, bindings, inferred, query.Syntax) + diagnostics = append(diagnostics, ds...) + if len(ds) != 0 && containsINSubquery(stmt) { + return query, diagnostics + } } else { diagnostics = append(diagnostics, inferInsert(block, tree.insert[0], target, bindings, inferred)...) } @@ -174,7 +180,11 @@ func analyzeQuery(catalog model.Catalog, block queryBlock) (model.AnalyzedQuery, if tree.updates[0].Into_values_source().Pure_column_list() != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, tree.updates[0].Into_values_source().Pure_column_list(), "UPDATE ON SELECT with an explicit source column list is unsupported")) } else { - diagnostics = append(diagnostics, analyzeNamedDMLSelect(catalog, block, stmt, tree.updates[0], target, bindings, inferred, query.Syntax)...) + ds := analyzeNamedDMLSelect(catalog, block, stmt, tree.updates[0], target, bindings, inferred, query.Syntax) + diagnostics = append(diagnostics, ds...) + if len(ds) != 0 && containsINSubquery(stmt) { + return query, diagnostics + } } } else if tree.updates[0].ON() != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, tree.updates[0], "UPDATE ON currently requires a SELECT source")) @@ -187,7 +197,11 @@ func analyzeQuery(catalog model.Catalog, block queryBlock) (model.AnalyzedQuery, if tree.deletes[0].Into_values_source().Pure_column_list() != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, tree.deletes[0].Into_values_source().Pure_column_list(), "DELETE ON SELECT with an explicit source column list is unsupported")) } else { - diagnostics = append(diagnostics, analyzeNamedDMLSelect(catalog, block, stmt, tree.deletes[0], target, bindings, inferred, query.Syntax)...) + ds := analyzeNamedDMLSelect(catalog, block, stmt, tree.deletes[0], target, bindings, inferred, query.Syntax) + diagnostics = append(diagnostics, ds...) + if len(ds) != 0 && containsINSubquery(stmt) { + return query, diagnostics + } } } else if tree.deletes[0].ON() != nil { diagnostics = append(diagnostics, diagnosticAt(block.file, block.line-1, tree.deletes[0], "DELETE ON currently requires a SELECT source")) @@ -248,7 +262,7 @@ func analyzeQuery(catalog model.Catalog, block queryBlock) (model.AnalyzedQuery, } func inferFromExpressionContexts(root antlr.Tree, declared, inferred map[string]model.Type) { - descendants(root, func(node antlr.Tree) { + scopeDescendants(root, func(node antlr.Tree) { if ctx, ok := node.(*parser.Mul_subexprContext); ok { inferFromConcatenation(ctx, declared, inferred) } @@ -675,7 +689,7 @@ type columnRef struct { func columnRefs(root antlr.Tree) []columnRef { var refs []columnRef - descendants(root, func(node antlr.Tree) { + scopeDescendants(root, func(node antlr.Tree) { ctx, ok := node.(*parser.Unary_subexprContext) if !ok { return @@ -780,27 +794,32 @@ func joinedColumn(column model.Column, optional bool) model.Column { return column } -func inferFromComparisons(tree queryTree, relations []relation, inferred map[string]model.Type) { - for _, root := range comparisonContexts(tree) { +func inferFromComparisons(root antlr.Tree, relations []relation, inferred map[string]model.Type) { + scopeDescendants(root, func(root antlr.Tree) { + switch root.(type) { + case *parser.Xor_subexprContext, *parser.Eq_subexprContext: + default: + return + } refs := columnRefs(root) if len(refs) != 1 { - continue + return } var binds []parser.IBind_parameterContext - descendants(root, func(node antlr.Tree) { + scopeDescendants(root, func(node antlr.Tree) { if bind, ok := node.(*parser.Bind_parameterContext); ok { binds = append(binds, bind) } }) if len(binds) != 1 || !isDirectComparison(root, refs[0], binds[0]) { - continue + return } column, err := resolveColumn(relations, refs[0]) if err != nil { - continue + return } inferParameter(inferred, bindName(binds[0]), column.Type) - } + }) } func isDirectComparison(root antlr.Tree, ref columnRef, bind parser.IBind_parameterContext) bool { @@ -814,17 +833,6 @@ func isDirectComparison(root antlr.Tree, ref columnRef, bind parser.IBind_parame return false } -func comparisonContexts(tree queryTree) []antlr.Tree { - out := make([]antlr.Tree, 0, len(tree.xors)+len(tree.eqs)) - for _, ctx := range tree.xors { - out = append(out, ctx) - } - for _, ctx := range tree.eqs { - out = append(out, ctx) - } - return out -} - func inferInsert(block queryBlock, statement *parser.Into_table_stmtContext, table *model.Table, bindings, inferred map[string]model.Type) []model.Diagnostic { source := statement.Into_values_source() if source == nil || source.Values_source() == nil || source.Values_source().Values_stmt() == nil || source.Pure_column_list() == nil { @@ -1034,8 +1042,22 @@ func compatibleTypes(left, right model.Type) bool { } func recordColumnBindings(syntax *model.QuerySyntax, root antlr.Tree, relations []relation) { + var bindings []model.TableBinding for _, relation := range relations { - syntax.Relations = append(syntax.Relations, model.TableBinding{Table: relation.table.Name, Alias: relation.alias}) + bindings = append(bindings, model.TableBinding{Table: relation.table.Name, Alias: relation.alias}) + } + if core, ok := root.(*parser.Select_coreContext); ok { + syntax.Selects[core.GetStart().GetTokenIndex()] = model.SelectBinding{Relations: bindings} + } + nested := false + for parent := root.GetParent(); parent != nil; parent = parent.GetParent() { + if _, ok := parent.(*parser.In_exprContext); ok { + nested = true + break + } + } + if !nested { + syntax.Relations = append(syntax.Relations, bindings...) } for _, ref := range columnRefs(root) { for _, relation := range relations { diff --git a/internal/analyzer/table_path_prefix_test.go b/internal/analyzer/table_path_prefix_test.go index ab4cf227..5e88e0b2 100644 --- a/internal/analyzer/table_path_prefix_test.go +++ b/internal/analyzer/table_path_prefix_test.go @@ -8,7 +8,9 @@ import ( "strings" "testing" + "github.com/antlr4-go/antlr/v4" "github.com/ydb-platform/sqlc-ydb/internal/model" + parser "github.com/ydb-platform/yql-parsers/go" ) func TestTablePathPrefixResolvesDistinctNamespaces(t *testing.T) { @@ -362,8 +364,14 @@ func TestColumnBindingsPreserveAliasCase(t *testing.T) { if query.ResultSets[0].Columns[0].Type.Kind != "Uint32" || query.ResultSets[0].Columns[1].Type.Kind != "Uint64" || !reflect.DeepEqual(query.Parameters, []model.Parameter{{Name: "id", Type: model.Type{Kind: "Uint64"}}}) { t.Fatalf("query types = %#v, parameters = %#v", query.ResultSets, query.Parameters) } + var core *parser.Select_coreContext + descendants(query.Syntax.Root, func(node antlr.Tree) { + if selectCore, ok := node.(*parser.Select_coreContext); ok { + core = selectCore + } + }) bindings := 0 - for _, ref := range columnRefs(query.Syntax.Root) { + for _, ref := range columnRefs(core) { wantTable, wantType := "records", "Uint32" if ref.qualifier == "R" { wantTable, wantType = "other_records", "Uint64" diff --git a/internal/codegen/java/jooq.go b/internal/codegen/java/jooq.go index 0ba27680..998ad8af 100644 --- a/internal/codegen/java/jooq.go +++ b/internal/codegen/java/jooq.go @@ -229,7 +229,7 @@ func generateJooq(a *model.AnalysisResult, o Options) ([]model.File, error) { b.WriteString(" }\n") continue } - isSelect := len(jooqNodes[*parser.Select_coreContext](q.Syntax.Root)) != 0 + isSelect := jooqIsSelect(q.Syntax.Root) for _, rel := range q.Syntax.Relations { tn, e := jooqTableConstant(rel.Table) if e != nil { @@ -280,7 +280,7 @@ func generateJooq(a *model.AnalysisResult, o Options) ([]model.File, error) { if q.Command == model.Many { fetch = "fetch" } - if len(jooqNodes[*parser.Select_coreContext](q.Syntax.Root)) == 0 { + if !isSelect { var fields []string table := r.aliases[q.Syntax.Relations[0].Alias] for _, c := range q.ResultSets[0].Columns { @@ -353,16 +353,24 @@ func (r *jooqRenderer) expr(n antlr.Tree) string { if strings.HasPrefix(identifier, "`") { identifier = strings.ReplaceAll(identifier[1:len(identifier)-1], "``", "`") } + ordering := false 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 { + ordering = true + } + if core, ok := parent.(*parser.Select_coreContext); ok { + if ordering { + for _, column := range r.query.Syntax.Selects[core.GetStart().GetTokenIndex()].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) + ")" + _, dataType, err := jooqType(column.Type) + if err != nil { + r.err = err + } + return "field(name(" + quoted(column.ResultName()) + "), " + dataType + ")" } } } + break } } } @@ -456,6 +464,12 @@ 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.IN() != nil { + selects := jooqNodes[*parser.Select_coreContext](cond.In_expr()) + if len(selects) == 1 { + return r.inSubquery(children[0], cond, selects[0]) + } + } if cond.NULL() != nil || cond.ISNULL() != nil || cond.NOTNULL() != nil { method := "isNull" if cond.NOT() != nil || cond.NOTNULL() != nil { @@ -487,6 +501,48 @@ func jooqOperator(op string) string { return map[string]string{"=": "eq", "==": "eq", "!=": "ne", "<>": "ne", "<": "lt", ">": "gt", "<=": "le", ">=": "ge", "AND": "and", "OR": "or", "||": "concat"}[strings.ToUpper(op)] } +func jooqIsSelect(root antlr.Tree) bool { + for _, stmt := range jooqNodes[*parser.Sql_stmtContext](root) { + if stmt.Sql_stmt_core().Select_stmt() != nil { + return true + } + } + return false +} + +func (r *jooqRenderer) inSubquery(left antlr.Tree, condition *parser.Cond_exprContext, core *parser.Select_coreContext) string { + scope := r.query.Syntax.Selects[core.GetStart().GetTokenIndex()] + if len(scope.Columns) == 1 && scope.Columns[0].Type.UnwrapOptional().Kind == "Tuple" { + r.err = fmt.Errorf("tuple IN subqueries require explicit DECLARE parameters or runtime: jdbc or ydb; the jOOQ DSL cannot preserve YQL's single tuple projection") + return "" + } + inner := *r + inner.aliases = map[string]string{} + for _, relation := range scope.Relations { + table, err := jooqTableConstant(relation.Table) + if err != nil { + r.err = err + return "" + } + if relation.Alias != relation.Table { + table += ".as(" + quoted(relation.Alias) + ")" + } + inner.aliases[relation.Alias] = table + } + selectSQL := inner.selectQuery(core) + if inner.err != nil { + r.err = inner.err + return "" + } + operator := "IN" + if condition.NOT() != nil { + operator = "NOT IN" + } + // YQL permits comparable operands with different scalar types; Java's + // Field.in(Select>) would reject those typed query parts. + return jooqCall("condition", []string{quoted("{0} " + operator + " ({1})"), r.expr(left), selectSQL}) +} + func (r *jooqRenderer) statement() string { root := r.query.Syntax.Root for _, stmt := range jooqNodes[*parser.Sql_stmtContext](root) { @@ -494,22 +550,30 @@ func (r *jooqRenderer) statement() string { if core.Pragma_stmt() != nil && r.query.Syntax.TablePathPrefix != "" { continue } - isDML := core.Into_table_stmt() != nil || core.Update_stmt() != nil || core.Delete_stmt() != nil - if isDML && len(jooqNodes[*parser.Select_coreContext](core)) > 0 { + var source parser.IInto_values_sourceContext + if into := core.Into_table_stmt(); into != nil { + source = into.Into_values_source() + } else if update := core.Update_stmt(); update != nil { + source = update.Into_values_source() + } else if del := core.Delete_stmt(); del != nil { + source = del.Into_values_source() + } + if source != nil && len(jooqNodes[*parser.Select_coreContext](source)) > 0 { r.err = fmt.Errorf("SELECT-backed DML is unsupported by the jOOQ DSL; use runtime: jdbc or ydb") return "" } - if core.Declare_stmt() == nil && core.Select_stmt() == nil && core.Into_table_stmt() == nil && core.Update_stmt() == nil && core.Delete_stmt() == nil { - return r.fail(core) + if selectStmt := core.Select_stmt(); selectStmt != nil { + selects := jooqNodes[*parser.Select_coreContext](selectStmt) + if len(selects) != 1 || len(jooqNodes[*parser.Union_opContext](selectStmt)) > 0 { + return r.fail(selectStmt) + } + return r.selectQuery(selects[0]) } - } - selects := jooqNodes[*parser.Select_coreContext](root) - if len(selects) > 0 { - if len(selects) != 1 || len(jooqNodes[*parser.Union_opContext](root)) > 0 { - return r.fail(root) + if core.Declare_stmt() == nil && core.Into_table_stmt() == nil && core.Update_stmt() == nil && core.Delete_stmt() == nil { + return r.fail(core) } - return r.selectQuery(selects[0]) } + rel := r.query.Syntax.Relations if len(rel) != 1 { return r.fail(root) @@ -546,12 +610,12 @@ func (r *jooqRenderer) statement() string { body += "\n .set(" + r.col(table, jooqID(set.Set_target().Column_name().An_id().GetText())) + ", " + r.expr(set.Expr()) + ")" } if stmt.Expr() != nil { - body += "\n .where(" + r.expr(stmt.Expr()) + ")" + body += "\n " + strings.ReplaceAll(jooqCall(".where", []string{r.expr(stmt.Expr())}), "\n", "\n ") } } else if nodes := jooqNodes[*parser.Delete_stmtContext](root); len(nodes) == 1 { body = "dsl.deleteFrom(" + table + ")" if nodes[0].Expr() != nil { - body += "\n .where(" + r.expr(nodes[0].Expr()) + ")" + body += "\n " + strings.ReplaceAll(jooqCall(".where", []string{r.expr(nodes[0].Expr())}), "\n", "\n ") } } else { return r.fail(root) @@ -619,7 +683,7 @@ func (r *jooqRenderer) selectQuery(core *parser.Select_coreContext) string { } } body := jooqCall("dsl."+method, cols) - rel := r.query.Syntax.Relations + rel := r.query.Syntax.Selects[core.GetStart().GetTokenIndex()].Relations if len(rel) > 0 { if len(core.AllJoin_source()) != 1 { return r.fail(core) @@ -656,7 +720,7 @@ 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) { + for _, order := range jooqNodes[*parser.Sort_specificationContext](core.Ext_order_by_clause()) { expr := r.expr(order.Expr()) if strings.HasSuffix(strings.ToUpper(order.GetText()), "DESC") { expr += ".desc()" @@ -666,15 +730,20 @@ func (r *jooqRenderer) selectQuery(core *parser.Select_coreContext) string { 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 { + for parent := core.GetParent(); parent != nil; parent = parent.GetParent() { + limit, ok := parent.(*parser.Select_kind_partialContext) + if !ok { continue } + if limit.LIMIT() == nil { + break + } xs := limit.AllExpr() if len(xs) != 1 { return r.fail(limit) } body += "\n .limit(" + r.expr(xs[0]) + ")" + break } return body } diff --git a/internal/codegen/java/jooq_subquery_boundaries_test.go b/internal/codegen/java/jooq_subquery_boundaries_test.go new file mode 100644 index 00000000..feec8a32 --- /dev/null +++ b/internal/codegen/java/jooq_subquery_boundaries_test.go @@ -0,0 +1,83 @@ +package java + +import ( + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +func TestJooqSubqueryTargetBoundaries(t *testing.T) { + for _, tc := range []struct { + name, schema, sql, want string + }{ + { + name: "local scalar binding is not a DSL statement", + schema: jooqSubquerySchema, + sql: `$selected = 1ul; SELECT id FROM records WHERE id IN (SELECT id FROM selected WHERE id = $selected);`, + want: `Read: unsupported jOOQ syntax "$selected=1ul"`, + }, + { + name: "collection output alias cannot become a scalar order field", + schema: `CREATE TABLE records (id Uint64 NOT NULL, PRIMARY KEY(id)); CREATE TABLE selected (id Uint64 NOT NULL, tags Json NOT NULL, PRIMARY KEY(id));`, + sql: `SELECT id FROM records WHERE NULL IN (SELECT Yson::ConvertToStringList(tags) AS keys FROM selected ORDER BY keys);`, + want: "jOOQ does not support type List", + }, + { + name: "inner HAVING is explicitly unsupported by the DSL", + schema: jooqSubquerySchema, + sql: `SELECT id FROM records WHERE id IN (SELECT id FROM selected GROUP BY id HAVING COUNT(*) > 0);`, + want: `Read: unsupported jOOQ syntax "SELECTidFROMselectedGROUPBYidHAVINGCOUNT(*)>0"`, + }, + { + name: "update source remains distinct from membership predicate", + schema: jooqSubquerySchema, + sql: `UPDATE records ON SELECT id, label FROM selected WHERE id IN (SELECT id FROM records);`, + want: "SELECT-backed DML is unsupported by the jOOQ DSL; use runtime: jdbc or ydb", + }, + { + name: "delete source remains distinct from membership predicate", + schema: jooqSubquerySchema, + sql: `DELETE FROM records ON SELECT id FROM selected WHERE id IN (SELECT id FROM records);`, + want: "SELECT-backed DML is unsupported by the jOOQ DSL; use runtime: jdbc or ydb", + }, + } { + t.Run(tc.name, func(t *testing.T) { + command := ":many" + if strings.HasPrefix(tc.sql, "UPDATE") || strings.HasPrefix(tc.sql, "DELETE") { + command = ":exec" + } + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: tc.schema}}, []model.Source{{Name: "queries.sql", Text: "-- name: Read " + command + "\n" + tc.sql}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", Runtime: "jooq"}) + if files != nil || err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("files = %v, error = %v; want %q and no output", files, err, tc.want) + } + }) + } +} + +func TestJooqSubqueryPreservesNegationAndDistinct(t *testing.T) { + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqSubquerySchema}}, []model.Source{{Name: "queries.sql", Text: "-- name: Read :many\nSELECT id FROM records WHERE NOT (id IN (SELECT DISTINCT id FROM selected));"}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if file.Name != "Queries.java" { + continue + } + text := string(file.Content) + if !strings.Contains(text, `"{0} IN ({1})"`) || !strings.Contains(text, "dsl.selectDistinct(SELECTED.ID)") || !strings.Contains(text, ").not()") { + t.Fatalf("inner DISTINCT or outer negation was lost:\n%s", text) + } + return + } + t.Fatal("Queries.java was not generated") +} diff --git a/internal/codegen/java/jooq_subquery_sdk_test.go b/internal/codegen/java/jooq_subquery_sdk_test.go new file mode 100644 index 00000000..acd51ae7 --- /dev/null +++ b/internal/codegen/java/jooq_subquery_sdk_test.go @@ -0,0 +1,115 @@ +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 TestJooqSubqueriesPublishedSDK(t *testing.T) { + maven := os.Getenv("SQLC_YDB_TEST_MAVEN") + if maven == "" { + t.Skip("set SQLC_YDB_TEST_MAVEN to compile and execute IN subqueries against the published dialect") + } + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqSubquerySchema}}, []model.Source{{Name: "queries.sql", Text: jooqSubqueryQueries + "\n-- name: ReadNegatedDistinct :many\nSELECT id FROM records WHERE NOT (id IN (SELECT DISTINCT id FROM selected));"}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", 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 subqueries; +import java.util.*; +import org.jooq.conf.*; +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 statements = new ArrayList(); + try (var connection = new MockConnection(ctx -> { + String sql = ctx.sql().replace("` + "`" + `", "").replaceAll("\\s+", " ").trim().toLowerCase(Locale.ROOT); + int index = statements.size(); + statements.add(sql); + String inner = index == 5 ? "mapped_small_keys" : "mapped_selected"; + if (!sql.contains("mapped_records") || !sql.contains(inner)) throw new AssertionError(sql); + if (index == 0 && !sql.contains("r.id in (select r.id from mapped_selected r where r.label = ?")) throw new AssertionError(sql); + if (index == 1 && !sql.contains("not in (select")) throw new AssertionError(sql); + int orderCount = sql.split("order by", -1).length - 1; + int wantOrders = (index == 6 || index == 7) ? 2 : index <= 2 ? 1 : 0; + if (orderCount != wantOrders) throw new AssertionError(sql); + if (index == 6 && (!sql.contains("order by mapped_selected.label desc") || !sql.endsWith("order by mapped_records.id"))) throw new AssertionError(sql); + if (index == 7 && !sql.contains("order by n desc limit 1")) throw new AssertionError(sql); + if (index == 8 && (!sql.contains("not (") || !sql.contains("select distinct"))) throw new AssertionError(sql); + Object[] want = switch(index) { + case 0, 2, 4 -> new Object[]{"chosen"}; + case 3 -> new Object[]{"changed", "chosen"}; + case 5 -> new Object[]{Integer.valueOf(2)}; + default -> new Object[0]; + }; + if (!Arrays.equals(ctx.bindings(), want)) throw new AssertionError(Arrays.toString(ctx.bindings()) + " for " + sql); + if (index == 3) return new MockResult[]{new MockResult(1)}; + var dsl = YDB.using(); + var id = field(name("id"), YdbTypes.UINT64); + if (index == 0 || index == 4) { + var label = field(name("label"), YdbTypes.UTF8); + var rows = dsl.newResult(id, label); + rows.add(dsl.newRecord(id, label).values(ULong.MAX, "chosen")); + return new MockResult[]{new MockResult(1, rows)}; + } + var rows = dsl.newResult(id); + rows.add(dsl.newRecord(id).values(ULong.MAX)); + return new MockResult[]{new MockResult(1, rows)}; + })) { + var settings = new Settings().withRenderMapping(new RenderMapping().withSchemata(new MappedSchema().withInput("").withTables( + new MappedTable().withInput("records").withOutput("mapped_records"), + new MappedTable().withInput("selected").withOutput("mapped_selected"), + new MappedTable().withInput("small_keys").withOutput("mapped_small_keys")))); + var queries = new Queries(YDB.using(connection, settings)); + if (!queries.readSelected("chosen").equals(List.of(new ReadSelectedRow(ULong.MAX, "chosen")))) throw new AssertionError("selected"); + if (!queries.readExcluded().equals(List.of(new ReadExcludedRow(ULong.MAX)))) throw new AssertionError("excluded"); + if (!queries.readNested("chosen").equals(List.of(new ReadNestedRow(ULong.MAX)))) throw new AssertionError("nested"); + queries.updateSelected("changed", "chosen"); + if (!queries.deleteSelected("chosen").equals(List.of(new DeleteSelectedRow(ULong.MAX, "chosen")))) throw new AssertionError("delete"); + if (!queries.readMixedTypes(2).equals(List.of(new ReadMixedTypesRow(ULong.MAX)))) throw new AssertionError("mixed numeric types"); + if (!queries.readInnerOrder().equals(List.of(new ReadInnerOrderRow(ULong.MAX)))) throw new AssertionError("inner order"); + if (!queries.readAggregate().equals(List.of(new ReadAggregateRow(ULong.MAX)))) throw new AssertionError("aggregate order"); + if (!queries.readNegatedDistinct().equals(List.of(new ReadNegatedDistinctRow(ULong.MAX)))) throw new AssertionError("negated distinct membership"); + if (statements.size() != 9) 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)), "subqueries.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_subquery_test.go b/internal/codegen/java/jooq_subquery_test.go new file mode 100644 index 00000000..8fdc3a01 --- /dev/null +++ b/internal/codegen/java/jooq_subquery_test.go @@ -0,0 +1,124 @@ +package java + +import ( + "strings" + "testing" + + "github.com/ydb-platform/sqlc-ydb/internal/analyzer" + "github.com/ydb-platform/sqlc-ydb/internal/model" +) + +const jooqSubquerySchema = `CREATE TABLE records (id Uint64 NOT NULL, label Utf8 NOT NULL, PRIMARY KEY(id)); +CREATE TABLE selected (id Uint64 NOT NULL, label Utf8 NOT NULL, PRIMARY KEY(id)); +CREATE TABLE small_keys (id Int32 NOT NULL, PRIMARY KEY(id));` + +const jooqSubqueryQueries = `-- name: ReadSelected :many +SELECT r.id, r.label FROM records AS r +WHERE r.id IN (SELECT r.id FROM selected AS r WHERE r.label = $label) +ORDER BY r.id; +-- name: ReadExcluded :many +SELECT id FROM records WHERE id NOT IN (SELECT id FROM selected) ORDER BY id; +-- name: ReadNested :many +SELECT id FROM records WHERE id IN (SELECT id FROM selected WHERE id IN (SELECT id FROM records WHERE label = $label)) ORDER BY id; +-- name: UpdateSelected :exec +UPDATE records SET label = $new_label WHERE id IN (SELECT id FROM selected WHERE label = $label); +-- name: DeleteSelected :many +DELETE FROM records WHERE id IN (SELECT id FROM selected WHERE label = $label) RETURNING id, label; +-- name: ReadMixedTypes :many +SELECT id FROM records WHERE id IN (SELECT id FROM small_keys WHERE id >= $minimum); +-- name: ReadInnerOrder :many +SELECT id FROM records WHERE label IN (SELECT label AS chosen FROM selected ORDER BY chosen DESC LIMIT 1) ORDER BY id; +-- name: ReadAggregate :many +SELECT id FROM records WHERE id IN (SELECT COUNT(*) AS n FROM selected GROUP BY label ORDER BY n DESC LIMIT 1) ORDER BY id;` + +func TestJooqScalarSubqueries(t *testing.T) { + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqSubquerySchema}}, []model.Source{{Name: "queries.sql", Text: jooqSubqueryQueries}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", 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 _, want := range []string{`"{0} IN ({1})"`, `"{0} NOT IN ({1})"`, `dsl.select(SELECTED.as("r").ID)`, `SELECTED.as("r").LABEL.eq(val(label, YdbTypes.UTF8))`, `dsl.update(RECORDS)`, `dsl.deleteFrom(RECORDS)`, `YDB RETURNING produces a result set`} { + if !strings.Contains(queries, want) { + t.Errorf("missing %s:\n%s", want, queries) + } + } + if strings.Count(queries, ".orderBy(") != 7 { + t.Fatalf("ORDER BY leaked into nested SELECT:\n%s", queries) + } +} + +func TestJooqTupleSubqueryRequiresDeclaredPath(t *testing.T) { + for _, declare := range []bool{false, true} { + sql := "-- name: Read :many\n" + if declare { + sql += "DECLARE $label AS Utf8;\n" + } + sql += `SELECT id FROM records WHERE (id, label) IN (SELECT (id, label) FROM selected WHERE label = $label);` + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqSubquerySchema}}, []model.Source{{Name: "queries.sql", Text: sql}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", Runtime: "jooq"}) + if !declare { + if files != nil || err == nil || !strings.Contains(err.Error(), "tuple IN subqueries require explicit DECLARE parameters or runtime: jdbc or ydb") { + t.Fatalf("tuple DSL: files=%v, error=%v", files, err) + } + continue + } + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if file.Name == "Queries.java" && (!strings.Contains(string(file.Content), "SELECT (id, label) FROM") || !strings.Contains(string(file.Content), "dsl.render(SELECTED)")) { + t.Fatalf("declared tuple SQL/mapped table changed:\n%s", file.Content) + } + } + } +} + +func TestJooqSubqueryPrefixAndAliases(t *testing.T) { + for _, declaration := range []string{"", "DECLARE $label AS Utf8;\n"} { + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: "PRAGMA TablePathPrefix('/local/nested');\n" + jooqSubquerySchema}}, []model.Source{{Name: "queries.sql", Text: "-- name: Read :many\nPRAGMA TablePathPrefix('/local/nested');\n" + declaration + `SELECT r.id FROM records AS r WHERE r.id IN (SELECT r.id FROM selected AS r WHERE r.label = $label);`}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", Runtime: "jooq"}) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if file.Name != "Queries.java" { + continue + } + queries := string(file.Content) + for _, want := range []string{"PRAGMA TablePathPrefix('/local/nested')", "LOCAL_NESTED_RECORDS", "LOCAL_NESTED_SELECTED"} { + if !strings.Contains(queries, want) { + t.Errorf("missing %q in prefixed subquery:\n%s", want, queries) + } + } + if declaration != "" && (!strings.Contains(queries, `dsl.render(LOCAL_NESTED_SELECTED)`) || !strings.Contains(queries, ` AS r WHERE r.label = $label`)) { + t.Fatalf("declared SQL lost inner mapping or source alias:\n%s", queries) + } + } + } +} + +func TestJooqSubqueryReportsUnsupportedInnerExpression(t *testing.T) { + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: jooqSubquerySchema}}, []model.Source{{Name: "queries.sql", Text: "-- name: Read :many\nSELECT id FROM records WHERE id IN (SELECT COALESCE(id, 0) FROM selected);"}}) + if err != nil { + t.Fatal(err) + } + files, err := Generate(analysis, Options{Package: "subqueries", Runtime: "jooq"}) + if files != nil || err == nil || err.Error() != `Read: unsupported jOOQ syntax "COALESCE(id,0)"` { + t.Fatalf("files = %v, error = %v; want query-specific inner expression diagnostic and no output", files, err) + } +} diff --git a/internal/endtoend/in_subqueries_jooq_live_test.go b/internal/endtoend/in_subqueries_jooq_live_test.go new file mode 100644 index 00000000..9243f8a3 --- /dev/null +++ b/internal/endtoend/in_subqueries_jooq_live_test.go @@ -0,0 +1,134 @@ +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 inSubqueriesJooq(t *testing.T) { + maven := os.Getenv("SQLC_YDB_TEST_MAVEN") + if maven == "" { + t.Skip("set SQLC_YDB_TEST_MAVEN for live generated jOOQ IN subqueries") + } + table := fmt.Sprintf("sqlc_jooq_in_t%d", time.Now().UnixNano()) + schema := strings.NewReplacer("records", table, "selected", table+"_selected", "small_keys", table+"_small").Replace(inSubqueriesJooqSchema) + analysis, err := analyzer.Analyze([]model.Source{{Name: "schema.sql", Text: inSubqueriesJooqSchema}}, []model.Source{{Name: "queries.sql", Text: inSubqueriesJooqQueries}}) + if err != nil { + t.Fatal(err) + } + files, err := java.Generate(analysis, java.Options{Package: "insubquerieslive", 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("$SCHEMA", strconv.Quote(schema), "$TABLE", strconv.Quote(table)).Replace(inSubqueriesJooqProgram) + 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)), "insubquerieslive.Main"}} { + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("jOOQ IN subqueries %s: %v\n%s", args[0], err, out) + } + } +} + +const inSubqueriesJooqSchema = `CREATE TABLE records (id Uint64 NOT NULL, group_id Uint64 NOT NULL, label Utf8 NOT NULL, PRIMARY KEY(id)); +CREATE TABLE selected (id Uint64 NOT NULL, group_id Uint64 NOT NULL, label Utf8 NOT NULL, PRIMARY KEY(id)); +CREATE TABLE small_keys (id Int32 NOT NULL, PRIMARY KEY(id));` + +const inSubqueriesJooqQueries = `-- name: ReadSelected :many +SELECT r.id,r.label FROM records AS r WHERE r.id IN (SELECT r.id FROM selected AS r WHERE r.label=$label) ORDER BY r.id; +-- name: ReadExcluded :many +SELECT id FROM records WHERE id NOT IN (SELECT id FROM selected) ORDER BY id; +-- name: ReadNested :many +SELECT id FROM records WHERE id IN (SELECT id FROM selected WHERE id IN (SELECT id FROM records WHERE label=$label)) ORDER BY id; +-- name: UpdateSelected :exec +UPDATE records SET label=$new_label WHERE id IN (SELECT id FROM selected WHERE label=$label); +-- name: DeleteSelected :many +DELETE FROM records WHERE id IN (SELECT id FROM selected WHERE label=$label) RETURNING id,label; +-- name: ReadMixedTypes :many +SELECT id FROM records WHERE id IN (SELECT id FROM small_keys WHERE id >= $minimum) ORDER BY id; +-- name: ReadInnerOrder :many +SELECT id FROM records WHERE label IN (SELECT label AS chosen FROM selected ORDER BY chosen DESC LIMIT 1) ORDER BY id; +-- name: ReadAggregate :many +SELECT id FROM records WHERE id IN (SELECT COUNT(*) AS n FROM selected GROUP BY label ORDER BY n DESC LIMIT 1) ORDER BY id; +-- name: ReadTuple :many +DECLARE $label AS Utf8; +SELECT id FROM records WHERE (group_id,label) IN (SELECT (group_id,label) FROM selected WHERE label=$label) ORDER BY id;` + +const inSubqueriesJooqProgram = `package insubquerieslive; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import org.jooq.conf.*; +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(); + String table = $TABLE; + try (var connection = DriverManager.getConnection("jdbc:ydb:" + System.getenv("YDB_CONNECTION_STRING"))) { + try { + try (var statement = connection.createStatement()) { + for (String ddl : $SCHEMA.split(";")) { + if (ddl.isBlank()) continue; + statement.execute(ddl); + created.add(ddl.trim().split("\\s+")[2]); + } + statement.execute("UPSERT INTO " + table + " (id,group_id,label) VALUES (1ul,10ul,'a'u),(2ul,20ul,'b'u),(3ul,30ul,'c'u),(18446744073709551615ul,18446744073709551615ul,'max'u);"); + statement.execute("UPSERT INTO " + table + "_selected (id,group_id,label) VALUES (1ul,10ul,'a'u),(2ul,999ul,'b'u),(18446744073709551615ul,18446744073709551615ul,'max'u);"); + statement.execute("UPSERT INTO " + table + "_small (id) VALUES (1),(2);"); + } + var settings = new Settings().withRenderMapping(new RenderMapping().withSchemata(new MappedSchema().withInput("").withTables( + new MappedTable().withInput("records").withOutput(table), + new MappedTable().withInput("selected").withOutput(table + "_selected"), + new MappedTable().withInput("small_keys").withOutput(table + "_small")))); + var queries = new Queries(YDB.using(connection,settings)); + if (!queries.readSelected("a").equals(List.of(new ReadSelectedRow(ULong.valueOf(1),"a")))) throw new AssertionError("shadowed alias"); + if (!queries.readSelected("absent").isEmpty()) throw new AssertionError("empty inner results"); + if (!queries.readExcluded().equals(List.of(new ReadExcludedRow(ULong.valueOf(3))))) throw new AssertionError("NOT IN"); + if (!queries.readNested("b").equals(List.of(new ReadNestedRow(ULong.valueOf(2))))) throw new AssertionError("nested IN"); + if (!queries.readMixedTypes(2).equals(List.of(new ReadMixedTypesRow(ULong.valueOf(2))))) throw new AssertionError("numeric comparison"); + if (!queries.readInnerOrder().equals(List.of(new ReadInnerOrderRow(ULong.MAX)))) throw new AssertionError("inner ORDER BY"); + if (!queries.readAggregate().equals(List.of(new ReadAggregateRow(ULong.valueOf(1))))) throw new AssertionError("aggregate inner ORDER BY"); + if (!queries.readTuple("a").equals(List.of(new ReadTupleRow(ULong.valueOf(1))))) throw new AssertionError("declared tuple"); + if (!queries.readTuple("b").isEmpty()) throw new AssertionError("tuple must compare both components"); + if (!queries.readTuple("max").equals(List.of(new ReadTupleRow(ULong.MAX)))) throw new AssertionError("tuple Uint64 boundary"); + queries.updateSelected("changed","b"); + if (!queries.readSelected("b").equals(List.of(new ReadSelectedRow(ULong.valueOf(2),"changed")))) throw new AssertionError("UPDATE IN"); + if (!queries.deleteSelected("b").equals(List.of(new DeleteSelectedRow(ULong.valueOf(2),"changed")))) throw new AssertionError("DELETE IN RETURNING"); + if (!queries.readSelected("b").isEmpty()) throw new AssertionError("deleted row remains"); + } finally { + for (String name : created.reversed()) { + try (var statement = connection.createStatement()) { statement.execute("DROP TABLE " + name); } + } + } + } + } +}` diff --git a/internal/endtoend/in_subqueries_live_test.go b/internal/endtoend/in_subqueries_live_test.go new file mode 100644 index 00000000..627362bf --- /dev/null +++ b/internal/endtoend/in_subqueries_live_test.go @@ -0,0 +1,242 @@ +package endtoend + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/ydb-platform/sqlc-ydb/internal/cli" +) + +func TestInSubqueriesGeneratedGoCompiles(t *testing.T) { + runInSubqueries(t, "grpc://localhost:2136/local", true) +} + +func TestLiveYDBInSubqueries(t *testing.T) { + dsn := os.Getenv("YDB_CONNECTION_STRING") + if dsn == "" { + t.Skip("set YDB_CONNECTION_STRING for IN subquery validation") + } + runInSubqueries(t, dsn, false) + t.Run("jooq", inSubqueriesJooq) +} + +func runInSubqueries(t *testing.T, dsn string, compileOnly bool) { + t.Helper() + dir := t.TempDir() + table := fmt.Sprintf("sqlc_in_subqueries_%d", time.Now().UnixNano()) + replace := strings.NewReplacer("records", table, "memberships", table+"_keys", "nullable_keys", table+"_nullable") + schema := replace.Replace(inSubqueriesSchema) + queries := replace.Replace(inSubqueriesQueries) + 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 IN subqueries: %s", stderr.String()) + } + 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).Replace(inSubqueriesRuntime) + compileTypedDMLPackage(t, dir, "./"+strings.ReplaceAll(runtime, "/", "_"), source, compileOnly) + }) + } +} + +const inSubqueriesSchema = `CREATE TABLE records (id Uint64 NOT NULL, code Utf8 NOT NULL, value Uint64, active Bool NOT NULL, PRIMARY KEY(id,code)); +CREATE TABLE memberships (id Uint64 NOT NULL, code Utf8 NOT NULL, PRIMARY KEY(id,code)); +CREATE TABLE nullable_keys (id Uint64, code Utf8, PRIMARY KEY(id,code));` + +const inSubqueriesQueries = `-- name: ListRecords :many +SELECT id,code,value,active FROM records ORDER BY id,code; + +-- name: ScalarKeys :many +DECLARE $keys AS List>; +DECLARE $minimum AS Uint64; +SELECT r.id,r.code FROM records AS r +WHERE r.id IN (SELECT k.id FROM AS_TABLE($keys) AS k WHERE k.id >= $minimum) +ORDER BY r.id,r.code; + +-- name: NullableKeys :many +DECLARE $keys AS List>; +SELECT id,code FROM records +WHERE value IN (SELECT k.value FROM AS_TABLE($keys) AS k) +ORDER BY id,code; + +-- name: NullableNotIn :many +DECLARE $keys AS List>; +SELECT id,code FROM records +WHERE value NOT IN (SELECT k.value FROM AS_TABLE($keys) AS k) +ORDER BY id,code; + +-- name: TupleKeys :many +DECLARE $keys AS List>; +SELECT id,code FROM records +WHERE (id,code) IN (SELECT (k.id,k.code) FROM AS_TABLE($keys) AS k) +ORDER BY id,code; + +-- name: NullableTupleKeys :many +DECLARE $keys AS List>; +SELECT id,code FROM nullable_keys +WHERE (id,code) IN (SELECT (k.id,k.code) FROM AS_TABLE($keys) AS k) +ORDER BY id,code; + +-- name: ActivateKeys :exec +DECLARE $keys AS List>; +UPDATE records SET active = true +WHERE (id,code) IN (SELECT (k.id,k.code) FROM AS_TABLE($keys) AS k); + +-- name: DeleteKeys :exec +DECLARE $keys AS List>; +DELETE FROM records +WHERE (id,code) IN (SELECT (k.id,k.code) FROM AS_TABLE($keys) AS k); + +-- name: ShadowedAliases :many +DECLARE $code AS Utf8; +SELECT r.id,r.code FROM records AS r +WHERE r.id IN (SELECT r.id FROM memberships AS r WHERE r.code = $code) +ORDER BY r.id,r.code; + +-- name: TableKeys :many +SELECT id,code FROM records +WHERE (id,code) IN (SELECT (k.id,k.code) FROM memberships AS k) +ORDER BY id,code; +` + +const inSubqueriesRuntime = `package records + +import ( + "context" + "fmt" + "io" + "reflect" + "strings" + "testing" + "time" + $SQL_IMPORT + ydb "github.com/ydb-platform/ydb-go-sdk/v3" +) + +func TestInSubqueries(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 + for _,ddl := range strings.Split($SCHEMA,";") { + if strings.TrimSpace(ddl)=="" {continue} + if err := driver.Query().Exec(ctx,ddl); err != nil {t.Fatal(err)} + created := strings.Fields(ddl)[2] + defer func() { + cleanup,done := context.WithTimeout(context.Background(),10*time.Second) + defer done() + if err := driver.Query().Exec(cleanup,"DROP TABLE "+created);err != nil {t.Error(err)} + }() + } + if err := driver.Query().Exec(ctx,"UPSERT INTO "+table+" (id,code,value,active) VALUES (1ul,'a'u,10ul,false),(2ul,'b'u,20ul,false),(3ul,'c'u,NULL,false),(18446744073709551615ul,'max'u,18446744073709551615ul,false);");err != nil {t.Fatal(err)} + if err := driver.Query().Exec(ctx,"UPSERT INTO "+table+"_keys (id,code) VALUES (1ul,'a'u),(2ul,'wrong'u),(18446744073709551615ul,'max'u);");err != nil {t.Fatal(err)} + if err := driver.Query().Exec(ctx,"UPSERT INTO "+table+"_nullable (id,code) VALUES (1ul,'a'u),(2ul,'b'u),(3ul,NULL),(NULL,'a'u),(18446744073709551615ul,'max'u);");err != nil {t.Fatal(err)} + $SETUP + all := []string{"1:a","2:b","3:c","18446744073709551615:max"} + pair := []string{"1:a","18446744073709551615:max"} + for _,keys := range [][]ScalarKeysKeysItem{nil,{}} { + rows,err := q.ScalarKeys(ctx,ScalarKeysParams{Keys:keys,Minimum:0}) + checkKeys(t,rows,err,nil) + } + rows,err := q.ScalarKeys(ctx,ScalarKeysParams{Keys:[]ScalarKeysKeysItem{{ID:1},{ID:3},{ID:^uint64(0)}},Minimum:3}) + checkKeys(t,rows,err,[]string{"3:c","18446744073709551615:max"}) + rows,err = q.ScalarKeys(ctx,ScalarKeysParams{Keys:[]ScalarKeysKeysItem{{ID:1}},Minimum:2}) + checkKeys(t,rows,err,nil) + value := uint64(10) + nulls,err := q.NullableKeys(ctx,[]NullableKeysKeysItem{{Value:&value},{Value:nil}}) + checkKeys(t,nulls,err,[]string{"1:a"}) + notIn,err := q.NullableNotIn(ctx,[]NullableNotInKeysItem{{Value:&value},{Value:nil}}) + checkKeys(t,notIn,err,nil) + notIn,err = q.NullableNotIn(ctx,nil) + checkKeys(t,notIn,err,all) + for _,keys := range [][]TupleKeysKeysItem{nil,{},{{ID:1,Code:"a"},{ID:1,Code:"a"},{ID:2,Code:"wrong"},{ID:^uint64(0),Code:"max"}}} { + tuples,err := q.TupleKeys(ctx,keys) + var want []string + if len(keys)>0 {want=pair} + checkKeys(t,tuples,err,want) + } + optionalTuples,err := q.NullableTupleKeys(ctx,[]NullableTupleKeysKeysItem{{ID:1,Code:"a"},{ID:2,Code:"wrong"},{ID:3,Code:"a"},{ID:^uint64(0),Code:"max"}}) + if err != nil {t.Fatal(err)} + if len(optionalTuples)!=2 || optionalTuples[0].ID==nil || *optionalTuples[0].ID!=1 || optionalTuples[0].Code==nil || *optionalTuples[0].Code!="a" || optionalTuples[1].ID==nil || *optionalTuples[1].ID!=^uint64(0) || optionalTuples[1].Code==nil || *optionalTuples[1].Code!="max" {t.Fatalf("nullable tuple comparison/decoding: %+v",optionalTuples)} + optionalTuples,err = q.NullableTupleKeys(ctx,nil) + if err != nil || len(optionalTuples)!=0 {t.Fatalf("empty nullable tuple keys: %+v, %v",optionalTuples,err)} + shadow,err := q.ShadowedAliases(ctx,"a") + checkKeys(t,shadow,err,[]string{"1:a"}) + shadow,err = q.ShadowedAliases(ctx,"absent") + checkKeys(t,shadow,err,nil) + physical,err := q.TableKeys(ctx) + checkKeys(t,physical,err,pair) + for _,keys := range [][]ActivateKeysKeysItem{nil,{},{{ID:1,Code:"a"},{ID:2,Code:"wrong"},{ID:^uint64(0),Code:"max"}}} { + if err := q.ActivateKeys(ctx,keys);err != nil {t.Fatal(err)} + } + records,err := q.ListRecords(ctx) + checkKeys(t,records,err,all) + if !records[0].Active || records[1].Active || records[2].Active || !records[3].Active || records[2].Value != nil || records[3].Value == nil || *records[3].Value != ^uint64(0) { + t.Fatalf("tuple update or nullable/boundary decoding: %+v",records) + } + checkInMetadata(t,ctx,driver,"SELECT r.id,r.code FROM "+table+" r WHERE (r.id,r.code) IN (SELECT(k.id,k.code) FROM "+table+"_keys k) ORDER BY r.id,r.code;",[]string{"id","code"},[]string{"Uint64","Utf8"}) + checkInMetadata(t,ctx,driver,"SELECT value FROM "+table+" WHERE value IN (SELECT value FROM "+table+" WHERE false);",[]string{"value"},[]string{"Optional"}) + for _,predicate := range []string{ + "(r.id,r.code) IN (SELECT k.id,k.code FROM "+table+"_keys k)", + "(r.id,r.code) IN (SELECT(k.id,k.code,k.id) FROM "+table+"_keys k)", + "(r.id,r.code) IN (SELECT(k.code,k.id) FROM "+table+"_keys k)", + "r.id IN (SELECT k.code FROM "+table+"_keys k)", + "r.id IN (SELECT k.id FROM "+table+"_keys k WHERE k.id=r.id)", + "r.id IN (SELECT k.id FROM "+table+"_keys k WHERE value=10ul)", + } { + if err := driver.Query().Exec(ctx,"SELECT r.id FROM "+table+" r WHERE "+predicate+";");err == nil {t.Errorf("server accepted invalid/correlated IN: %s",predicate)} + } + for _,keys := range [][]DeleteKeysKeysItem{nil,{},{{ID:1,Code:"wrong"},{ID:2,Code:"b"}}} { + if err := q.DeleteKeys(ctx,keys);err != nil {t.Fatal(err)} + } + records,err = q.ListRecords(ctx) + checkKeys(t,records,err,[]string{"1:a","3:c","18446744073709551615:max"}) +} + +func checkKeys(t *testing.T,rows any,err error,want []string) { + t.Helper() + if err != nil {t.Fatal(err)} + var got []string + values:=reflect.ValueOf(rows) + for i:=0;i + diff --git a/tests/examples/go/booktest/smoke_test.go b/tests/examples/go/booktest/smoke_test.go index b58c71e0..95e40f2f 100644 --- a/tests/examples/go/booktest/smoke_test.go +++ b/tests/examples/go/booktest/smoke_test.go @@ -91,6 +91,31 @@ func TestGeneratedExample(t *testing.T) { t.Fatalf("native BooksByTags() = %#v, %v", rows, err) } + if rows, err := n.ListAuthorsWithRecentBooks(ctx, 2000); err != nil || len(rows) != 1 || rows[0].AuthorID != 100 || rows[0].Name != author.Name { + t.Fatalf("native ListAuthorsWithRecentBooks() = %#v, %v", rows, err) + } + if rows, err := s.ListAuthorsWithRecentBooks(ctx, 2000); err != nil || len(rows) != 1 || rows[0].AuthorID != 100 || rows[0].Name != author.Name { + t.Fatalf("database/sql ListAuthorsWithRecentBooks() = %#v, %v", rows, err) + } + if rows, err := n.ListAuthorsWithRecentBooks(ctx, 2021); err != nil || len(rows) != 0 { + t.Fatalf("native empty author subquery = %#v, %v", rows, err) + } + if rows, err := s.ListAuthorsWithRecentBooks(ctx, 2021); err != nil || len(rows) != 0 { + t.Fatalf("database/sql empty author subquery = %#v, %v", rows, err) + } + if rows, err := n.ListBooksWithRecentEditions(ctx, 2000); err != nil || len(rows) != 2 || rows[0].BookID != 201 || rows[1].BookID != 202 { + t.Fatalf("native tuple subquery must compare author and type: %#v, %v", rows, err) + } + if rows, err := s.ListBooksWithRecentEditions(ctx, 2000); err != nil || len(rows) != 2 || rows[0].BookID != 201 || rows[1].BookID != 202 { + t.Fatalf("database/sql tuple subquery must compare author and type: %#v, %v", rows, err) + } + if rows, err := n.ListBooksWithRecentEditions(ctx, 2021); err != nil || len(rows) != 0 { + t.Fatalf("native empty tuple subquery = %#v, %v", rows, err) + } + if rows, err := s.ListBooksWithRecentEditions(ctx, 2021); err != nil || len(rows) != 0 { + t.Fatalf("database/sql empty tuple subquery = %#v, %v", rows, err) + } + tx, err := db.SQL.BeginTx(ctx, nil) if err != nil { t.Fatal(err) @@ -125,6 +150,35 @@ func TestGeneratedExample(t *testing.T) { if _, err := s.GetBook(ctx, 101); err == nil { t.Fatal("DeleteAuthorBeforeYear left an old book") } + if err := s.DeleteBook(ctx, 102); err != nil { + t.Fatal(err) + } + if _, err := n.GetBook(ctx, 102); err == nil { + t.Fatal("DeleteBook left an existing row") + } + if err := n.DeleteBooksByAuthorName(ctx, "absent"); err != nil { + t.Fatal(err) + } + if err := n.DeleteBooksByAuthorName(ctx, author.Name); err != nil { + t.Fatal(err) + } + for _, id := range []uint64{102, 201} { + if _, err := s.GetBook(ctx, id); err == nil { + t.Fatalf("native DeleteBooksByAuthorName left book %d", id) + } + } + if _, err := n.GetBook(ctx, 202); err != nil { + t.Fatalf("DeleteBooksByAuthorName removed unmatched author: %v", err) + } + if _, err := s.CreateAuthor(ctx, sq.CreateAuthorParams{AuthorID: 999, Name: "Second author"}); err != nil { + t.Fatal(err) + } + if err := s.DeleteBooksByAuthorName(ctx, "Second author"); err != nil { + t.Fatal(err) + } + if _, err := n.GetBook(ctx, 202); err == nil { + t.Fatal("database/sql DeleteBooksByAuthorName left book 202") + } for _, id := range []uint64{102, 201, 202} { if err := s.DeleteBook(ctx, id); err != nil { t.Fatal(err) diff --git a/tests/examples/java/batch/pom.xml b/tests/examples/java/batch/pom.xml index 37567b34..15e5f79a 100644 --- a/tests/examples/java/batch/pom.xml +++ b/tests/examples/java/batch/pom.xml @@ -13,6 +13,8 @@ ${project.basedir}/../../../../examples/batch/java/jdbc ${project.basedir}/../../../../examples/namespaces/java/native/src/main/java ${project.basedir}/../../../../examples/namespaces/java/jdbc/src/main/java + ${project.basedir}/../../../../examples/booktest/java/native + ${project.basedir}/../../../../examples/booktest/java/jdbc diff --git a/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java b/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java index de5eed37..df1dca8d 100644 --- a/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java +++ b/tests/examples/java/jooq/src/test/java/GeneratedQueriesTest.java @@ -58,6 +58,14 @@ void everyExampleRendersAndBindsThroughThePublishedDialect() throws Exception { if (method.getName().equals("findAuthorsByName") || method.getName().equals("findAuthorsByNameCovering")) { assertTrue(sql.replace("`", "").contains("VIEW by_name"), sql); } + if (List.of("listAuthorsWithRecentBooks", "listBooksWithRecentEditions", "deleteBooksByAuthorName").contains(method.getName())) { + assertTrue(sql.toLowerCase().contains(" in ("), sql); + assertTrue(sql.toLowerCase().contains("select"), sql); + if (method.getName().equals("listBooksWithRecentEditions")) { + assertTrue(sql.contains("DECLARE $since_year AS Int32;"), sql); + assertTrue(sql.contains("SELECT (recent.author_id, recent.book_type)"), sql); + } + } if (method.getName().equals("booksByTags")) { assertTrue(sql.contains("Yson::ConvertToStringList"), sql); assertTrue(sql.toLowerCase().contains("left outer join") || sql.toLowerCase().contains("left join"), sql); @@ -65,7 +73,7 @@ void everyExampleRendersAndBindsThroughThePublishedDialect() throws Exception { } } } - assertEquals(55, statements.size()); + assertEquals(58, statements.size()); } @Test void declaredQueryReadsDialectCarriers() throws Exception { diff --git a/tests/examples/kotlin/pom.xml b/tests/examples/kotlin/pom.xml index 48fd543c..8e191dee 100644 --- a/tests/examples/kotlin/pom.xml +++ b/tests/examples/kotlin/pom.xml @@ -72,6 +72,9 @@ ${project.basedir}/../../../examples/namespaces/kotlin/nativeapi ${project.basedir}/../../../examples/namespaces/kotlin/jdbc ${project.basedir}/../../../examples/namespaces/kotlin/exposed + ${project.basedir}/../../../examples/booktest/kotlin/nativeapi + ${project.basedir}/../../../examples/booktest/kotlin/jdbc + ${project.basedir}/../../../examples/booktest/kotlin/exposed