From f7cb18c756ecf2e389eaffc26f499392b25c44e8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:28:05 +0000 Subject: [PATCH 1/3] Document Ruby 3.4, option validation, keyword naming and transaction exits Co-Authored-By: Doron Eli Rachman --- CLAUDE.md | 2 +- README.md | 12 +++++++----- Steepfile | 2 +- docs/Design.md | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37e1a6b..e98a5f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ UPDATE_GOLDEN=1 bundle exec ruby -Itest test/extended_golden_test.rb # rewrite * **The plugin runs inside ruby.wasm**: only Ruby core + `json`; no gems, no C extensions, no filesystem, no network. Anything `require`d by `lib/` or `exe/` must exist in the WASM (`scripts/wasm/build.sh` stages the stdlib). Gems used only by tests/tooling go in the `Gemfile` groups, never in `lib/`. -* **Generated code must stay valid for Ruby 3.1–3.3** (`Struct` below 3.2, `Data` from 3.2) and RuboCop/Steep-clean. +* **Generated code must stay valid for Ruby 3.1–3.4** (`Struct` below 3.2, `Data` from 3.2) and RuboCop/Steep-clean. * Generated code is plain, idiomatic Ruby (keyword args, `Data` rows, implicit returns, 2-space indent); do not mirror the C# sibling plugin's style. Parameter *values* are never logged. * Prepared statements are per connection (`ConnectionState`) because the query object is shared by pooled connections. diff --git a/README.md b/README.md index b2c2f3a..98adb55 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ codec), and it is shipped as a single self-contained WebAssembly module — the | Option | Possible values | Optional | Info | |-----------------|------------------------------------------------|----------|-----------------------------------------------------------------------------------------------------------| | driver | values: `mysql2`,`pg`, `sqlite3` | No | Choosing the driver to use - see [here](docs/Examples.md) for more details info. | -| rubyVersion | default: `3.3`
values: `3.1`, `3.2`, `3.3` | Yes | Determines the Ruby version the generated code should support.. | +| rubyVersion | default: `3.3`
values: `3.1` - `3.4` | Yes | Determines the Ruby version the generated code should support.. | | generateGemfile | default: `false`
values: `false`,`true` | Yes | Assists you with the integration of SQLC and Ruby by generating a `Gemfile` with the needed dependencies. | | generateTypes | default: `true`
values: `false`,`true` | Yes | Determines whether to generate type signatures in addition to the code. | | overrides | a list, see [below](#override-option) | Yes | Overrides the generated (RBS) type of specific columns / parameters. | | moduleName | default: `Codegen` (e.g. `PgCodegen`) | Yes | The Ruby module the generated code lives in, e.g. `MyApp::Db`. | | preparedStatements | default: `true`
values: `false`,`true` | Yes | `pg` only: `false` sends the SQL with every call (`exec_params`) - needed behind pgbouncer in transaction mode. | +Unknown option names (e.g. a typo such as `moduleNmae`) and `preparedStatements` with a driver other than `pg` are errors. + ### Override option ```yaml overrides: @@ -96,15 +98,15 @@ queries.list_authors_paged(limit: 10, offset: 0) # :many -> Array of r queries.get_authors_by_ids(ids: [1, 2, 3]) # sqlc.slice / array parameter queries.copy_authors([PgCodegen::CopyAuthorsArgs.new(name: 'a', bio: nil)]) # :copyfrom -queries.transaction(isolation: :serializable) do |tx| # COMMIT on return, ROLLBACK on error +queries.transaction(isolation: :serializable) do |tx| # COMMIT on return (also break / return / throw), ROLLBACK on error id = tx.create_author_return_id(name: 'a', bio: nil) tx.transaction { |inner| inner.create_book(name: 'b', author_id: id) } # nested -> SAVEPOINT end ``` -* **Keyword arguments:** every query parameter is a keyword argument (missing / unknown ones raise `ArgumentError`). Parameters named with `sqlc.arg` / `sqlc.narg` that are nullable default to `nil`; a plain nullable column (`INSERT ... VALUES ($1, $2)`) has to be passed explicitly. Names are snake_cased, Ruby keywords get a trailing underscore (`class_:`). +* **Keyword arguments:** every query parameter is a keyword argument (missing / unknown ones raise `ArgumentError`). Parameters named with `sqlc.arg` / `sqlc.narg` that are nullable default to `nil`; a plain nullable column (`INSERT ... VALUES ($1, $2)`) has to be passed explicitly. Names are snake_cased, Ruby keywords and the method's own locals (`client`, `sql`, ...) get a trailing underscore (`class_:`); a `sqlc.arg` used twice is one keyword, repeated `?` of the same column become `id:, id_2:, id_3:`. * **Bang finders:** every `:one` query also gets a `get_author!` that raises `QuerySql::NotFound` instead of returning `nil` (like `find_by` vs `find`). * **Logging:** `QuerySql.connect(params, logger: Rails.logger)` / `QuerySql.new(pool, logger: logger)` logs `QuerySql#get_author (0.4ms)` at debug level for every query and transaction (SQL parameters are never logged). Without a logger there is no overhead. -* **Rows** are immutable `Data` objects (`Struct` for `rubyVersion` below 3.2), so they work with pattern matching: `case row in { id:, name: }`. +* **Rows** are immutable `Data` objects (a `keyword_init` `Struct` for `rubyVersion` below 3.2; both are built with keywords: `Row.new(id: 1, name: 'a')`), so they work with pattern matching: `case row in { id:, name: }`. Column names that are not valid identifiers are snake_cased (`"Weird Name"` -> `weird_name`), names of the row's own methods get a trailing underscore (`hash` -> `hash_`). * **Connections:** `QuerySql.connect(params, **pool_options)` builds a [`connection_pool`](https://github.com/mperham/connection_pool); to share a pool you already have (or to inject a fake in tests) use `QuerySql.new(pool)` - anything that responds to `#with` and yields a connection. For SQLite pass a `SQLite3::Database` to `QuerySql.new(db)`. * **Async / fibers:** the generated code is plain synchronous Ruby and works with the Fiber scheduler (the [`async`](https://github.com/socketry/async) gem): inside `Sync { ... }` pooled queries on `pg` and `mysql2` overlap instead of blocking (see `tests/end2end_pg.rb`). SQLite calls are synchronous C calls and do not overlap. * **SQL** is kept in frozen heredoc constants (`GET_AUTHOR_SQL`); query comments in the `.sql` file become doc comments on the method; `.rbs` signatures are generated for everything. @@ -133,7 +135,7 @@ end ### Other * **Models:** a `models.rb` / `models.rbs` with one `Data` (`Struct` below Ruby 3.2) class per table, and one module with constants per enum (`ExtendedBioType::MEMOIR`, `ExtendedBioType::ALL`). Enum columns are `String`s. -* **Transactions:** `queries.transaction { |tx| tx.create_author(...) }` runs everything in the block on one connection in a transaction (commit when the block returns, rollback when it raises) and returns the block's value. Nested calls become savepoints; `isolation:` (`pg`, `mysql2`) / `mode:` (`sqlite3`) choose the isolation level / locking mode. +* **Transactions:** `queries.transaction { |tx| tx.create_author(...) }` runs everything in the block on one connection in a transaction (commit when the block returns, also through `break` / `return` / `throw`; rollback when it raises or the thread is killed) and returns the block's value. Nested calls become savepoints; `isolation:` (`pg`, `mysql2`) / `mode:` (`sqlite3`) choose the isolation level / locking mode. * **Nullability:** nullable columns / parameters are typed `T?` in the `.rbs`. * **Arrays (PostgreSQL):** `Array[T]` for columns and parameters. * Types follow the Ruby classes the drivers actually return (e.g. `BigDecimal` for `numeric`/`decimal`, `Date`, `Time`, `IPAddr`; `json`/`jsonb` are parsed, so `untyped`). diff --git a/Steepfile b/Steepfile index dea21da..cf78099 100644 --- a/Steepfile +++ b/Steepfile @@ -2,6 +2,6 @@ target :gen do check "examples" signature "examples" - # stdlib signatures for the types the generated code refers to (BigDecimal, Date, Time, IPAddr, Tempfile; Set is core since RBS 4, CSV has no useful signature) + # stdlib signatures for the types the generated code refers to (BigDecimal, Date, Time, IPAddr, Tempfile; Set is core since RBS 4) library "bigdecimal", "date", "time", "ipaddr", "tempfile" end diff --git a/docs/Design.md b/docs/Design.md index 25373fd..f2fe36b 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -10,7 +10,7 @@ process or as a WASI module. This plugin is pure Ruby and is distributed as a WA | `exe/plugin.rb` | entry point (stdin → `Generator` → stdout) | | `lib/sqlc_gen_ruby/proto.rb` | ~150 line protobuf wire codec. `google-protobuf` is a C extension and cannot be loaded inside ruby.wasm, so the plugin brings its own | | `lib/sqlc_gen_ruby/plugin_pb.rb` | the subset of sqlc's `codegen.proto` the generator needs (field numbers must match upstream) | -| `lib/sqlc_gen_ruby/options.rb`, `ruby_version.rb` | the `options:` JSON, `rubyVersion` parsing / validation (3.1 – 3.3; `Struct` below 3.2, `Data` from 3.2) | +| `lib/sqlc_gen_ruby/options.rb`, `ruby_version.rb` | the `options:` JSON, `rubyVersion` parsing / validation (3.1 – 3.4; `Struct` below 3.2, `Data` from 3.2) | | `lib/sqlc_gen_ruby/generator.rb` | module / class / dataclass (`Data.define`, `Struct.new`) layout, `.rb` / `.rbs` / `Gemfile` files, `models.rb` (tables + enums) | | `lib/sqlc_gen_ruby/method_gen.rb` | one generated method per query: `:one`, `:many`, `:exec`, `:execrows`, `:execlastid`, `:copyfrom`, `sqlc.slice` | | `lib/sqlc_gen_ruby/row_layout.rb` | how result columns become `Row` attributes (`sqlc.embed` expands a table into one model attribute) | From c3978ed5c987869803186c8b94711b6a9b3bc30e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:28:14 +0000 Subject: [PATCH 2/3] Fix bugs found by end-to-end testing of v0.13.0 - MySQL: a reused sqlc.arg is one keyword; repeated ? get id, id_2, ...; rows are read by position so unaliased expressions (count(*)) work; :copyfrom quotes values so "NULL" stays a string - SQLite: types with modifiers (VARCHAR(255), DECIMAL(10,2)); :exec / :execrows with RETURNING run every row - PostgreSQL: bytea with NUL / non-UTF-8 bytes (parameters and COPY), JSON array parameters, enum[] / uuid[] decoded to arrays, :execlastid without a row returns nil, prepared statements are re-prepared after PG::Connection#reset - All drivers: transactions commit on break / return / throw instead of leaking an open transaction; :copyfrom quotes identifiers; casted sqlc.narg is optional; untyped (any) expressions generate as untyped - Names: parameters / columns that collide with generated locals or row methods, or are not valid identifiers, are renamed; colliding constants / methods are reported; lowercase query names get a valid Row class - Ruby 3.1: keyword_init Structs (+ RBS), RuboCop-clean output; rubyVersion 3.4 accepted; unknown options and preparedStatements on non-pg drivers are errors; nested moduleName output is self-contained Co-Authored-By: Doron Eli Rachman --- examples/authors/mysql/query.sql | 12 + examples/authors/mysql/schema.sql | 6 + examples/authors/postgresql/query.sql | 15 + examples/authors/postgresql/schema.sql | 9 + examples/authors/sqlite/query.sql | 18 + examples/authors/sqlite/schema.sql | 6 + examples/mysql2/models.rb | 3 + examples/mysql2/models.rbs | 7 + examples/mysql2/query_sql.rb | 227 +++++++---- examples/mysql2/query_sql.rbs | 34 ++ examples/pg/connection.rb | 8 +- examples/pg/connection.rbs | 9 +- examples/pg/models.rb | 10 + examples/pg/models.rbs | 17 + examples/pg/query_sql.rb | 189 ++++++++- examples/pg/query_sql.rbs | 73 ++++ examples/sqlite3/models.rb | 3 + examples/sqlite3/models.rbs | 7 + examples/sqlite3/query_sql.rb | 147 ++++++- examples/sqlite3/query_sql.rbs | 52 +++ lib/sqlc_gen_ruby/code.rb | 41 +- lib/sqlc_gen_ruby/drivers/base.rb | 22 +- lib/sqlc_gen_ruby/drivers/mysql2.rb | 46 ++- lib/sqlc_gen_ruby/drivers/pg.rb | 114 +++++- lib/sqlc_gen_ruby/drivers/sqlite3.rb | 30 +- lib/sqlc_gen_ruby/generator.rb | 76 +++- lib/sqlc_gen_ruby/method_gen.rb | 100 +++-- lib/sqlc_gen_ruby/options.rb | 7 + lib/sqlc_gen_ruby/row_layout.rb | 6 +- lib/sqlc_gen_ruby/ruby_version.rb | 2 +- lib/sqlc_gen_ruby/type_resolver.rb | 19 +- steep_expectations.yml | 132 +++++-- test/features_test.rb | 111 +++++- test/fixtures/mysql2.bin | 25 +- test/fixtures/pg.bin | Bin 11146 -> 13779 bytes test/fixtures/sqlite3.bin | Bin 4771 -> 6156 bytes test/golden/mysql2/default/models.rb | 3 + test/golden/mysql2/default/models.rbs | 7 + test/golden/mysql2/default/my_queries_sql.rb | 23 +- test/golden/mysql2/default/other_sql.rb | 27 +- test/golden/mysql2/default/query_sql.rb | 249 ++++++++---- test/golden/mysql2/default/query_sql.rbs | 36 +- test/golden/mysql2/ruby31_gemfile/Gemfile | 3 +- test/golden/mysql2/ruby31_gemfile/models.rb | 29 +- test/golden/mysql2/ruby31_gemfile/models.rbs | 138 ++++--- .../mysql2/ruby31_gemfile/my_queries_sql.rb | 23 +- .../golden/mysql2/ruby31_gemfile/other_sql.rb | 30 +- .../mysql2/ruby31_gemfile/other_sql.rbs | 5 +- .../golden/mysql2/ruby31_gemfile/query_sql.rb | 332 ++++++++++------ .../mysql2/ruby31_gemfile/query_sql.rbs | 238 +++++++---- test/golden/mysql2/ruby32_notypes/models.rb | 3 + .../mysql2/ruby32_notypes/my_queries_sql.rb | 23 +- .../golden/mysql2/ruby32_notypes/other_sql.rb | 27 +- .../golden/mysql2/ruby32_notypes/query_sql.rb | 249 ++++++++---- test/golden/pg/default/connection.rb | 8 +- test/golden/pg/default/connection.rbs | 9 +- test/golden/pg/default/models.rb | 10 + test/golden/pg/default/models.rbs | 17 + test/golden/pg/default/my_queries_sql.rb | 33 +- test/golden/pg/default/my_queries_sql.rbs | 2 + test/golden/pg/default/other_sql.rb | 40 +- test/golden/pg/default/other_sql.rbs | 4 + test/golden/pg/default/query_sql.rb | 193 ++++++++- test/golden/pg/default/query_sql.rbs | 75 +++- test/golden/pg/ruby31_gemfile/Gemfile | 4 +- test/golden/pg/ruby31_gemfile/connection.rb | 8 +- test/golden/pg/ruby31_gemfile/connection.rbs | 9 +- test/golden/pg/ruby31_gemfile/models.rb | 37 +- test/golden/pg/ruby31_gemfile/models.rbs | 126 ++++-- .../pg/ruby31_gemfile/my_queries_sql.rb | 33 +- .../pg/ruby31_gemfile/my_queries_sql.rbs | 2 + test/golden/pg/ruby31_gemfile/other_sql.rb | 45 ++- test/golden/pg/ruby31_gemfile/other_sql.rbs | 9 +- test/golden/pg/ruby31_gemfile/query_sql.rb | 373 +++++++++++++----- test/golden/pg/ruby31_gemfile/query_sql.rbs | 278 +++++++++---- test/golden/pg/ruby32_notypes/connection.rb | 8 +- test/golden/pg/ruby32_notypes/models.rb | 10 + .../pg/ruby32_notypes/my_queries_sql.rb | 33 +- test/golden/pg/ruby32_notypes/other_sql.rb | 40 +- test/golden/pg/ruby32_notypes/query_sql.rb | 193 ++++++++- test/golden/sqlite3/default/models.rb | 3 + test/golden/sqlite3/default/models.rbs | 7 + test/golden/sqlite3/default/my_queries_sql.rb | 19 +- test/golden/sqlite3/default/other_sql.rb | 19 +- test/golden/sqlite3/default/query_sql.rb | 149 ++++++- test/golden/sqlite3/default/query_sql.rbs | 54 ++- test/golden/sqlite3/ruby31_gemfile/Gemfile | 4 +- test/golden/sqlite3/ruby31_gemfile/models.rb | 14 +- test/golden/sqlite3/ruby31_gemfile/models.rbs | 18 +- .../sqlite3/ruby31_gemfile/my_queries_sql.rb | 19 +- .../sqlite3/ruby31_gemfile/other_sql.rb | 24 +- .../sqlite3/ruby31_gemfile/other_sql.rbs | 5 +- .../sqlite3/ruby31_gemfile/query_sql.rb | 242 +++++++++--- .../sqlite3/ruby31_gemfile/query_sql.rbs | 148 +++++-- test/golden/sqlite3/ruby32_notypes/models.rb | 3 + .../sqlite3/ruby32_notypes/my_queries_sql.rb | 19 +- .../sqlite3/ruby32_notypes/other_sql.rb | 19 +- .../sqlite3/ruby32_notypes/query_sql.rb | 149 ++++++- test/options_test.rb | 8 +- tests/end2end_mysql2.rb | 40 ++ tests/end2end_pg.rb | 60 +++ tests/end2end_sqlite3.rb | 33 ++ 102 files changed, 4528 insertions(+), 1155 deletions(-) diff --git a/examples/authors/mysql/query.sql b/examples/authors/mysql/query.sql index 7a60060..3f25a8c 100644 --- a/examples/authors/mysql/query.sql +++ b/examples/authors/mysql/query.sql @@ -88,3 +88,15 @@ SELECT * FROM extended_bios WHERE bio_type = ? LIMIT 1; -- name: TruncateExtendedBios :exec DELETE FROM extended_bios; + +-- name: CountRegressionsByText :one +SELECT count(*) FROM regressions WHERE `order` = sqlc.arg(term) OR note = sqlc.arg(term); + +-- name: ListRegressionsByIds :many +SELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY id; + +-- name: CopyRegressions :copyfrom +INSERT INTO regressions (`order`, note) VALUES (?, ?); + +-- name: TruncateRegressions :exec +TRUNCATE TABLE regressions; diff --git a/examples/authors/mysql/schema.sql b/examples/authors/mysql/schema.sql index 58a8c07..ebde0cd 100644 --- a/examples/authors/mysql/schema.sql +++ b/examples/authors/mysql/schema.sql @@ -63,3 +63,9 @@ CREATE TABLE extended_bios ( author_type SET('Author', 'Editor', 'Translator'), PRIMARY KEY (author_name, name) ); + +CREATE TABLE regressions ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + `order` varchar(255) NOT NULL, + note text +); diff --git a/examples/authors/postgresql/query.sql b/examples/authors/postgresql/query.sql index b6d24be..035c13f 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -101,3 +101,18 @@ SELECT * FROM postgres_extra_types LIMIT 1; -- name: TruncateExtraTypes :exec TRUNCATE TABLE postgres_extra_types; + +-- name: CreateRegression :one +INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: CreateRegressionIfNew :execlastid +INSERT INTO regressions (id, "order") VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id; + +-- name: CopyRegressions :copyfrom +INSERT INTO regressions (id, "order", payload, doc) VALUES ($1, $2, $3, $4); + +-- name: ListRegressions :many +SELECT * FROM regressions WHERE (sqlc.narg(order_name)::text IS NULL OR "order" = sqlc.narg(order_name)) ORDER BY id; + +-- name: TruncateRegressions :exec +TRUNCATE TABLE regressions; diff --git a/examples/authors/postgresql/schema.sql b/examples/authors/postgresql/schema.sql index 6540944..3afb157 100644 --- a/examples/authors/postgresql/schema.sql +++ b/examples/authors/postgresql/schema.sql @@ -58,3 +58,12 @@ CREATE TABLE postgres_extra_types ( c_money MONEY, c_not_null TEXT NOT NULL DEFAULT 'x' ); + +CREATE TABLE regressions ( + id bigint PRIMARY KEY, + "order" text NOT NULL, + payload bytea, + doc jsonb, + bio_types extended.bio_type[], + uids uuid[] +); diff --git a/examples/authors/sqlite/query.sql b/examples/authors/sqlite/query.sql index 0c77ede..7dd6f3b0 100644 --- a/examples/authors/sqlite/query.sql +++ b/examples/authors/sqlite/query.sql @@ -73,3 +73,21 @@ SELECT sqlc.embed(books) FROM authors INNER JOIN books ON authors.id = books.author_id WHERE books.name = ?; + +-- name: CreateRegression :exec +INSERT INTO regressions ("order", price) VALUES (?, ?) RETURNING id; + +-- name: UpdateRegressionPrices :execrows +UPDATE regressions SET price = ? RETURNING id; + +-- name: CopyRegressions :copyfrom +INSERT INTO regressions ("order", price) VALUES (?, ?); + +-- name: ListRegressions :many +SELECT id, "order", price FROM regressions ORDER BY id; + +-- name: GetMaxRegressionPrice :one +SELECT coalesce(max(price), 0) AS max_price FROM regressions; + +-- name: TruncateRegressions :exec +DELETE FROM regressions; diff --git a/examples/authors/sqlite/schema.sql b/examples/authors/sqlite/schema.sql index 549d6d4..e8ebd30 100644 --- a/examples/authors/sqlite/schema.sql +++ b/examples/authors/sqlite/schema.sql @@ -12,3 +12,9 @@ CREATE TABLE books ( description TEXT, FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE CASCADE ); + +CREATE TABLE regressions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + "order" VARCHAR(255) NOT NULL, + price DECIMAL(10,2) +); diff --git a/examples/mysql2/models.rb b/examples/mysql2/models.rb index 3c5c04f..2bce311 100644 --- a/examples/mysql2/models.rb +++ b/examples/mysql2/models.rb @@ -66,4 +66,7 @@ class Book < Data.define(:id, :name, :author_id, :description) class ExtendedBio < Data.define(:author_name, :name, :bio_type, :author_type) end + + class Regression < Data.define(:id, :order, :note) + end end diff --git a/examples/mysql2/models.rbs b/examples/mysql2/models.rbs index f1926fc..e19b214 100644 --- a/examples/mysql2/models.rbs +++ b/examples/mysql2/models.rbs @@ -115,4 +115,11 @@ module Mysql2Codegen attr_reader author_type: String? def initialize: ( String? author_name, String? name, String? bio_type, String? author_type) -> void end + + class Regression + attr_reader id: Integer + attr_reader order: String + attr_reader note: String? + def initialize: ( Integer id, String order, String? note) -> void + end end diff --git a/examples/mysql2/query_sql.rb b/examples/mysql2/query_sql.rb index 2382175..27ebb40 100644 --- a/examples/mysql2/query_sql.rb +++ b/examples/mysql2/query_sql.rb @@ -3,7 +3,6 @@ # auto-generated by sqlc - do not edit require 'connection_pool' require 'mysql2' -require 'csv' require 'tempfile' require_relative 'connection' require_relative 'models' @@ -188,6 +187,27 @@ class GetFirstExtendedBioByTypeRow < Data.define(:author_name, :name, :bio_type, DELETE FROM extended_bios SQL + COUNT_REGRESSIONS_BY_TEXT_SQL = <<~'SQL' + SELECT count(*) FROM regressions WHERE `order` = ? OR note = ? + SQL + + class CountRegressionsByTextRow < Data.define(:count) + end + + LIST_REGRESSIONS_BY_IDS_SQL = <<~'SQL' + SELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY id + SQL + + class ListRegressionsByIdsRow < Data.define(:id, :order, :note) + end + + class CopyRegressionsArgs < Data.define(:order, :note) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -205,8 +225,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -218,15 +239,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -237,9 +266,9 @@ def get_author(id:) query_params = [id] stmt = client.prepare(GET_AUTHOR_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetAuthorRow.new(row['id'], row['name'], row['bio']) + row && GetAuthorRow.new(row[0], row[1], row[2]) ensure stmt.close end @@ -255,9 +284,9 @@ def list_authors with_client('list_authors') do |client| stmt = client.prepare(LIST_AUTHORS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - ListAuthorsRow.new(row['id'], row['name'], row['bio']) + ListAuthorsRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -321,46 +350,46 @@ def test with_client('test') do |client| stmt = client.prepare(TEST_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first row && TestRow.new( - row['c_bit'], - row['c_tinyint'], - row['c_bool'], - row['c_boolean'], - row['c_smallint'], - row['c_mediumint'], - row['c_int'], - row['c_integer'], - row['c_bigint'], - row['c_serial'], - row['c_decimal'], - row['c_dec'], - row['c_numeric'], - row['c_fixed'], - row['c_float'], - row['c_double'], - row['c_double_precision'], - row['c_date'], - row['c_time'], - row['c_datetime'], - row['c_timestamp'], - row['c_year'], - row['c_char'], - row['c_nchar'], - row['c_national_char'], - row['c_varchar'], - row['c_binary'], - row['c_varbinary'], - row['c_tinyblob'], - row['c_tinytext'], - row['c_blob'], - row['c_text'], - row['c_mediumblob'], - row['c_mediumtext'], - row['c_longblob'], - row['c_longtext'], - row['c_json'] + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + row[12], + row[13], + row[14], + row[15], + row[16], + row[17], + row[18], + row[19], + row[20], + row[21], + row[22], + row[23], + row[24], + row[25], + row[26], + row[27], + row[28], + row[29], + row[30], + row[31], + row[32], + row[33], + row[34], + row[35], + row[36] ) ensure stmt.close @@ -378,9 +407,9 @@ def list_authors_paged(limit:, offset:) query_params = [limit, offset] stmt = client.prepare(LIST_AUTHORS_PAGED_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - ListAuthorsPagedRow.new(row['id'], row['name'], row['bio']) + ListAuthorsPagedRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -393,9 +422,9 @@ def get_author_by_name_pattern(name_pattern: nil) query_params = [name_pattern] stmt = client.prepare(GET_AUTHOR_BY_NAME_PATTERN_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorByNamePatternRow.new(row['id'], row['name'], row['bio']) + GetAuthorByNamePatternRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -420,12 +449,12 @@ def update_author_bios(bio: nil) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -436,13 +465,13 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsAndNamesRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsAndNamesRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -460,7 +489,7 @@ def copy_authors(rows) file.write(csv_line([row.name, row.bio])) end file.flush - client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE authors FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (name, bio)") + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") end nil end @@ -497,7 +526,10 @@ def list_all_authors_books begin result = stmt.execute(as: :array) result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end ensure stmt.close @@ -552,9 +584,9 @@ def get_first_extended_bio_by_type(bio_type:) query_params = [bio_type] stmt = client.prepare(GET_FIRST_EXTENDED_BIO_BY_TYPE_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetFirstExtendedBioByTypeRow.new(row['author_name'], row['name'], row['bio_type'], row['author_type']) + row && GetFirstExtendedBioByTypeRow.new(row[0], row[1], row[2], row[3]) ensure stmt.close end @@ -578,6 +610,67 @@ def truncate_extended_bios end end + def count_regressions_by_text(term:) + with_client('count_regressions_by_text') do |client| + query_params = [term, term] + stmt = client.prepare(COUNT_REGRESSIONS_BY_TEXT_SQL) + begin + result = stmt.execute(*query_params, as: :array) + row = result.first + row && CountRegressionsByTextRow.new(row[0]) + ensure + stmt.close + end + end + end + + # Like #count_regressions_by_text, but raises NotFound when there is no row. + def count_regressions_by_text!(...) + count_regressions_by_text(...) || raise(NotFound, 'CountRegressionsByText returned no row') + end + + def list_regressions_by_ids(id:, id_2:, id_3:) + with_client('list_regressions_by_ids') do |client| + query_params = [id, id_2, id_3] + stmt = client.prepare(LIST_REGRESSIONS_BY_IDS_SQL) + begin + result = stmt.execute(*query_params, as: :array) + result.map do |row| + ListRegressionsByIdsRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + return if rows.empty? + + Tempfile.create(['copy_regressions', '.csv']) do |file| + rows.each do |row| + file.write(csv_line([row.order, row.note])) + end + file.flush + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + end + nil + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + private def instrument(name) @@ -609,10 +702,10 @@ def csv_line(values) when true then '1' when false then '0' when Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') - else value.to_s.gsub('\\') { '\\\\' } + else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""')}") end end - CSV.generate_line(fields) + "#{fields.join(',')}\n" end def slice_placeholders(values) diff --git a/examples/mysql2/query_sql.rbs b/examples/mysql2/query_sql.rbs index 7b2a71b..ce57615 100644 --- a/examples/mysql2/query_sql.rbs +++ b/examples/mysql2/query_sql.rbs @@ -195,6 +195,30 @@ module Mysql2Codegen TRUNCATE_EXTENDED_BIOS_SQL: String + COUNT_REGRESSIONS_BY_TEXT_SQL: String + + class CountRegressionsByTextRow + attr_reader count: Integer + def initialize: ( Integer count) -> void + end + + LIST_REGRESSIONS_BY_IDS_SQL: String + + class ListRegressionsByIdsRow + attr_reader id: Integer + attr_reader order: String + attr_reader note: String? + def initialize: ( Integer id, String order, String? note) -> void + end + + class CopyRegressionsArgs + attr_reader order: String + attr_reader note: String? + def initialize: ( String order, String? note) -> void + end + + TRUNCATE_REGRESSIONS_SQL: String + class QuerySql class NotFound < StandardError end @@ -253,6 +277,16 @@ module Mysql2Codegen def truncate_extended_bios: -> void + def count_regressions_by_text: (term: String) -> CountRegressionsByTextRow? + + def count_regressions_by_text!: (term: String) -> CountRegressionsByTextRow + + def list_regressions_by_ids: (id: Integer, id_2: Integer, id_3: Integer) -> Array[ListRegressionsByIdsRow] + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def truncate_regressions: -> void + private def instrument: [T] (String name) { () -> T } -> T diff --git a/examples/pg/connection.rb b/examples/pg/connection.rb index a6a4492..22ecd81 100644 --- a/examples/pg/connection.rb +++ b/examples/pg/connection.rb @@ -5,8 +5,14 @@ module PgCodegen # State kept on every PG::Connection the first time it is used. module ConnectionState + # Names of the statements prepared on this connection; forgotten when the server session changes + # (PG::Connection#reset after a failover / terminated backend). def prepared_statements - @prepared_statements ||= Set[] + if @prepared_statements_pid != backend_pid + @prepared_statements = Set[] + @prepared_statements_pid = backend_pid + end + @prepared_statements end end diff --git a/examples/pg/connection.rbs b/examples/pg/connection.rbs index 3b51951..af2278b 100644 --- a/examples/pg/connection.rbs +++ b/examples/pg/connection.rbs @@ -1,6 +1,13 @@ # auto-generated by sqlc - do not edit module PgCodegen - module ConnectionState + interface _PgConnection + def backend_pid: () -> Integer + end + + module ConnectionState : _PgConnection + @prepared_statements: Set[String] + @prepared_statements_pid: Integer + def prepared_statements: () -> Set[String] end diff --git a/examples/pg/models.rb b/examples/pg/models.rb index 6d0eee8..0196340 100644 --- a/examples/pg/models.rb +++ b/examples/pg/models.rb @@ -50,6 +50,16 @@ class PostgresExtraType < Data.define( ) end + class Regression < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + class ExtendedBio < Data.define(:author_name, :name, :bio_type) end end diff --git a/examples/pg/models.rbs b/examples/pg/models.rbs index b97a45b..a96af6a 100644 --- a/examples/pg/models.rbs +++ b/examples/pg/models.rbs @@ -84,6 +84,23 @@ module PgCodegen ) -> void end + class Regression + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + attr_reader bio_types: Array[String]? + attr_reader uids: Array[String]? + def initialize: ( + Integer id, + String order, + String? payload, + untyped doc, + Array[String]? bio_types, + Array[String]? uids + ) -> void + end + class ExtendedBio attr_reader author_name: String attr_reader name: String diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index 9849d78..43d5da8 100644 --- a/examples/pg/query_sql.rb +++ b/examples/pg/query_sql.rb @@ -203,6 +203,48 @@ class GetExtraTypesRow < Data.define( TRUNCATE TABLE postgres_extra_types SQL + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids + SQL + + class CreateRegressionRow < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + + CREATE_REGRESSION_IF_NEW_SQL = <<~'SQL' + INSERT INTO regressions (id, "order") VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id + SQL + + class CreateRegressionIfNewRow < Data.define(:id) + end + + class CopyRegressionsArgs < Data.define(:id, :order, :payload, :doc) + end + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", payload, doc, bio_types, uids FROM regressions WHERE ($1::text IS NULL OR "order" = $1) ORDER BY id + SQL + + class ListRegressionsRow < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -220,8 +262,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -231,13 +274,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -376,7 +425,8 @@ def create_author_return_id(name:, bio:) client.prepared_statements.add('query_sql_create_author_return_id') end result = client.exec_prepared('query_sql_create_author_return_id', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -425,7 +475,7 @@ def get_authors_by_ids_and_names(param1:, param2:) def copy_authors(rows) with_client('copy_authors') do |client| encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) - client.copy_data('COPY authors (name, bio) FROM STDIN', encoder) do + client.copy_data('COPY "authors" ("name", "bio") FROM STDIN', encoder) do rows.each do |row| client.put_copy_data([row.name, row.bio]) end @@ -453,7 +503,8 @@ def create_book(name:, author_id:) client.prepared_statements.add('query_sql_create_book') end result = client.exec_prepared('query_sql_create_book', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -465,7 +516,10 @@ def list_all_authors_books end result = client.exec_prepared('query_sql_list_all_authors_books').values result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end end end @@ -542,7 +596,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim with_client('insert_extra_types') do |client| query_params = [ c_uuid, - c_jsonb, + encode_json(c_jsonb), c_text_array, c_integer_array, c_timestamptz, @@ -596,6 +650,95 @@ def truncate_extra_types end end + def create_regression(id:, order:, payload:, doc:, bio_types:, uids:) + with_client('create_regression') do |client| + query_params = [ + id, + order, + encode_bytea(payload), + encode_json(doc), + bio_types, + uids + ] + unless client.prepared_statements.include?('query_sql_create_regression') + client.prepare('query_sql_create_regression', CREATE_REGRESSION_SQL) + client.prepared_statements.add('query_sql_create_regression') + end + result = client.exec_prepared('query_sql_create_regression', query_params) + row = result.first + row && CreateRegressionRow.new( + row['id'], + row['order'], + row['payload'], + row['doc'], + row['bio_types'], + row['uids'] + ) + end + end + + # Like #create_regression, but raises NotFound when there is no row. + def create_regression!(...) + create_regression(...) || raise(NotFound, 'CreateRegression returned no row') + end + + def create_regression_if_new(id:, order:) + with_client('create_regression_if_new') do |client| + query_params = [id, order] + unless client.prepared_statements.include?('query_sql_create_regression_if_new') + client.prepare('query_sql_create_regression_if_new', CREATE_REGRESSION_IF_NEW_SQL) + client.prepared_statements.add('query_sql_create_regression_if_new') + end + result = client.exec_prepared('query_sql_create_regression_if_new', query_params) + row = result.first + row && row['id'] + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) + client.copy_data('COPY "regressions" ("id", "order", "payload", "doc") FROM STDIN', encoder) do + rows.each do |row| + client.put_copy_data([row.id, row.order, encode_bytea(row.payload), encode_json(row.doc)]) + end + end + nil + end + end + + def list_regressions(order_name: nil) + with_client('list_regressions') do |client| + query_params = [order_name] + unless client.prepared_statements.include?('query_sql_list_regressions') + client.prepare('query_sql_list_regressions', LIST_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_list_regressions') + end + result = client.exec_prepared('query_sql_list_regressions', query_params) + result.map do |row| + ListRegressionsRow.new( + row['id'], + row['order'], + row['payload'], + row['doc'], + row['bio_types'], + row['uids'] + ) + end + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + unless client.prepared_statements.include?('query_sql_truncate_regressions') + client.prepare('query_sql_truncate_regressions', TRUNCATE_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_truncate_regressions') + end + client.exec_prepared('query_sql_truncate_regressions') + nil + end + end + private def instrument(name) @@ -622,10 +765,32 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end + + # json / jsonb parameters: an Array is a JSON array, not a PG array (Hashes and JSON text pass through). + def encode_json(value) + value.is_a?(Array) ? PG::TextEncoder::JSON.new.encode(value) : value + end end end diff --git a/examples/pg/query_sql.rbs b/examples/pg/query_sql.rbs index 1742e90..38dc3aa 100644 --- a/examples/pg/query_sql.rbs +++ b/examples/pg/query_sql.rbs @@ -198,6 +198,61 @@ module PgCodegen TRUNCATE_EXTRA_TYPES_SQL: String + CREATE_REGRESSION_SQL: String + + class CreateRegressionRow + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + attr_reader bio_types: Array[String]? + attr_reader uids: Array[String]? + def initialize: ( + Integer id, + String order, + String? payload, + untyped doc, + Array[String]? bio_types, + Array[String]? uids + ) -> void + end + + CREATE_REGRESSION_IF_NEW_SQL: String + + class CreateRegressionIfNewRow + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + class CopyRegressionsArgs + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + def initialize: ( Integer id, String order, String? payload, untyped doc) -> void + end + + LIST_REGRESSIONS_SQL: String + + class ListRegressionsRow + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + attr_reader bio_types: Array[String]? + attr_reader uids: Array[String]? + def initialize: ( + Integer id, + String order, + String? payload, + untyped doc, + Array[String]? bio_types, + Array[String]? uids + ) -> void + end + + TRUNCATE_REGRESSIONS_SQL: String + class QuerySql class NotFound < StandardError end @@ -264,6 +319,18 @@ module PgCodegen def truncate_extra_types: -> void + def create_regression: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow? + + def create_regression!: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow + + def create_regression_if_new: (id: Integer, order: String) -> Integer + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def list_regressions: (?order_name: String?) -> Array[ListRegressionsRow] + + def truncate_regressions: -> void + private def instrument: [T] (String name) { () -> T } -> T @@ -271,5 +338,11 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped + + def encode_bytea: (String? value) -> String? + + def encode_json: (untyped value) -> untyped end end diff --git a/examples/sqlite3/models.rb b/examples/sqlite3/models.rb index bf6966f..7df9524 100644 --- a/examples/sqlite3/models.rb +++ b/examples/sqlite3/models.rb @@ -8,4 +8,7 @@ class Author < Data.define(:id, :name, :bio) class Book < Data.define(:id, :name, :author_id, :description) end + + class Regression < Data.define(:id, :order, :price) + end end diff --git a/examples/sqlite3/models.rbs b/examples/sqlite3/models.rbs index cd9946a..afe0090 100644 --- a/examples/sqlite3/models.rbs +++ b/examples/sqlite3/models.rbs @@ -14,4 +14,11 @@ module Sqlite3Codegen attr_reader description: String? def initialize: ( Integer id, String name, Integer author_id, String? description) -> void end + + class Regression + attr_reader id: Integer + attr_reader order: String + attr_reader price: Float? + def initialize: ( Integer id, String order, Float? price) -> void + end end diff --git a/examples/sqlite3/query_sql.rb b/examples/sqlite3/query_sql.rb index 109053c..e693317 100644 --- a/examples/sqlite3/query_sql.rb +++ b/examples/sqlite3/query_sql.rb @@ -127,6 +127,41 @@ class GetDuplicateAuthorsRow < Data.define(:author, :author2) class GetAuthorsByBookNameRow < Data.define(:id, :name, :bio, :book) end + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions ("order", price) VALUES (?, ?) RETURNING id + SQL + + class CreateRegressionRow < Data.define(:id) + end + + UPDATE_REGRESSION_PRICES_SQL = <<~'SQL' + UPDATE regressions SET price = ? RETURNING id + SQL + + class UpdateRegressionPricesRow < Data.define(:id) + end + + class CopyRegressionsArgs < Data.define(:order, :price) + end + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", price FROM regressions ORDER BY id + SQL + + class ListRegressionsRow < Data.define(:id, :order, :price) + end + + GET_MAX_REGRESSION_PRICE_SQL = <<~'SQL' + SELECT coalesce(max(price), 0) AS max_price FROM regressions + SQL + + class GetMaxRegressionPriceRow < Data.define(:max_price) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + DELETE FROM regressions + SQL + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -138,20 +173,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -277,7 +319,7 @@ def update_author_bios(bio:) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) @@ -293,8 +335,8 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) @@ -311,7 +353,7 @@ def get_authors_by_ids_and_names(ids:, names:) def copy_authors(rows) with_client('copy_authors') do |client| rows.each_slice(499) do |batch| - sql = "INSERT INTO authors (name, bio) VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}" + sql = %(INSERT INTO "authors" ("name", "bio") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) client.execute(sql, batch.flat_map { |row| [row.name, row.bio] }) end nil @@ -350,7 +392,10 @@ def list_all_authors_books begin result = stmt.execute result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end ensure stmt.close @@ -387,6 +432,86 @@ def get_authors_by_book_name(name:) end end + def create_regression(order:, price:) + with_client('create_regression') do |client| + query_params = [order, price] + stmt = client.prepare(CREATE_REGRESSION_SQL) + begin + stmt.execute(*query_params).to_a + nil + ensure + stmt.close + end + end + end + + def update_regression_prices(price:) + with_client('update_regression_prices') do |client| + query_params = [price] + stmt = client.prepare(UPDATE_REGRESSION_PRICES_SQL) + begin + stmt.execute(*query_params).to_a + client.changes + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + rows.each_slice(499) do |batch| + sql = %(INSERT INTO "regressions" ("order", "price") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) + client.execute(sql, batch.flat_map { |row| [row.order, row.price] }) + end + nil + end + end + + def list_regressions + with_client('list_regressions') do |client| + stmt = client.prepare(LIST_REGRESSIONS_SQL) + begin + result = stmt.execute + result.map do |row| + ListRegressionsRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def get_max_regression_price + with_client('get_max_regression_price') do |client| + stmt = client.prepare(GET_MAX_REGRESSION_PRICE_SQL) + begin + result = stmt.execute + row = result.first + row && GetMaxRegressionPriceRow.new(row[0]) + ensure + stmt.close + end + end + end + + # Like #get_max_regression_price, but raises NotFound when there is no row. + def get_max_regression_price!(...) + get_max_regression_price(...) || raise(NotFound, 'GetMaxRegressionPrice returned no row') + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + private def instrument(name) diff --git a/examples/sqlite3/query_sql.rbs b/examples/sqlite3/query_sql.rbs index 6e7f0a0..4cb7912 100644 --- a/examples/sqlite3/query_sql.rbs +++ b/examples/sqlite3/query_sql.rbs @@ -108,6 +108,44 @@ module Sqlite3Codegen def initialize: ( Integer id, String name, String? bio, Book book) -> void end + CREATE_REGRESSION_SQL: String + + class CreateRegressionRow + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + UPDATE_REGRESSION_PRICES_SQL: String + + class UpdateRegressionPricesRow + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + class CopyRegressionsArgs + attr_reader order: String + attr_reader price: Float? + def initialize: ( String order, Float? price) -> void + end + + LIST_REGRESSIONS_SQL: String + + class ListRegressionsRow + attr_reader id: Integer + attr_reader order: String + attr_reader price: Float? + def initialize: ( Integer id, String order, Float? price) -> void + end + + GET_MAX_REGRESSION_PRICE_SQL: String + + class GetMaxRegressionPriceRow + attr_reader max_price: untyped + def initialize: ( untyped max_price) -> void + end + + TRUNCATE_REGRESSIONS_SQL: String + class QuerySql class NotFound < StandardError end @@ -150,6 +188,20 @@ module Sqlite3Codegen def get_authors_by_book_name: (name: String) -> Array[GetAuthorsByBookNameRow] + def create_regression: (order: String, price: Float?) -> void + + def update_regression_prices: (price: Float?) -> Integer + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def list_regressions: -> Array[ListRegressionsRow] + + def get_max_regression_price: -> GetMaxRegressionPriceRow? + + def get_max_regression_price!: -> GetMaxRegressionPriceRow + + def truncate_regressions: -> void + private def instrument: [T] (String name) { () -> T } -> T diff --git a/lib/sqlc_gen_ruby/code.rb b/lib/sqlc_gen_ruby/code.rb index 15a1b01..bfd61d2 100644 --- a/lib/sqlc_gen_ruby/code.rb +++ b/lib/sqlc_gen_ruby/code.rb @@ -14,8 +14,8 @@ def indent(text) text.split("\n", -1).map { |l| l.strip.empty? ? '' : "#{INDENT}#{l}" }.join("\n") end - def comma_join(items) - return items.join(', ') if items.size < MAX_ELEMENTS_PER_LINE + def comma_join(items, wrap: false) + return items.join(', ') if items.size < MAX_ELEMENTS_PER_LINE && !wrap "\n#{indent(items.join(",\n"))}\n" end @@ -57,12 +57,47 @@ def constant_name(value) module next nil not or redo rescue retry return self super then true undef unless until when while yield __FILE__ __LINE__ __ENCODING__].freeze - # sqlc column / parameter name -> a valid keyword-argument name (userId -> user_id, class -> class_) + # sqlc column / parameter name -> a valid keyword-argument name (userId -> user_id, class -> class_, + # "Weird Name" -> weird_name, 2col -> _2col) def param_name(name) snake = snake_case(name) + snake = identifier(snake.gsub(/[^\p{Word}]+/, '_').gsub(/_{2,}/, '_').delete_prefix('_')) unless snake.match?(LOCAL) RUBY_KEYWORDS.include?(snake) ? "#{snake}_" : snake end + LOCAL = /\A[\p{Ll}_][\p{Word}]*\z/.freeze + + # Public methods of every row object (Object + Data): a column with one of these names would override it. + OBJECT_METHODS = %w[__id__ __send__ class clone deconstruct deconstruct_keys define_singleton_method display dup + enum_for eql? equal? extend freeze frozen? hash inspect instance_of? instance_variable_defined? + instance_variable_get instance_variable_set instance_variables is_a? itself kind_of? members + method methods nil? object_id public_method public_send respond_to? send singleton_class + singleton_method singleton_methods tap then to_enum to_h to_s with yield_self].freeze + + # Struct (rows on Ruby < 3.2) also has Enumerable and accessors such as #size / #values. + STRUCT_METHODS = (OBJECT_METHODS + %w[all? any? chain chunk chunk_while collect collect_concat compact count cycle + detect dig drop drop_while each each_cons each_entry each_pair each_slice + each_with_index each_with_object entries filter filter_map find find_all + find_index first flat_map grep grep_v group_by include? inject lazy length + map max max_by member? min min_by minmax minmax_by none? one? partition + reduce reject reverse_each select size slice_after slice_before slice_when + sort sort_by sum take take_while tally to_a to_set uniq values values_at + zip]).freeze + + # Column name -> attribute of a Data / Struct row: valid identifier (case kept), and never one of the row's own + # methods (`class` -> class_, `hash` -> hash_). + def member_name(name, struct: false) + name = name.match?(/\A[\p{L}_][\p{Word}]*\z/) ? name : param_name(name) + name = "#{name}_" while (struct ? STRUCT_METHODS : OBJECT_METHODS).include?(name) + name + end + + def identifier(name) + return '_' if name.empty? + + name.match?(/\A\p{Digit}/) ? "_#{name}" : name + end + # GetAuthor => GET_AUTHOR_SQL def sql_constant(query_name) = "#{snake_case(query_name).upcase}_SQL" diff --git a/lib/sqlc_gen_ruby/drivers/base.rb b/lib/sqlc_gen_ruby/drivers/base.rb index 0302250..f3e7b65 100644 --- a/lib/sqlc_gen_ruby/drivers/base.rb +++ b/lib/sqlc_gen_ruby/drivers/base.rb @@ -60,12 +60,20 @@ def row_data_type = :hash # sqlc.slice is only offered by sqlc for MySQL / SQLite (`IN (/*SLICE:ids*/?)`). def supports_slice? = false - def exec_rows_lines(_exec) = raise(NotImplementedError) + def exec_lines(exec, _query) = [exec, 'nil'] + + def exec_rows_lines(_exec, _query) = raise(NotImplementedError) def exec_last_id_lines(_exec, _query) = raise(NotImplementedError) - # table: SQL name; columns: SQL column names; attrs: matching reader names of the rows - def copy_from_lines(_rows_var, _table, _columns, _attrs, _label) = raise(NotImplementedError) + # table: quoted SQL name; columns: quoted SQL column names; values: Ruby expressions of the row values + def copy_from_lines(_rows_var, _table, _columns, _values, _label) = raise(NotImplementedError) + + # SQL identifier quoting for the statements the plugin writes itself (:copyfrom). + def quote_identifier(name) = %("#{name.gsub('"', '""')}") + + # Ruby expression that turns a parameter value into what the driver can bind (e.g. bytea escaping). + def encode_param(expr, _column) = expr # Private helper methods the class needs for these queries: [[code, rbs], ...] def helper_methods(_queries) = [] @@ -76,9 +84,11 @@ def column_type(column, fallback: :raise) return 'String' if catalog&.enum?(column.type) db_type = normalize_type(column.type.name) + return 'untyped' if db_type == 'any' # sqlc could not infer the type of the expression + column_mappings.each do |mapping| matched = if mapping.prefix_based - mapping.db_types.any? { |d| d.start_with?(db_type) } + mapping.db_types.any? { |d| db_type.start_with?(d) } else mapping.db_types.include?(db_type) end @@ -136,8 +146,8 @@ class NotFound < StandardError; end private - # from sqlc - char(5), int4, pg_catalog.int8, BLOB, ... - def normalize_type(type_name) = type_name.downcase.delete_prefix('pg_catalog.') + # from sqlc - char(5), int4, pg_catalog.int8, BLOB, VARCHAR(255), ... => char, int4, int8, blob, varchar + def normalize_type(type_name) = type_name.downcase.delete_prefix('pg_catalog.').sub(/\s*\(.*\)\z/, '') def driver_gems = raise(NotImplementedError) diff --git a/lib/sqlc_gen_ruby/drivers/mysql2.rb b/lib/sqlc_gen_ruby/drivers/mysql2.rb index eaac2a4..c795583 100644 --- a/lib/sqlc_gen_ruby/drivers/mysql2.rb +++ b/lib/sqlc_gen_ruby/drivers/mysql2.rb @@ -41,15 +41,19 @@ def closes_statements? = true def supports_slice? = true - def exec_rows_lines(exec) = [exec, 'stmt.affected_rows'] + # Rows are read by position: mysql2 names unaliased expressions after their text (`count(*)`), sqlc does not. + def row_data_type = :array + + def quote_identifier(name) = "`#{name.gsub('`', '``')}`" + + def exec_rows_lines(exec, _query) = [exec, 'stmt.affected_rows'] def exec_last_id_lines(exec, _query) = [exec, 'client.last_id'] # LOAD DATA LOCAL INFILE from a temporary CSV (the client needs `local_infile: true`, the server # `local_infile=1`). - def copy_from_lines(rows_var, table, columns, attrs, label) - write = Code.block("#{rows_var}.each do |row|", - "file.write(csv_line([#{attrs.map { |a| "row.#{a}" }.join(', ')}]))") + def copy_from_lines(rows_var, table, columns, values, label) + write = Code.block("#{rows_var}.each do |row|", "file.write(csv_line([#{values.join(', ')}]))") load_data = format(LOAD_DATA, table: table, columns: columns.join(', ')) [ "return if #{rows_var}.empty?", @@ -59,9 +63,7 @@ def copy_from_lines(rows_var, table, columns, attrs, label) ] end - def extra_requires(queries, _ruby_version) = copy_from?(queries) ? %w[csv tempfile] : [] - - def extra_gems(queries) = copy_from?(queries) ? %w[csv] : [] + def extra_requires(queries, _ruby_version) = copy_from?(queries) ? %w[tempfile] : [] def helper_methods(queries) return [] unless copy_from?(queries) @@ -94,8 +96,9 @@ def self.connect(mysql2_params, logger: nil, **pool_options) RUBY TRANSACTION = <<~'RUBY'.chomp - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -107,15 +110,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -137,7 +148,8 @@ def with_client(name) client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE %s FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (%s)") RUBY - # \N is LOAD DATA's NULL; backslashes are doubled because the statement uses ESCAPED BY '\'. + # \N is LOAD DATA's NULL; every other value is enclosed in quotes (an unquoted NULL would also be read as + # NULL), backslashes are doubled because the statement uses ESCAPED BY '\'. CSV_LINE = <<~'RUBY'.chomp # Formats one row for LOAD DATA ... FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\'. def csv_line(values) @@ -147,10 +159,10 @@ def csv_line(values) when true then '1' when false then '0' when Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') - else value.to_s.gsub('\\') { '\\\\' } + else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""')}") end end - CSV.generate_line(fields) + "#{fields.join(',')}\n" end RUBY diff --git a/lib/sqlc_gen_ruby/drivers/pg.rb b/lib/sqlc_gen_ruby/drivers/pg.rb index 954cad9..fb7f9f4 100644 --- a/lib/sqlc_gen_ruby/drivers/pg.rb +++ b/lib/sqlc_gen_ruby/drivers/pg.rb @@ -15,7 +15,7 @@ def name = 'Pg' def module_members [ - [CONNECTION_STATE, "module ConnectionState\n def prepared_statements: () -> Set[String]\nend"], + [CONNECTION_STATE, CONNECTION_STATE_RBS], [ISOLATION_LEVELS, ISOLATION_RBS] ] end @@ -33,7 +33,7 @@ def private_methods [ [INSTRUMENT, INSTRUMENT_RBS], [WITH_CLIENT, 'def with_client: [T] (String name) { (untyped) -> T } -> T'], - [CONFIGURE, 'def configure: (untyped client) -> void'] + [CONFIGURE, "def configure: (untyped client) -> void\n\ndef string_arrays: (untyped client) -> untyped"] ] end @@ -58,21 +58,20 @@ def execute_stmt(stmt_name, const, has_params, array_rows: false) array_rows ? "#{call}.values" : call end - def exec_rows_lines(exec) = ["result = #{exec}", 'result.cmd_tuples'] + def exec_rows_lines(exec, _query) = ["result = #{exec}", 'result.cmd_tuples'] - # Relies on a user-written `RETURNING `. + # Relies on a user-written `RETURNING `; nil when no row is returned (e.g. ON CONFLICT DO NOTHING). def exec_last_id_lines(exec, query) if query.columns.empty? raise ArgumentError, "#{query.name}: :execlastid on PostgreSQL needs a RETURNING clause" end - ["result = #{exec}", "result.first['#{query.columns.first.name}']"] + ["result = #{exec}", 'row = result.first', "row && row['#{query.columns.first.name}']"] end # COPY ... FROM STDIN, one row at a time through the pg gem's CopyRow encoder. - def copy_from_lines(rows_var, table, columns, attrs, _label) - put = Code.block("#{rows_var}.each do |row|", - "client.put_copy_data([#{attrs.map { |a| "row.#{a}" }.join(', ')}])") + def copy_from_lines(rows_var, table, columns, values, _label) + put = Code.block("#{rows_var}.each do |row|", "client.put_copy_data([#{values.join(', ')}])") [ 'encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries)', Code.block("client.copy_data('COPY #{table} (#{columns.join(', ')}) FROM STDIN', encoder) do", put), @@ -80,15 +79,68 @@ def copy_from_lines(rows_var, table, columns, attrs, _label) ] end + # bytea, json / jsonb (BasicTypeMapForQueries sends a Ruby Array as a PG array) and, without prepared + # statements (no server-side parameter types), arrays of String-typed values such as uuid[] / enum[] + def encode_param(expr, column) + helper = encoder_for(column) + helper ? "#{helper}(#{expr})" : expr + end + + def helper_methods(queries) + used = queries.flat_map { |q| q.params.map { |p| encoder_for(p.column) } }.compact.uniq + ENCODERS.select { |name, _| used.include?(name) }.values + end + + ENCODERS = { + 'encode_bytea' => [<<~'RUBY'.chomp, 'def encode_bytea: (String? value) -> String?'], + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end + RUBY + 'encode_json' => [<<~'RUBY'.chomp, 'def encode_json: (untyped value) -> untyped'], + # json / jsonb parameters: an Array is a JSON array, not a PG array (Hashes and JSON text pass through). + def encode_json(value) + value.is_a?(Array) ? PG::TextEncoder::JSON.new.encode(value) : value + end + RUBY + 'encode_text_array' => [<<~'RUBY'.chomp, 'def encode_text_array: (Array[untyped]? value) -> String?'] + # Without prepared statements an Array of Strings would be typed text[]; as an array literal the server + # converts it to the column type (uuid[], enum[], ...). + def encode_text_array(value) + value && PG::TextEncoder::Array.new(elements_type: PG::TextEncoder::String.new).encode(value) + end + RUBY + }.freeze + CONNECTION_STATE = <<~'RUBY'.chomp # State kept on every PG::Connection the first time it is used. module ConnectionState + # Names of the statements prepared on this connection; forgotten when the server session changes + # (PG::Connection#reset after a failover / terminated backend). def prepared_statements - @prepared_statements ||= Set[] + if @prepared_statements_pid != backend_pid + @prepared_statements = Set[] + @prepared_statements_pid = backend_pid + end + @prepared_statements end end RUBY + CONNECTION_STATE_RBS = <<~'RBS'.chomp + interface _PgConnection + def backend_pid: () -> Integer + end + + module ConnectionState : _PgConnection + @prepared_statements: Set[String] + @prepared_statements_pid: Integer + + def prepared_statements: () -> Set[String] + end + RBS + INITIALIZE = <<~'RUBY'.chomp # db: a ConnectionPool (or anything that responds to #with and yields a PG::Connection). # logger: optional, anything with #debug (Logger, Rails.logger); logs the name and duration of every query. @@ -107,8 +159,9 @@ def self.connect(pg_params, logger: nil, **pool_options) RUBY TRANSACTION = <<~'RUBY'.chomp - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -118,13 +171,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -145,17 +204,40 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end RUBY private def driver_gems = %w[pg] + def encoder_for(column) + return nil if column.is_sqlc_slice + + type = normalize_type(column.type.name) + if column.is_array + 'encode_text_array' if !@prepared && !%w[bytea json jsonb].include?(type) && column_type(column) == 'String' + elsif type == 'bytea' then 'encode_bytea' + elsif %w[json jsonb].include?(type) then 'encode_json' + end + end + def column_mappings # Ruby classes as returned by the pg gem with PG::BasicTypeMapForResults (verified by tests/end2end_pg.rb); # types without a decoder (uuid, money, interval, enums, ...) come back as Strings. diff --git a/lib/sqlc_gen_ruby/drivers/sqlite3.rb b/lib/sqlc_gen_ruby/drivers/sqlite3.rb index 078b8ce..b712497 100644 --- a/lib/sqlc_gen_ruby/drivers/sqlite3.rb +++ b/lib/sqlc_gen_ruby/drivers/sqlite3.rb @@ -40,7 +40,10 @@ def closes_statements? = true def supports_slice? = true - def exec_rows_lines(exec) = [exec, 'client.changes'] + # sqlite3 only runs a statement eagerly when it has no result columns: one with RETURNING is stepped to the end. + def exec_lines(exec, query) = [query.columns.empty? ? exec : "#{exec}.to_a", 'nil'] + + def exec_rows_lines(exec, query) = [query.columns.empty? ? exec : "#{exec}.to_a", 'client.changes'] # Relies on a user-written `RETURNING `. def exec_last_id_lines(exec, query) @@ -52,12 +55,12 @@ def exec_last_id_lines(exec, query) end # One multi-row `INSERT ... VALUES (?, ?), (?, ?), ...` per batch. - def copy_from_lines(rows_var, table, columns, attrs, _label) + def copy_from_lines(rows_var, table, columns, values, _label) rows_per_batch = [MAX_BOUND_PARAMETERS / columns.size, 1].max placeholders = "(#{(['?'] * columns.size).join(', ')})" body = [ - %(sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES \#{Array.new(batch.size, '#{placeholders}').join(', ')}"), - "client.execute(sql, batch.flat_map { |row| [#{attrs.map { |a| "row.#{a}" }.join(', ')}] })" + "sql = %(INSERT INTO #{table} (#{columns.join(', ')}) VALUES \#{Array.new(batch.size, '#{placeholders}').join(', ')})", + "client.execute(sql, batch.flat_map { |row| [#{values.join(', ')}] })" ].join("\n") [Code.block("#{rows_var}.each_slice(#{rows_per_batch}) do |batch|", body), 'nil'] end @@ -72,20 +75,27 @@ def initialize(db, logger: nil) RUBY TRANSACTION = <<~'RUBY'.chomp - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end diff --git a/lib/sqlc_gen_ruby/generator.rb b/lib/sqlc_gen_ruby/generator.rb index 19039c8..7044ee7 100644 --- a/lib/sqlc_gen_ruby/generator.rb +++ b/lib/sqlc_gen_ruby/generator.rb @@ -28,6 +28,7 @@ def generate(request) @layout = RowLayout.new(@driver, @catalog, @types) name_anonymous_params(request.queries) + check_constants(request.queries) models = models_files connection = connection_files files = request.queries.group_by { |q| class_name_for(q.filename) }.flat_map do |class_name, queries| @@ -49,6 +50,27 @@ def name_anonymous_params(queries) end end + # Everything is defined in one module: models, enums, query classes, rows, ... must not share a name. + def check_constants(queries) + names = @catalog.enum_modules.map { |name, _| [name, 'enum'] } + names.concat(@catalog.tables.map { |t| [t.model_name, "model of table #{t.name}"] }) + queries.group_by { |q| class_name_for(q.filename) }.each do |class_name, list| + names << [class_name, "query class of #{list.first.filename}"] + list.each do |q| + names << ["#{Code.upcase_first(q.name)}Row", "row class of query #{q.name}"] unless q.columns.empty? + names << ["#{Code.upcase_first(q.name)}Args", "rows of query #{q.name}"] if q.cmd == ':copyfrom' + end + end + names.concat(@driver.module_members.flat_map { |code, _| code.scan(/^(?:module|class) (\w+)|^(\w+) = /).flatten.compact } + .map { |n| [n, 'generated code'] }) + names.group_by(&:first).each do |name, uses| + next if uses.size < 2 + + raise ArgumentError, "#{module_name}::#{name} would be defined twice (#{uses.map(&:last).join(', ')}); " \ + 'rename one of them' + end + end + def instantiate_driver case @options.driver_name when 'mysql2' then Drivers::Mysql2.new @@ -76,17 +98,22 @@ def generate_files(queries, class_name, with_models:, with_connection:) code_body = members.map(&:first) + [Code.block("class #{class_name}", klass.map(&:first).join("\n\n"))] requires = requires_for(queries, with_models, with_connection) - code = "#{HEADER}\n#{requires}\n\n#{Code.block("module #{module_name}", code_body.join("\n\n"))}\n" + code = "#{HEADER}\n#{requires}\n\n#{in_module(code_body.join("\n\n"))}\n" files = [Plugin::File.new(name: "#{base}.rb", contents: code)] if @options.generate_types type_body = members.map(&:last).compact + [Code.block("class #{class_name}", klass.map(&:last).join("\n\n"))] - types = "#{RBS_HEADER}\n#{Code.block("module #{module_name}", type_body.join("\n\n"))}\n" + types = "#{RBS_HEADER}\n#{in_module(type_body.join("\n\n"))}\n" files << Plugin::File.new(name: "#{base}.rbs", contents: types) end files end + # module MyApp; module Db; ...; end; end (the parent modules do not need to exist) + def in_module(body) + module_name.split('::').reverse.reduce(body) { |inner, name| Code.block("module #{name}", inner) } + end + def requires_for(queries, with_models, with_connection) lines = (@driver.required_gems + @driver.extra_requires(queries, @options.ruby_version)).map { |g| "require '#{g}'" } lines << "require_relative '#{CONNECTION_FILE}'" if with_connection @@ -102,7 +129,7 @@ def query_members(query, method_gen) end members << dataclass("#{Code.upcase_first(query.name)}Row", row_pairs(query)) unless query.columns.empty? if query.cmd == ':copyfrom' - pairs = method_gen.params(query).map { |p| [p.name, p.type] } + pairs = method_gen.params(query).reject(&:reused).map { |p| [p.name, p.type] } members << dataclass("#{Code.upcase_first(query.name)}Args", pairs) end members @@ -113,14 +140,29 @@ def row_pairs(query) = @layout.entries(query).map { |e| [e.attr, e.type] } # constructor / connect / transaction, one method per query, then the private helpers def class_members(queries, method_gen) members = @driver.class_methods.dup - members.concat(queries.map { |q| [method_gen.code(q), method_gen.type(q)] }) helpers = @driver.private_methods + method_gen.helper_methods(queries) + check_method_names(queries, (members + helpers).flat_map { |code, _| code.scan(/^def (?:self\.)?([\w?!]+)/) }.flatten) + members.concat(queries.map { |q| [method_gen.code(q), method_gen.type(q)] }) unless helpers.empty? members << ["private\n\n#{helpers.map(&:first).join("\n\n")}", "private\n\n#{helpers.map(&:last).join("\n\n")}"] end members end + def check_method_names(queries, generated) + taken = generated.to_h { |name| [name, 'a method of the generated class'] } + queries.each do |q| + func = Code.snake_case(q.name) + [func, *("#{func}!" if q.cmd == ':one')].each do |name| + if taken.key?(name) + raise ArgumentError, "query #{q.name}: method #{name} is already #{taken[name]}; rename the query" + end + + taken[name] = "the method of query #{q.name}" + end + end + end + # --- data classes (Data.define / Struct.new) --- def dataclass(name, pairs) @@ -139,13 +181,15 @@ def data_define(name, pairs) [code, "class #{name}\n#{readers}\n#{init}\nend"] end + # Ruby < 3.2: keyword_init so that rows are built like Data rows (`Row.new(id: 1, name: 'x')`). def new_struct(name, pairs) - attrs = Code.comma_join(pairs.map { |n, _| ":#{n}" }) - code = "class #{name} < Struct.new(#{attrs})\nend" + attrs = Code.comma_join(pairs.map { |n, _| ":#{n}" } + ['keyword_init: true']) + code = "#{name} = Struct.new(#{attrs})" accessors = Code.indent(pairs.map { |n, t| "attr_accessor #{n}: #{t}" }.join("\n")) - init_args = Code.indent(Code.comma_join(pairs.map { |n, t| "#{t} #{n}" })) - init = Code.indent("def self.new: (#{init_args}) -> #{name}") - [code, "class #{name} < ::Struct\n#{accessors}\n#{init}\nend"] + init_args = Code.indent(Code.comma_join(pairs.map { |n, t| "#{n}: #{t}" })) + init = Code.indent("def initialize: (#{init_args}) -> void") + new = Code.indent("def self.new: (#{init_args}) -> #{name}") + [code, "class #{name} < ::Struct[untyped]\n#{accessors}\n#{init}\n#{new}\nend"] end # --- models.rb: one class per table + one module per enum --- @@ -155,15 +199,15 @@ def models_files @catalog.tables.each do |table| next if table.columns.empty? - pairs = table.columns.map { |c| [c.name, @types.type(c, fallback: 'untyped')] } + pairs = table.columns.map { |c| [@types.member_name(c.name), @types.type(c, fallback: 'untyped')] } members << dataclass(table.model_name, pairs) end return [] if members.empty? - code = "#{HEADER}\n\n#{Code.block("module #{module_name}", members.map(&:first).join("\n\n"))}\n" + code = "#{HEADER}\n\n#{in_module(members.map(&:first).join("\n\n"))}\n" files = [Plugin::File.new(name: "#{MODELS_FILE}.rb", contents: code)] if @options.generate_types - types = "#{RBS_HEADER}\n#{Code.block("module #{module_name}", members.map(&:last).join("\n\n"))}\n" + types = "#{RBS_HEADER}\n#{in_module(members.map(&:last).join("\n\n"))}\n" files << Plugin::File.new(name: "#{MODELS_FILE}.rbs", contents: types) end files @@ -175,9 +219,9 @@ def connection_files return [] if members.empty? files = [Plugin::File.new(name: "#{CONNECTION_FILE}.rb", - contents: "#{HEADER}\n\n#{Code.block("module #{module_name}", members.map(&:first).join("\n\n"))}\n")] + contents: "#{HEADER}\n\n#{in_module(members.map(&:first).join("\n\n"))}\n")] if @options.generate_types - types = "#{RBS_HEADER}\n#{Code.block("module #{module_name}", members.map(&:last).join("\n\n"))}\n" + types = "#{RBS_HEADER}\n#{in_module(members.map(&:last).join("\n\n"))}\n" files << Plugin::File.new(name: "#{CONNECTION_FILE}.rbs", contents: types) end files @@ -206,8 +250,8 @@ def unique_constants(values) end def gemfile(queries) - gems = (@driver.required_gems + @driver.extra_gems(queries)).uniq.map { |g| "gem '#{g}'" }.join("\n") - Plugin::File.new(name: 'Gemfile', contents: "source 'https://rubygems.org'\n\n#{gems}") + gems = (@driver.required_gems + @driver.extra_gems(queries)).uniq.sort.map { |g| "gem '#{g}'" }.join("\n") + Plugin::File.new(name: 'Gemfile', contents: "# frozen_string_literal: true\n\nsource 'https://rubygems.org'\n\n#{gems}\n") end end end diff --git a/lib/sqlc_gen_ruby/method_gen.rb b/lib/sqlc_gen_ruby/method_gen.rb index f4691fd..ff67500 100644 --- a/lib/sqlc_gen_ruby/method_gen.rb +++ b/lib/sqlc_gen_ruby/method_gen.rb @@ -13,7 +13,11 @@ class MethodGen ].freeze # optional: nullable and named by the user (sqlc.arg / sqlc.narg) -> keyword argument defaulting to nil - Param = Struct.new(:name, :column, :slice, :optional, :type) + # reused: a later occurrence of a named parameter (MySQL sends one parameter per `?`): bound, not a keyword + Param = Struct.new(:name, :column, :slice, :optional, :type, :reused) + + # Locals of the generated query methods; parameters with these names get a trailing underscore. + GENERATED_LOCALS = %w[client query_params sql stmt result row].freeze # layout: RowLayout, types: TypeResolver, class_name: the generated query class (prefixes statement names) def initialize(driver, layout, types, class_name) @@ -24,11 +28,22 @@ def initialize(driver, layout, types, class_name) end def params(query) + copy = query.cmd == ':copyfrom' + by_name = {} query.params.map do |p| column = p.column + name = Code.param_name(column.name) + name = copy ? @types.member_name(name) : avoid_locals(name) + if column.is_named_param && by_name.key?(name) + first = by_name[name] + first.optional &&= optional?(column, query) + next Param.new(name, column, first.slice, false, first.type, true) + end + + name = unique_param_name(name, by_name) slice = column.is_sqlc_slice type = slice ? @types.slice_type(column, query.name) : @types.type(column, query_name: query.name) - Param.new(Code.param_name(column.name), column, slice, column.is_named_param && @types.nullable?(column, query.name), type) + by_name[name] = Param.new(name, column, slice, optional?(column, query), type, false) end end @@ -40,7 +55,8 @@ def code(query) signature = '(rows)' else statements = query_statements(query, func, params) - signature = params.empty? ? '' : "(#{params.map { |p| p.optional ? "#{p.name}: nil" : "#{p.name}:" }.join(', ')})" + keywords = params.reject(&:reused).map { |p| p.optional ? "#{p.name}: nil" : "#{p.name}:" } + signature = keywords.empty? ? '' : "(#{keywords.join(', ')})" end doc = query.comments.map { |c| "# #{c.strip}".rstrip }.join("\n") method = Code.block("def #{func}#{signature}", method_body(func, statements)) @@ -59,13 +75,13 @@ def type(query) if query.cmd == ':copyfrom' signature = "(Array[#{query.name}Args] rows)" else - list = params(query).map { |p| "#{p.optional ? '?' : ''}#{p.name}: #{p.type}" } + list = params(query).reject(&:reused).map { |p| "#{p.optional ? '?' : ''}#{p.name}: #{p.type}" } signature = list.empty? ? '' : "(#{list.join(', ')})" end rbs = "def #{func}: #{signature}#{signature.empty? ? '' : ' '}-> #{return_type(query)}" return rbs unless query.cmd == ':one' - "#{rbs}\n\ndef #{func}!: #{signature}#{signature.empty? ? '' : ' '}-> #{query.name}Row" + "#{rbs}\n\ndef #{func}!: #{signature}#{signature.empty? ? '' : ' '}-> #{Code.upcase_first(query.name)}Row" end # [[code, rbs]] of private helper methods the generated class needs for these queries. @@ -77,12 +93,33 @@ def helper_methods(queries) private + # sqlc.narg / sqlc.arg are named parameters; behind a cast (`sqlc.narg(email)::text`) sqlc drops the flag, but + # keeps the name without a source column. + def optional?(column, query) + named = column.is_named_param || + (!column.name.empty? && column.table.nil? && column.original_name.to_s.empty?) + named && @types.nullable?(column, query.name) + end + + def avoid_locals(name) + name = "#{name}_" while GENERATED_LOCALS.include?(name) + name + end + + # id, id_2, id_3 for unnamed parameters of the same column (`WHERE id IN (?, ?, ?)`) + def unique_param_name(base, taken) + name = base + suffix = 1 + name = "#{base}_#{suffix += 1}" while taken.key?(name) + name + end + def slice?(query) = query.params.any? { |p| p.column.is_sqlc_slice } def return_type(query) case query.cmd - when ':one' then "#{query.name}Row?" - when ':many' then "Array[#{query.name}Row]" + when ':one' then "#{Code.upcase_first(query.name)}Row?" + when ':many' then "Array[#{Code.upcase_first(query.name)}Row]" when ':execrows' then 'Integer' when ':execlastid' then last_id_type(query) else 'void' @@ -100,8 +137,10 @@ def copy_statements(query, params) raise ArgumentError, "#{query.name}: :copyfrom without a target table" if query.insert_into_table.nil? table = query.insert_into_table - name = table.schema.to_s.empty? ? table.name : "#{table.schema}.#{table.name}" - @driver.copy_from_lines('rows', name, params.map { |p| p.column.name }, params.map(&:name), + name = table.schema.to_s.empty? ? '' : "#{@driver.quote_identifier(table.schema)}." + name += @driver.quote_identifier(table.name) + values = params.map { |p| @driver.encode_param("row.#{p.name}", p.column) } + @driver.copy_from_lines('rows', name, params.map { |p| @driver.quote_identifier(p.column.name) }, values, Code.snake_case(query.name)) end @@ -130,27 +169,27 @@ def query_statements(query, func, params) end def query_params(params) - list = params.map { |p| p.slice ? "*#{p.name}" : p.name } + list = params.map { |p| p.slice ? "*#{p.name}" : @driver.encode_param(p.name, p.column) } "query_params = [#{Code.comma_join(list)}]" end - # sql = FOO_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - once per slice + # sql = FOO_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) - once per slice def slice_sql(const, params) - params.select(&:slice).each_with_index.map do |p, i| - "sql = #{i.zero? ? const : 'sql'}.sub('/*SLICE:#{p.column.name}*/?', slice_placeholders(#{p.name}))" + params.select { |p| p.slice && !p.reused }.each_with_index.map do |p, i| + "sql = #{i.zero? ? const : 'sql'}.gsub('/*SLICE:#{p.column.name}*/?', slice_placeholders(#{p.name}))" end.join("\n") end # The value of the last expression is the return value of the method (no explicit `return`). def command_statements(query, stmt_name, const, has_params) - exec = @driver.execute_stmt(stmt_name, const, has_params, array_rows: @layout.array_rows?(query)) + exec = @driver.execute_stmt(stmt_name, const, has_params, array_rows: !query.columns.empty? && @layout.array_rows?(query)) case query.cmd when ':one' ["result = #{exec}", 'row = result.first', "row && #{new_row(query)}"] when ':many' ["result = #{exec}", Code.block('result.map do |row|', new_row(query))] - when ':exec' then [exec, 'nil'] - when ':execrows' then @driver.exec_rows_lines(exec) + when ':exec' then @driver.exec_lines(exec, query) + when ':execrows' then @driver.exec_rows_lines(exec, query) when ':execlastid' then @driver.exec_last_id_lines(exec, query) else raise ArgumentError, "unsupported query command: #{query.cmd}" end @@ -164,17 +203,26 @@ def new_row(query) array = @layout.array_rows?(query) index = 0 args = @layout.entries(query).map do |entry| - if entry.table - values = entry.table.columns.each_index.map { |i| "row[#{index + i}]" } - index += values.size - "#{entry.table.model_name}.new(#{Code.comma_join(values)})" - else - access = array ? "row[#{index}]" : "row['#{entry.column.name}']" - index += 1 - access - end + value = if entry.table + values = entry.table.columns.each_index.map { |i| "row[#{index + i}]" } + index += values.size + names = entry.table.columns.map { |c| @types.member_name(c.name) } + "#{entry.table.model_name}.new(#{constructor_args(names, values).join(', ')})" + else + index += 1 + array ? "row[#{index - 1}]" : "row['#{entry.column.name}']" + end + [entry.attr, value] end - "#{query.name}Row.new(#{Code.comma_join(args)})" + args = constructor_args(args.map(&:first), args.map(&:last)) + row_class = "#{Code.upcase_first(query.name)}Row" + wrap = "#{row_class}.new(#{args.join(', ')})".length > 100 + "#{row_class}.new(#{Code.comma_join(args, wrap: wrap)})" + end + + # Struct rows (Ruby < 3.2) are built with keywords (keyword_init: true), Data rows positionally. + def constructor_args(names, values) + @types.keyword_rows? ? names.zip(values).map { |n, v| "#{n}: #{v}" } : values end end end diff --git a/lib/sqlc_gen_ruby/options.rb b/lib/sqlc_gen_ruby/options.rb index 4f20013..aedce6a 100644 --- a/lib/sqlc_gen_ruby/options.rb +++ b/lib/sqlc_gen_ruby/options.rb @@ -7,6 +7,7 @@ module SqlcGenRuby # Port of PluginOptions/Options.cs + RawOptions.cs: parses the JSON `options:` block of the sqlc config. class Options DRIVERS = %w[mysql2 pg sqlite3].freeze + KEYS = %w[driver rubyVersion generateGemfile generateTypes overrides moduleName preparedStatements].freeze # `overrides:` entry - column is ":" or "*:"; type/not_null come from `ruby_type:` Override = Struct.new(:query, :column, :type, :not_null) @@ -19,6 +20,9 @@ def initialize(plugin_options_bytes) raw = JSON.parse(text) raise ArgumentError, 'plugin options must be a JSON object' unless raw.is_a?(Hash) + unknown = raw.keys - KEYS + raise ArgumentError, "unknown option(s): #{unknown.join(', ')} (supported: #{KEYS.join(', ')})" unless unknown.empty? + @driver_name = fetch_string(raw, 'driver', required: true).downcase raise ArgumentError, "unknown driver: #{raw['driver']}" unless DRIVERS.include?(@driver_name) @@ -28,6 +32,9 @@ def initialize(plugin_options_bytes) @overrides = parse_overrides(raw.fetch('overrides', [])) @module_name = raw.key?('moduleName') ? fetch_module_name(raw) : nil # default: Codegen @prepared_statements = fetch_bool(raw, 'preparedStatements', true) + if raw.key?('preparedStatements') && @driver_name != 'pg' + raise ArgumentError, 'option preparedStatements is only supported by the pg driver' + end end # The override matching a result column / parameter of a query: "Query:col" wins over "*:col". diff --git a/lib/sqlc_gen_ruby/row_layout.rb b/lib/sqlc_gen_ruby/row_layout.rb index 0dcb8cf..d1f7002 100644 --- a/lib/sqlc_gen_ruby/row_layout.rb +++ b/lib/sqlc_gen_ruby/row_layout.rb @@ -30,14 +30,14 @@ def array_rows?(query) def embed?(column) = !column.embed_table.nil? && !column.embed_table.name.empty? def build(query) - taken = query.columns.reject { |c| embed?(c) }.map(&:name) + taken = query.columns.reject { |c| embed?(c) }.map { |c| @types.member_name(c.name) } query.columns.map do |column| if embed?(column) table = @catalog.table_for(column.embed_table) or raise ArgumentError, "#{query.name}: sqlc.embed of unknown table #{column.embed_table.name}" - Entry.new(unique_name(taken, Code.singularize(column.name)), table.model_name, column, table) + Entry.new(unique_name(taken, @types.member_name(Code.singularize(column.name))), table.model_name, column, table) else - Entry.new(column.name, @types.type(column, query_name: query.name), column, nil) + Entry.new(@types.member_name(column.name), @types.type(column, query_name: query.name), column, nil) end end.tap { |list| reject_duplicate_columns(query, list) } end diff --git a/lib/sqlc_gen_ruby/ruby_version.rb b/lib/sqlc_gen_ruby/ruby_version.rb index 763a18b..deebd40 100644 --- a/lib/sqlc_gen_ruby/ruby_version.rb +++ b/lib/sqlc_gen_ruby/ruby_version.rb @@ -82,7 +82,7 @@ def compare_nullable(value, other) private :compare_nullable MIN_SUPPORTED = new(3, 1).freeze - MAX_SUPPORTED = new(3, 3).freeze + MAX_SUPPORTED = new(3, 4).freeze IMMUTABLE_DATA_MIN = new(3, 2).freeze end end diff --git a/lib/sqlc_gen_ruby/type_resolver.rb b/lib/sqlc_gen_ruby/type_resolver.rb index 9669777..0a2a423 100644 --- a/lib/sqlc_gen_ruby/type_resolver.rb +++ b/lib/sqlc_gen_ruby/type_resolver.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'code' + module SqlcGenRuby # Full RBS type of a result column / parameter: DB type mapping (driver) + array + nullability + `overrides:`. class TypeResolver @@ -12,7 +14,7 @@ def initialize(driver, options) # fallback: see Drivers::Base#column_type. def type(column, query_name: nil, fallback: :raise) override = query_name && @options.override_for(query_name, column.name) - base = override ? override.type : @driver.column_type(column, fallback: fallback) + base = override ? override.type : driver_type(column, query_name, fallback) base = "Array[#{base}]" if column.is_array && !override not_null?(column, override) || base == 'untyped' ? base : "#{base}?" end @@ -24,11 +26,24 @@ def nullable?(column, query_name) # A sqlc.slice parameter is passed as an Array of the element type. def slice_type(column, query_name) override = @options.override_for(query_name, column.name) - "Array[#{override ? override.type : @driver.column_type(column)}]" + "Array[#{override ? override.type : driver_type(column, query_name, :raise)}]" end + # Rows are Struct (keyword_init) before Ruby 3.2, Data from 3.2. + def keyword_rows? = !@options.ruby_version.immutable_data_supported? + + def member_name(name) = Code.member_name(name, struct: keyword_rows?) + private + def driver_type(column, query_name, fallback) + @driver.column_type(column, fallback: fallback) + rescue ArgumentError => e + raise ArgumentError, "#{query_name || 'model'}: column #{column.name}: #{e.message}" if query_name + + raise + end + def not_null?(column, override) override&.not_null.nil? ? column.not_null : override.not_null end diff --git a/steep_expectations.yml b/steep_expectations.yml index a71323d..c496551 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -3,123 +3,183 @@ diagnostics: - range: start: - line: 198 + line: 218 character: 10 end: - line: 198 + line: 218 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 198 + line: 218 character: 47 end: - line: 198 + line: 218 character: 53 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 198 + line: 218 character: 66 end: - line: 198 + line: 218 character: 81 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - - range: - start: - line: 615 - character: 6 - end: - line: 615 - character: 9 - severity: WARNING - message: 'Cannot find the declaration of constant: `CSV`' - code: Ruby::UnknownConstant - file: examples/pg/query_sql.rb diagnostics: - range: start: - line: 213 + line: 255 character: 10 end: - line: 213 + line: 255 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 213 + line: 255 character: 47 end: - line: 213 + line: 255 character: 49 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 213 + line: 255 character: 58 end: - line: 213 + line: 255 character: 69 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 227 + line: 270 character: 49 end: - line: 227 + line: 270 character: 51 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 427 + line: 477 character: 18 end: - line: 427 + line: 477 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 624 - character: 17 + line: 700 + character: 18 end: - line: 624 - character: 19 + line: 700 + character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 625 - character: 34 + line: 767 + character: 17 end: - line: 625 - character: 36 + line: 767 + character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 627 + line: 770 character: 36 end: - line: 627 + line: 770 character: 38 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant + - range: + start: + line: 776 + character: 16 + end: + line: 776 + character: 18 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 777 + character: 15 + end: + line: 777 + character: 17 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 778 + character: 79 + end: + line: 778 + character: 81 + severity: WARNING + message: Empty array doesn't have type annotation + code: Ruby::UnannotatedEmptyCollection + - range: + start: + line: 779 + character: 25 + end: + line: 779 + character: 27 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 780 + character: 67 + end: + line: 780 + character: 69 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 788 + character: 15 + end: + line: 788 + character: 17 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 793 + character: 27 + end: + line: 793 + character: 29 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant diff --git a/test/features_test.rb b/test/features_test.rb index b01a95c..88edf6a 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -71,7 +71,7 @@ def test_embedded_tables_become_model_attributes def test_slices_expand_at_runtime_and_are_array_typed files = generate(load_request('mysql2')) - assert_includes files.fetch('query_sql.rb'), "sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids))" + assert_includes files.fetch('query_sql.rb'), "sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids))" assert_includes files.fetch('query_sql.rb'), 'query_params = [*ids]' assert_includes files.fetch('query_sql.rbs'), 'def get_authors_by_ids: (ids: Array[Integer]) -> Array[GetAuthorsByIdsRow]' assert_includes files.fetch('query_sql.rbs'), 'def slice_placeholders: (Array[untyped] values) -> String' @@ -121,12 +121,13 @@ def test_pg_and_sqlite_execlastid_need_returning end end - def test_mysql_copyfrom_adds_csv_helpers_and_gemfile_entry + def test_mysql_copyfrom_writes_its_own_csv files = generate(load_request('mysql2', generateGemfile: true)) - assert_includes files.fetch('query_sql.rb'), "require 'csv'" + refute_includes files.fetch('query_sql.rb'), "require 'csv'" + assert_includes files.fetch('query_sql.rb'), "require 'tempfile'" assert_includes files.fetch('query_sql.rb'), 'def csv_line(values)' - assert_includes files.fetch('Gemfile'), "gem 'csv'" - refute_includes generate(load_request('pg', generateGemfile: true)).fetch('Gemfile'), 'csv' + assert_equal "# frozen_string_literal: true\n\nsource 'https://rubygems.org'\n\ngem 'connection_pool'\ngem 'mysql2'\n", + files.fetch('Gemfile') end def test_sqlite_and_mysql_close_prepared_statements @@ -186,7 +187,7 @@ def test_pg_can_skip_prepared_statements def test_module_name_option files = generate(load_request('pg', moduleName: 'MyApp::Db')) - files.each { |name, contents| assert_includes contents, 'module MyApp::Db' if name.end_with?('.rb', '.rbs') } + files.each { |name, contents| assert_includes contents, "module MyApp\n module Db\n" if name.end_with?('.rb', '.rbs') } assert_raises(RuntimeError) { generate(load_request('pg', moduleName: 'not a constant')) } end @@ -195,14 +196,16 @@ def test_set_is_only_required_before_ruby_3_2 refute_includes generate(load_request('pg')).fetch('query_sql.rb'), "require 'set'" end - # Ruby < 3.2 has no Data: rows are Structs, whose members must be symbols (Struct.new(:id, :name)). + # Ruby < 3.2 has no Data: rows are keyword_init Structs, built with keywords like Data rows. def test_struct_classes_are_valid_and_usable files = generate(load_request('pg', rubyVersion: '3.1')) - assert_includes files.fetch('models.rb'), 'class Author < Struct.new(:id, :name, :bio)' - assert_includes files.fetch('query_sql.rb'), 'class GetAuthorRow < Struct.new(:id, :name, :bio)' + assert_includes files.fetch('models.rb'), 'Author = Struct.new(:id, :name, :bio, keyword_init: true)' + assert_includes files.fetch('query_sql.rb'), 'GetAuthorRow = Struct.new(:id, :name, :bio, keyword_init: true)' + assert_includes files.fetch('query_sql.rb'), "GetAuthorRow.new(id: row['id'], name: row['name'], bio: row['bio'])" + assert_includes files.fetch('models.rbs'), "class Author < ::Struct[untyped]\n" scope = Module.new scope.module_eval(files.fetch('models.rb')) - author = scope::PgCodegen::Author.new(1, 'a', nil) + author = scope::PgCodegen::Author.new(id: 1, name: 'a', bio: nil) assert_equal({ id: 1, name: 'a', bio: nil }, author.to_h) assert_equal 'a', author.name end @@ -239,4 +242,92 @@ def test_logger_support end assert_includes generate(load_request('pg')).fetch('query_sql.rb'), 'def self.connect(pg_params, logger: nil, **pool_options)' end + + # ---- regressions (examples/authors/*: the `regressions` table) ----------------------------------------------- + + def test_reused_and_repeated_parameters_get_one_keyword_each + rb = generate(load_request('mysql2')).fetch('query_sql.rb') + assert_includes rb, "def count_regressions_by_text(term:)\n" + assert_includes rb, 'query_params = [term, term]' + assert_includes rb, 'def list_regressions_by_ids(id:, id_2:, id_3:)' + end + + def test_mysql_rows_are_read_by_position + rb = generate(load_request('mysql2')).fetch('query_sql.rb') + assert_includes rb, 'CountRegressionsByTextRow.new(row[0])' + refute_includes rb, "row['" + end + + def test_sqlite_types_with_modifiers_and_untyped_expressions + files = generate(load_request('sqlite3')) + assert_match(/class Regression\n\s+attr_reader id: Integer\n\s+attr_reader order: String\n\s+attr_reader price: Float\?/, + files.fetch('models.rbs')) + assert_includes files.fetch('query_sql.rbs'), 'attr_reader max_price: untyped' + end + + def test_casted_narg_is_optional + assert_includes generate(load_request('pg')).fetch('query_sql.rbs'), + 'def list_regressions: (?order_name: String?) -> Array[ListRegressionsRow]' + end + + def test_copyfrom_quotes_identifiers + assert_includes generate(load_request('pg')).fetch('query_sql.rb'), 'COPY "regressions" ("id", "order", "payload", "doc")' + assert_includes generate(load_request('mysql2')).fetch('query_sql.rb'), 'INTO TABLE `regressions`' + assert_includes generate(load_request('sqlite3')).fetch('query_sql.rb'), 'INSERT INTO "regressions" ("order", "price")' + end + + def test_pg_encodes_bytea_json_and_string_arrays + rb = generate(load_request('pg')).fetch('query_sql.rb') + assert_includes rb, 'encode_bytea(payload),' + assert_includes rb, 'encode_json(doc),' + refute_includes rb, 'encode_text_array' + noprep = generate(load_request('pg', preparedStatements: false)).fetch('query_sql.rb') + assert_includes noprep, 'encode_text_array(bio_types)' + assert_includes noprep, 'def encode_text_array(value)' + end + + def test_transactions_finish_on_non_local_exits + %w[pg mysql2 sqlite3].each do |driver| + assert_includes generate(load_request(driver)).fetch('query_sql.rb'), "Thread.current.status == 'aborting'", driver + end + end + + def test_parameters_named_like_generated_locals_are_renamed + request = load_request('pg') + query(request, 'GetAuthor').params.first.column.name = 'client' + assert_includes generate(request).fetch('query_sql.rb'), "def get_author(client_:)\n" + end + + def test_columns_become_valid_non_overriding_attributes + request = load_request('pg') + columns = query(request, 'GetAuthor').columns + columns[1].name = 'Weird Name' + columns[2].name = 'hash' + assert_includes generate(request).fetch('query_sql.rb'), 'class GetAuthorRow < Data.define(:id, :weird_name, :hash_)' + end + + def test_query_method_colliding_with_a_generated_method_is_an_error + request = load_request('pg') + query(request, 'GetAuthor').name = 'Transaction' + assert_raises(RuntimeError) { generate(request) } + end + + def test_constant_defined_twice_is_an_error + request = load_request('pg') + request.catalog.schemas.flat_map(&:tables).find { |t| t.rel.name == 'authors' }.rel.name = 'get_author_rows' + assert_raises(RuntimeError) { generate(request) } + end + + def test_nested_module_name_is_self_contained + files = generate(load_request('pg', moduleName: 'MyApp::Db')) + scope = Module.new + scope.module_eval(files.fetch('models.rb')) + assert scope::MyApp::Db::Author + end + + def test_unknown_and_misplaced_options_are_errors + assert_raises(RuntimeError) { generate(load_request('pg', moduleNmae: 'Db')) } + assert_raises(RuntimeError) { generate(load_request('mysql2', preparedStatements: false)) } + generate(load_request('pg', rubyVersion: '3.4')) + end end diff --git a/test/fixtures/mysql2.bin b/test/fixtures/mysql2.bin index eef840c..e676a68 100644 --- a/test/fixtures/mysql2.bin +++ b/test/fixtures/mysql2.bin @@ -1,4 +1,4 @@ -”public"‰public€ +¤public"™public€ authors& id0ÿÿÿÿÿÿÿÿÿR authorsbbigint& name0ÿÿÿÿÿÿÿÿÿR authorsbtext# @@ -67,7 +67,11 @@ c_longtext0 author_name0dR extended_biosb varchar$ name0dR extended_biosb varchar7 bio_type0 R extended_biosbextended_bios_bio_type= - author_type0R extended_biosbextended_bios_author_type": + author_type0R extended_biosbextended_bios_author_type� +  regressions* +id0ÿÿÿÿÿÿÿÿÿR  regressionsbbigint& +order0ÿR  regressionsb varchar( +note0ÿÿÿÿÿÿÿÿÿR  regressionsbtext": extended_bios_bio_type Autobiography BiographyMemoir"7 extended_bios_author_typeAuthorEditor TranslatorË @@ -232,4 +236,19 @@ XINSERT INTO extended_bios (author_name, name, bio_type, author_type) VALUES (?, bio_type0 R extended_biosbextended_bios_bio_typezbio_type"J author_type0R extended_biosbextended_bios_author_typez author_type*EA bio_type0 R extended_biosbextended_bios_bio_typezbio_type: query.sqlC -DELETE FROM extended_biosTruncateExtendedBios:exec: query.sql"v1.27.0*T{"driver":"mysql2","generateGemfile":false,"generateTypes":true,"rubyVersion":"3.3"} \ No newline at end of file +DELETE FROM extended_biosTruncateExtendedBios:exec: query.sqlõ +>SELECT count(*) FROM regressions WHERE `order` = ? OR note = ?CountRegressionsByText:one" +count0ÿÿÿÿÿÿÿÿÿ@bbigint*2. +term0ÿ8R  regressionsb varcharzorder*40 +term0ÿÿÿÿÿÿÿÿÿ8R  regressionsbtextznote: query.sql  +KSELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY idListRegressionsByIds:many". +id0ÿÿÿÿÿÿÿÿÿR  regressionsbbigintzid"- +order0ÿR  regressionsb varcharzorder". +note0ÿÿÿÿÿÿÿÿÿR  regressionsbtextznote*2. +id0ÿÿÿÿÿÿÿÿÿR  regressionsbbigintzid*2. +id0ÿÿÿÿÿÿÿÿÿR  regressionsbbigintzid*2. +id0ÿÿÿÿÿÿÿÿÿR  regressionsbbigintzid: query.sqlä +5INSERT INTO regressions (`order`, note) VALUES (?, ?)CopyRegressions :copyfrom*95 +order0ÿRpublic regressionsb varcharzorder*:6 +note0ÿÿÿÿÿÿÿÿÿRpublic regressionsbtextznote: query.sqlB  regressionsC +TRUNCATE TABLE regressionsTruncateRegressions:exec: query.sql"v1.27.0*T{"driver":"mysql2","generateGemfile":false,"generateTypes":true,"rubyVersion":"3.3"} \ No newline at end of file diff --git a/test/fixtures/pg.bin b/test/fixtures/pg.bin index 75b705d8be0ce34b52416a125a8d16f39fba001f..5846fcef1766bab6cbec6145dc04d62ee7255b1e 100644 GIT binary patch delta 2536 zcmd^B&rj1(9M8te%0odP%$SD6XW4<3K^#b;HHc#rnanonI*1oZyFSQtw4?0;+od;S zym;8b_&<2ic=W>6vuFPW<9~p!V}$1A&VV7^EDm1jzOqe2gHOPSRx)>-S_RuE_{O$2 z32AZ>Z0J~LXusrbCUn*@a8+TR)WoyL1_v>%Jt|xlX>_ zlPOH?DfO=CZq>9VXA-K|uZOnI9yP+EHRLmui za*ydSFQk;L^}Hluh>PM|djiITG6IkVlPFxKt8?5>CpnNc1e;bK{} z9pWZ*kr7Exjio%}U5T^?AoykoYz}O2vA!Ly)zBi(5^y|YR%+*+$!13QBKfmxnfq7r z*emwY0IWYmz+qm<2}E%Lz2EJV9cW#ao_7S#_J$w}o|1Xqi(iz@Be9UnAxZY*2rZ*{ zifCM36J)gdf*c3Ob0ynpr-|bCRioA!Arsz6(AAzj>HhDT(I>OIyA(G~GUE;a&3RzQ wQ1@}$gyT<2ZQ@`ufKlXvm`N)FQqrqAp`Bwmp;%Re#P(_CQ_az>F*R%a0pTspD*ylh delta 32 ncmcbd-4)I!)GZ{$R#2LhlbNjaj(;QHJNC_1oW{zND@>ySyPFFx diff --git a/test/fixtures/sqlite3.bin b/test/fixtures/sqlite3.bin index c41d644b325567e7ab7e6d3f38a5256c991c86c7..f27e086295ce1deda66b107deded6c7c9383524a 100644 GIT binary patch delta 1427 zcmbW1Uu)V>7{-ZKtY>zXBi+UU;Se@RBz4x&j>*KP@ze_bDKQpyHDWv~fog1GH}xhL zWp}&tBaD53v3-+$g28V00md%27&A2zy4rL3a^A!H{GR7MAD?{4#^KK}+~}%$kNpw; z94|2N69cx;J(HfB)UtG=XTiz$P}26-+A$QZ)D==DGJFOihS{O!BPRRj-(lpH`}a}& zOq8V)QRZ^{`#j8nNZ-^odP7SBZIy^rsfkr?FB=we{Ff8v_qKq(hJaM5H;JrZXM_e` z8WnS_I|?gc_w`97nzFYgld1zM&lRCao(`$TJ_RAYbK}$tm1E~>y0po_!$7sk8ABhp z_Dr)q7d@VFN=us|SPak~_=t~!17SSlmXBRtQFD~BE-#M!sL?kD?6yuzqC#9Wwr3-5 z5@kjO7jO~FL}|(OO1+Hr4t&t+cYKIWr`}j7=7KVfch>r_BnQ@1rM+tN9c7|l3^=%@ z?>s&et1Z&RTv4!bk%tM%=#Lf&m@3np${W+@-p$q9n`#F|V|`bzL*SrEszg#8!vyTl zIX;#fHQypM_=g*7%9FXnBo_!Gz`IXlH zV?pm3@Y3U;8R`YKG|F|=A?NNpFW@YX#U^$tuiXkhwpY-=oiZKN)Zv1M8+NpjH+qy! zf!Mrv>2GRBP}m{1v~}nZJS;hWI9s*~A0`#^s;7Ai_1N1#@7Gy+f{ni2%jBMCvgyL4 K4pWnBi2ni#0R6WB delta 61 zcmeA%Sggt;bd*VmB{wlMPiYU+Mjl(n%^w+m32-a1ml^6And=#7g-$*%F3abhnwyrH Rld5EumY7qVI{A^f763xP6L void end + + class Regression + attr_reader id: Integer + attr_reader order: String + attr_reader note: String? + def initialize: ( Integer id, String order, String? note) -> void + end end diff --git a/test/golden/mysql2/default/my_queries_sql.rb b/test/golden/mysql2/default/my_queries_sql.rb index 05bd12b..2f677b4 100644 --- a/test/golden/mysql2/default/my_queries_sql.rb +++ b/test/golden/mysql2/default/my_queries_sql.rb @@ -29,8 +29,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -42,15 +43,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end diff --git a/test/golden/mysql2/default/other_sql.rb b/test/golden/mysql2/default/other_sql.rb index f204cec..3d5d7c8 100644 --- a/test/golden/mysql2/default/other_sql.rb +++ b/test/golden/mysql2/default/other_sql.rb @@ -42,8 +42,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -55,15 +56,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -91,9 +100,9 @@ def other_one with_client('other_one') do |client| stmt = client.prepare(OTHER_ONE_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first - row && OtherOneRow.new(row['c0']) + row && OtherOneRow.new(row[0]) ensure stmt.close end diff --git a/test/golden/mysql2/default/query_sql.rb b/test/golden/mysql2/default/query_sql.rb index a4cfd49..f1d4d24 100644 --- a/test/golden/mysql2/default/query_sql.rb +++ b/test/golden/mysql2/default/query_sql.rb @@ -3,7 +3,6 @@ # auto-generated by sqlc - do not edit require 'connection_pool' require 'mysql2' -require 'csv' require 'tempfile' require_relative 'connection' require_relative 'models' @@ -188,6 +187,27 @@ class GetFirstExtendedBioByTypeRow < Data.define(:author_name, :name, :bio_type, DELETE FROM extended_bios SQL + COUNT_REGRESSIONS_BY_TEXT_SQL = <<~'SQL' + SELECT count(*) FROM regressions WHERE `order` = ? OR note = ? + SQL + + class CountRegressionsByTextRow < Data.define(:count) + end + + LIST_REGRESSIONS_BY_IDS_SQL = <<~'SQL' + SELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY id + SQL + + class ListRegressionsByIdsRow < Data.define(:id, :order, :note) + end + + class CopyRegressionsArgs < Data.define(:order, :note) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -235,8 +255,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -248,15 +269,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -267,9 +296,9 @@ def get_author(id:) query_params = [id] stmt = client.prepare(GET_AUTHOR_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetAuthorRow.new(row['id'], row['name'], row['bio']) + row && GetAuthorRow.new(row[0], row[1], row[2]) ensure stmt.close end @@ -285,9 +314,9 @@ def list_authors with_client('list_authors') do |client| stmt = client.prepare(LIST_AUTHORS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - ListAuthorsRow.new(row['id'], row['name'], row['bio']) + ListAuthorsRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -351,46 +380,46 @@ def test with_client('test') do |client| stmt = client.prepare(TEST_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first row && TestRow.new( - row['c_bit'], - row['c_tinyint'], - row['c_bool'], - row['c_boolean'], - row['c_smallint'], - row['c_mediumint'], - row['c_int'], - row['c_integer'], - row['c_bigint'], - row['c_serial'], - row['c_decimal'], - row['c_dec'], - row['c_numeric'], - row['c_fixed'], - row['c_float'], - row['c_double'], - row['c_double_precision'], - row['c_date'], - row['c_time'], - row['c_datetime'], - row['c_timestamp'], - row['c_year'], - row['c_char'], - row['c_nchar'], - row['c_national_char'], - row['c_varchar'], - row['c_binary'], - row['c_varbinary'], - row['c_tinyblob'], - row['c_tinytext'], - row['c_blob'], - row['c_text'], - row['c_mediumblob'], - row['c_mediumtext'], - row['c_longblob'], - row['c_longtext'], - row['c_json'] + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + row[12], + row[13], + row[14], + row[15], + row[16], + row[17], + row[18], + row[19], + row[20], + row[21], + row[22], + row[23], + row[24], + row[25], + row[26], + row[27], + row[28], + row[29], + row[30], + row[31], + row[32], + row[33], + row[34], + row[35], + row[36] ) ensure stmt.close @@ -408,9 +437,9 @@ def list_authors_paged(limit:, offset:) query_params = [limit, offset] stmt = client.prepare(LIST_AUTHORS_PAGED_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - ListAuthorsPagedRow.new(row['id'], row['name'], row['bio']) + ListAuthorsPagedRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -423,9 +452,9 @@ def get_author_by_name_pattern(name_pattern: nil) query_params = [name_pattern] stmt = client.prepare(GET_AUTHOR_BY_NAME_PATTERN_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorByNamePatternRow.new(row['id'], row['name'], row['bio']) + GetAuthorByNamePatternRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -450,12 +479,12 @@ def update_author_bios(bio: nil) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -466,13 +495,13 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsAndNamesRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsAndNamesRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -490,7 +519,7 @@ def copy_authors(rows) file.write(csv_line([row.name, row.bio])) end file.flush - client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE authors FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (name, bio)") + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") end nil end @@ -527,7 +556,10 @@ def list_all_authors_books begin result = stmt.execute(as: :array) result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end ensure stmt.close @@ -582,9 +614,9 @@ def get_first_extended_bio_by_type(bio_type:) query_params = [bio_type] stmt = client.prepare(GET_FIRST_EXTENDED_BIO_BY_TYPE_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetFirstExtendedBioByTypeRow.new(row['author_name'], row['name'], row['bio_type'], row['author_type']) + row && GetFirstExtendedBioByTypeRow.new(row[0], row[1], row[2], row[3]) ensure stmt.close end @@ -608,6 +640,67 @@ def truncate_extended_bios end end + def count_regressions_by_text(term:) + with_client('count_regressions_by_text') do |client| + query_params = [term, term] + stmt = client.prepare(COUNT_REGRESSIONS_BY_TEXT_SQL) + begin + result = stmt.execute(*query_params, as: :array) + row = result.first + row && CountRegressionsByTextRow.new(row[0]) + ensure + stmt.close + end + end + end + + # Like #count_regressions_by_text, but raises NotFound when there is no row. + def count_regressions_by_text!(...) + count_regressions_by_text(...) || raise(NotFound, 'CountRegressionsByText returned no row') + end + + def list_regressions_by_ids(id:, id_2:, id_3:) + with_client('list_regressions_by_ids') do |client| + query_params = [id, id_2, id_3] + stmt = client.prepare(LIST_REGRESSIONS_BY_IDS_SQL) + begin + result = stmt.execute(*query_params, as: :array) + result.map do |row| + ListRegressionsByIdsRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + return if rows.empty? + + Tempfile.create(['copy_regressions', '.csv']) do |file| + rows.each do |row| + file.write(csv_line([row.order, row.note])) + end + file.flush + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + end + nil + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -619,9 +712,9 @@ def many_params(c0:, c1:, c2:, c3:, c4:) ] stmt = client.prepare(MANY_PARAMS_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && ManyParamsRow.new(row['c0'], row['c1']) + row && ManyParamsRow.new(row[0], row[1]) ensure stmt.close end @@ -638,14 +731,14 @@ def many_rows(c0:) query_params = [c0] stmt = client.prepare(MANY_ROWS_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - manyRowsRow.new( - row['c0'], - row['c1'], - row['c2'], - row['c3'], - row['c4'] + ManyRowsRow.new( + row[0], + row[1], + row[2], + row[3], + row[4] ) end ensure @@ -658,9 +751,9 @@ def four_cols with_client('four_cols') do |client| stmt = client.prepare(FOUR_COLS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - FourColsRow.new(row['c0'], row['c1'], row['c2'], row['c3']) + FourColsRow.new(row[0], row[1], row[2], row[3]) end ensure stmt.close @@ -699,10 +792,10 @@ def csv_line(values) when true then '1' when false then '0' when Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') - else value.to_s.gsub('\\') { '\\\\' } + else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""')}") end end - CSV.generate_line(fields) + "#{fields.join(',')}\n" end def slice_placeholders(values) diff --git a/test/golden/mysql2/default/query_sql.rbs b/test/golden/mysql2/default/query_sql.rbs index 2aef607..8c3b38e 100644 --- a/test/golden/mysql2/default/query_sql.rbs +++ b/test/golden/mysql2/default/query_sql.rbs @@ -195,6 +195,30 @@ module Mysql2Codegen TRUNCATE_EXTENDED_BIOS_SQL: String + COUNT_REGRESSIONS_BY_TEXT_SQL: String + + class CountRegressionsByTextRow + attr_reader count: Integer + def initialize: ( Integer count) -> void + end + + LIST_REGRESSIONS_BY_IDS_SQL: String + + class ListRegressionsByIdsRow + attr_reader id: Integer + attr_reader order: String + attr_reader note: String? + def initialize: ( Integer id, String order, String? note) -> void + end + + class CopyRegressionsArgs + attr_reader order: String + attr_reader note: String? + def initialize: ( String order, String? note) -> void + end + + TRUNCATE_REGRESSIONS_SQL: String + MANY_PARAMS_SQL: String class ManyParamsRow @@ -288,11 +312,21 @@ module Mysql2Codegen def truncate_extended_bios: -> void + def count_regressions_by_text: (term: String) -> CountRegressionsByTextRow? + + def count_regressions_by_text!: (term: String) -> CountRegressionsByTextRow + + def list_regressions_by_ids: (id: Integer, id_2: Integer, id_3: Integer) -> Array[ListRegressionsByIdsRow] + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def truncate_regressions: -> void + def many_params: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow? def many_params!: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow - def many_rows: (c0: Integer) -> Array[manyRowsRow] + def many_rows: (c0: Integer) -> Array[ManyRowsRow] def four_cols: -> Array[FourColsRow] diff --git a/test/golden/mysql2/ruby31_gemfile/Gemfile b/test/golden/mysql2/ruby31_gemfile/Gemfile index 2fe5953..ba8c9e9 100644 --- a/test/golden/mysql2/ruby31_gemfile/Gemfile +++ b/test/golden/mysql2/ruby31_gemfile/Gemfile @@ -1,5 +1,6 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gem 'connection_pool' gem 'mysql2' -gem 'csv' \ No newline at end of file diff --git a/test/golden/mysql2/ruby31_gemfile/models.rb b/test/golden/mysql2/ruby31_gemfile/models.rb index 7a5a36d..4966c7c 100644 --- a/test/golden/mysql2/ruby31_gemfile/models.rb +++ b/test/golden/mysql2/ruby31_gemfile/models.rb @@ -17,10 +17,9 @@ module ExtendedBiosAuthorType ALL = [AUTHOR, EDITOR, TRANSLATOR].freeze end - class Author < Struct.new(:id, :name, :bio) - end + Author = Struct.new(:id, :name, :bio, keyword_init: true) - class NodeMysqlType < Struct.new( + NodeMysqlType = Struct.new( :c_bit, :c_tinyint, :c_bool, @@ -57,13 +56,25 @@ class NodeMysqlType < Struct.new( :c_mediumtext, :c_longblob, :c_longtext, - :c_json + :c_json, + keyword_init: true ) - end - class Book < Struct.new(:id, :name, :author_id, :description) - end + Book = Struct.new( + :id, + :name, + :author_id, + :description, + keyword_init: true + ) - class ExtendedBio < Struct.new(:author_name, :name, :bio_type, :author_type) - end + ExtendedBio = Struct.new( + :author_name, + :name, + :bio_type, + :author_type, + keyword_init: true + ) + + Regression = Struct.new(:id, :order, :note, keyword_init: true) end diff --git a/test/golden/mysql2/ruby31_gemfile/models.rbs b/test/golden/mysql2/ruby31_gemfile/models.rbs index a728153..8dce5e5 100644 --- a/test/golden/mysql2/ruby31_gemfile/models.rbs +++ b/test/golden/mysql2/ruby31_gemfile/models.rbs @@ -14,14 +14,15 @@ module Mysql2Codegen ALL: Array[String] end - class Author < ::Struct + class Author < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> Author + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> Author end - class NodeMysqlType < ::Struct + class NodeMysqlType < ::Struct[untyped] attr_accessor c_bit: String? attr_accessor c_tinyint: Integer? attr_accessor c_bool: Integer? @@ -59,60 +60,109 @@ module Mysql2Codegen attr_accessor c_longblob: String? attr_accessor c_longtext: String? attr_accessor c_json: String? + def initialize: ( + c_bit: String?, + c_tinyint: Integer?, + c_bool: Integer?, + c_boolean: Integer?, + c_smallint: Integer?, + c_mediumint: Integer?, + c_int: Integer?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer, + c_decimal: BigDecimal?, + c_dec: BigDecimal?, + c_numeric: BigDecimal?, + c_fixed: BigDecimal?, + c_float: Float?, + c_double: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: Time?, + c_datetime: Time?, + c_timestamp: Time?, + c_year: Integer?, + c_char: String?, + c_nchar: String?, + c_national_char: String?, + c_varchar: String?, + c_binary: String?, + c_varbinary: String?, + c_tinyblob: String?, + c_tinytext: String?, + c_blob: String?, + c_text: String?, + c_mediumblob: String?, + c_mediumtext: String?, + c_longblob: String?, + c_longtext: String?, + c_json: String? + ) -> void def self.new: ( - String? c_bit, - Integer? c_tinyint, - Integer? c_bool, - Integer? c_boolean, - Integer? c_smallint, - Integer? c_mediumint, - Integer? c_int, - Integer? c_integer, - Integer? c_bigint, - Integer c_serial, - BigDecimal? c_decimal, - BigDecimal? c_dec, - BigDecimal? c_numeric, - BigDecimal? c_fixed, - Float? c_float, - Float? c_double, - Float? c_double_precision, - Date? c_date, - Time? c_time, - Time? c_datetime, - Time? c_timestamp, - Integer? c_year, - String? c_char, - String? c_nchar, - String? c_national_char, - String? c_varchar, - String? c_binary, - String? c_varbinary, - String? c_tinyblob, - String? c_tinytext, - String? c_blob, - String? c_text, - String? c_mediumblob, - String? c_mediumtext, - String? c_longblob, - String? c_longtext, - String? c_json + c_bit: String?, + c_tinyint: Integer?, + c_bool: Integer?, + c_boolean: Integer?, + c_smallint: Integer?, + c_mediumint: Integer?, + c_int: Integer?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer, + c_decimal: BigDecimal?, + c_dec: BigDecimal?, + c_numeric: BigDecimal?, + c_fixed: BigDecimal?, + c_float: Float?, + c_double: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: Time?, + c_datetime: Time?, + c_timestamp: Time?, + c_year: Integer?, + c_char: String?, + c_nchar: String?, + c_national_char: String?, + c_varchar: String?, + c_binary: String?, + c_varbinary: String?, + c_tinyblob: String?, + c_tinytext: String?, + c_blob: String?, + c_text: String?, + c_mediumblob: String?, + c_mediumtext: String?, + c_longblob: String?, + c_longtext: String?, + c_json: String? ) -> NodeMysqlType end - class Book < ::Struct + class Book < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor author_id: Integer attr_accessor description: String? - def self.new: ( Integer id, String name, Integer author_id, String? description) -> Book + def initialize: ( id: Integer, name: String, author_id: Integer, description: String?) -> void + def self.new: ( id: Integer, name: String, author_id: Integer, description: String?) -> Book end - class ExtendedBio < ::Struct + class ExtendedBio < ::Struct[untyped] attr_accessor author_name: String? attr_accessor name: String? attr_accessor bio_type: String? attr_accessor author_type: String? - def self.new: ( String? author_name, String? name, String? bio_type, String? author_type) -> ExtendedBio + def initialize: ( author_name: String?, name: String?, bio_type: String?, author_type: String?) -> void + def self.new: ( author_name: String?, name: String?, bio_type: String?, author_type: String?) -> ExtendedBio + end + + class Regression < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor note: String? + def initialize: ( id: Integer, order: String, note: String?) -> void + def self.new: ( id: Integer, order: String, note: String?) -> Regression end end diff --git a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb index 05bd12b..2f677b4 100644 --- a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb @@ -29,8 +29,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -42,15 +43,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end diff --git a/test/golden/mysql2/ruby31_gemfile/other_sql.rb b/test/golden/mysql2/ruby31_gemfile/other_sql.rb index 8595621..a2bd1aa 100644 --- a/test/golden/mysql2/ruby31_gemfile/other_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/other_sql.rb @@ -17,8 +17,7 @@ module Mysql2Codegen WHERE a = ? SQL - class OtherOneRow < Struct.new(:c0) - end + OtherOneRow = Struct.new(:c0, keyword_init: true) COUNT_ROWS_SQL = <<~'SQL' SELECT 1 -- ? ? @@ -42,8 +41,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -55,15 +55,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -91,9 +99,9 @@ def other_one with_client('other_one') do |client| stmt = client.prepare(OTHER_ONE_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first - row && OtherOneRow.new(row['c0']) + row && OtherOneRow.new(c0: row[0]) ensure stmt.close end diff --git a/test/golden/mysql2/ruby31_gemfile/other_sql.rbs b/test/golden/mysql2/ruby31_gemfile/other_sql.rbs index afd2285..118f7bf 100644 --- a/test/golden/mysql2/ruby31_gemfile/other_sql.rbs +++ b/test/golden/mysql2/ruby31_gemfile/other_sql.rbs @@ -4,9 +4,10 @@ module Mysql2Codegen OTHER_ONE_SQL: String - class OtherOneRow < ::Struct + class OtherOneRow < ::Struct[untyped] attr_accessor c0: Integer - def self.new: ( Integer c0) -> OtherOneRow + def initialize: ( c0: Integer) -> void + def self.new: ( c0: Integer) -> OtherOneRow end COUNT_ROWS_SQL: String diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rb b/test/golden/mysql2/ruby31_gemfile/query_sql.rb index 833d0a6..9261405 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rb @@ -3,7 +3,6 @@ # auto-generated by sqlc - do not edit require 'connection_pool' require 'mysql2' -require 'csv' require 'tempfile' require_relative 'connection' require_relative 'models' @@ -14,16 +13,14 @@ module Mysql2Codegen WHERE id = ? LIMIT 1 SQL - class GetAuthorRow < Struct.new(:id, :name, :bio) - end + GetAuthorRow = Struct.new(:id, :name, :bio, keyword_init: true) LIST_AUTHORS_SQL = <<~'SQL' SELECT id, name, bio FROM authors ORDER BY name SQL - class ListAuthorsRow < Struct.new(:id, :name, :bio) - end + ListAuthorsRow = Struct.new(:id, :name, :bio, keyword_init: true) CREATE_AUTHOR_SQL = <<~'SQL' INSERT INTO authors ( @@ -57,7 +54,7 @@ class ListAuthorsRow < Struct.new(:id, :name, :bio) LIMIT 1 SQL - class TestRow < Struct.new( + TestRow = Struct.new( :c_bit, :c_tinyint, :c_bool, @@ -94,9 +91,9 @@ class TestRow < Struct.new( :c_mediumtext, :c_longblob, :c_longtext, - :c_json + :c_json, + keyword_init: true ) - end LIST_AUTHORS_PAGED_SQL = <<~'SQL' SELECT id, name, bio FROM authors @@ -104,16 +101,14 @@ class TestRow < Struct.new( LIMIT ? OFFSET ? SQL - class ListAuthorsPagedRow < Struct.new(:id, :name, :bio) - end + ListAuthorsPagedRow = Struct.new(:id, :name, :bio, keyword_init: true) GET_AUTHOR_BY_NAME_PATTERN_SQL = <<~'SQL' SELECT id, name, bio FROM authors WHERE name LIKE COALESCE(?, '%') SQL - class GetAuthorByNamePatternRow < Struct.new(:id, :name, :bio) - end + GetAuthorByNamePatternRow = Struct.new(:id, :name, :bio, keyword_init: true) UPDATE_AUTHOR_BIOS_SQL = <<~'SQL' UPDATE authors @@ -125,18 +120,15 @@ class GetAuthorByNamePatternRow < Struct.new(:id, :name, :bio) SELECT id, name, bio FROM authors WHERE id IN (/*SLICE:ids*/?) SQL - class GetAuthorsByIdsRow < Struct.new(:id, :name, :bio) - end + GetAuthorsByIdsRow = Struct.new(:id, :name, :bio, keyword_init: true) GET_AUTHORS_BY_IDS_AND_NAMES_SQL = <<~'SQL' SELECT id, name, bio FROM authors WHERE id IN (/*SLICE:ids*/?) AND name IN (/*SLICE:names*/?) SQL - class GetAuthorsByIdsAndNamesRow < Struct.new(:id, :name, :bio) - end + GetAuthorsByIdsAndNamesRow = Struct.new(:id, :name, :bio, keyword_init: true) - class CopyAuthorsArgs < Struct.new(:name, :bio) - end + CopyAuthorsArgs = Struct.new(:name, :bio, keyword_init: true) TRUNCATE_AUTHORS_SQL = <<~'SQL' DELETE FROM authors @@ -152,8 +144,7 @@ class CopyAuthorsArgs < Struct.new(:name, :bio) ORDER BY authors.name SQL - class ListAllAuthorsBooksRow < Struct.new(:author, :book) - end + ListAllAuthorsBooksRow = Struct.new(:author, :book, keyword_init: true) GET_DUPLICATE_AUTHORS_SQL = <<~'SQL' SELECT authors1.id, authors1.name, authors1.bio, authors2.id, authors2.name, authors2.bio @@ -161,8 +152,7 @@ class ListAllAuthorsBooksRow < Struct.new(:author, :book) WHERE authors1.id < authors2.id SQL - class GetDuplicateAuthorsRow < Struct.new(:author, :author2) - end + GetDuplicateAuthorsRow = Struct.new(:author, :author2, keyword_init: true) GET_AUTHORS_BY_BOOK_NAME_SQL = <<~'SQL' SELECT authors.id, authors.name, authors.bio, books.id, books.name, books.author_id, books.description @@ -170,8 +160,13 @@ class GetDuplicateAuthorsRow < Struct.new(:author, :author2) WHERE books.name = ? SQL - class GetAuthorsByBookNameRow < Struct.new(:id, :name, :bio, :book) - end + GetAuthorsByBookNameRow = Struct.new( + :id, + :name, + :bio, + :book, + keyword_init: true + ) CREATE_EXTENDED_BIO_SQL = <<~'SQL' INSERT INTO extended_bios (author_name, name, bio_type, author_type) VALUES (?, ?, ?, ?) @@ -181,42 +176,69 @@ class GetAuthorsByBookNameRow < Struct.new(:id, :name, :bio, :book) SELECT author_name, name, bio_type, author_type FROM extended_bios WHERE bio_type = ? LIMIT 1 SQL - class GetFirstExtendedBioByTypeRow < Struct.new(:author_name, :name, :bio_type, :author_type) - end + GetFirstExtendedBioByTypeRow = Struct.new( + :author_name, + :name, + :bio_type, + :author_type, + keyword_init: true + ) TRUNCATE_EXTENDED_BIOS_SQL = <<~'SQL' DELETE FROM extended_bios SQL + COUNT_REGRESSIONS_BY_TEXT_SQL = <<~'SQL' + SELECT count(*) FROM regressions WHERE `order` = ? OR note = ? + SQL + + CountRegressionsByTextRow = Struct.new(:count_, keyword_init: true) + + LIST_REGRESSIONS_BY_IDS_SQL = <<~'SQL' + SELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY id + SQL + + ListRegressionsByIdsRow = Struct.new(:id, :order, :note, keyword_init: true) + + CopyRegressionsArgs = Struct.new(:order, :note, keyword_init: true) + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class ManyParamsRow < Struct.new(:c0, :c1) - end + ManyParamsRow = Struct.new(:c0, :c1, keyword_init: true) MANY_ROWS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class ManyRowsRow < Struct.new( + ManyRowsRow = Struct.new( :c0, :c1, :c2, :c3, - :c4 + :c4, + keyword_init: true ) - end FOUR_COLS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class FourColsRow < Struct.new(:c0, :c1, :c2, :c3) - end + FourColsRow = Struct.new( + :c0, + :c1, + :c2, + :c3, + keyword_init: true + ) class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. @@ -235,8 +257,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -248,15 +271,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -267,9 +298,9 @@ def get_author(id:) query_params = [id] stmt = client.prepare(GET_AUTHOR_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetAuthorRow.new(row['id'], row['name'], row['bio']) + row && GetAuthorRow.new(id: row[0], name: row[1], bio: row[2]) ensure stmt.close end @@ -285,9 +316,9 @@ def list_authors with_client('list_authors') do |client| stmt = client.prepare(LIST_AUTHORS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - ListAuthorsRow.new(row['id'], row['name'], row['bio']) + ListAuthorsRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -351,46 +382,46 @@ def test with_client('test') do |client| stmt = client.prepare(TEST_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first row && TestRow.new( - row['c_bit'], - row['c_tinyint'], - row['c_bool'], - row['c_boolean'], - row['c_smallint'], - row['c_mediumint'], - row['c_int'], - row['c_integer'], - row['c_bigint'], - row['c_serial'], - row['c_decimal'], - row['c_dec'], - row['c_numeric'], - row['c_fixed'], - row['c_float'], - row['c_double'], - row['c_double_precision'], - row['c_date'], - row['c_time'], - row['c_datetime'], - row['c_timestamp'], - row['c_year'], - row['c_char'], - row['c_nchar'], - row['c_national_char'], - row['c_varchar'], - row['c_binary'], - row['c_varbinary'], - row['c_tinyblob'], - row['c_tinytext'], - row['c_blob'], - row['c_text'], - row['c_mediumblob'], - row['c_mediumtext'], - row['c_longblob'], - row['c_longtext'], - row['c_json'] + c_bit: row[0], + c_tinyint: row[1], + c_bool: row[2], + c_boolean: row[3], + c_smallint: row[4], + c_mediumint: row[5], + c_int: row[6], + c_integer: row[7], + c_bigint: row[8], + c_serial: row[9], + c_decimal: row[10], + c_dec: row[11], + c_numeric: row[12], + c_fixed: row[13], + c_float: row[14], + c_double: row[15], + c_double_precision: row[16], + c_date: row[17], + c_time: row[18], + c_datetime: row[19], + c_timestamp: row[20], + c_year: row[21], + c_char: row[22], + c_nchar: row[23], + c_national_char: row[24], + c_varchar: row[25], + c_binary: row[26], + c_varbinary: row[27], + c_tinyblob: row[28], + c_tinytext: row[29], + c_blob: row[30], + c_text: row[31], + c_mediumblob: row[32], + c_mediumtext: row[33], + c_longblob: row[34], + c_longtext: row[35], + c_json: row[36] ) ensure stmt.close @@ -408,9 +439,9 @@ def list_authors_paged(limit:, offset:) query_params = [limit, offset] stmt = client.prepare(LIST_AUTHORS_PAGED_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - ListAuthorsPagedRow.new(row['id'], row['name'], row['bio']) + ListAuthorsPagedRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -423,9 +454,9 @@ def get_author_by_name_pattern(name_pattern: nil) query_params = [name_pattern] stmt = client.prepare(GET_AUTHOR_BY_NAME_PATTERN_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorByNamePatternRow.new(row['id'], row['name'], row['bio']) + GetAuthorByNamePatternRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -450,12 +481,12 @@ def update_author_bios(bio: nil) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -466,13 +497,13 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsAndNamesRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsAndNamesRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -490,7 +521,7 @@ def copy_authors(rows) file.write(csv_line([row.name, row.bio])) end file.flush - client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE authors FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (name, bio)") + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") end nil end @@ -527,7 +558,10 @@ def list_all_authors_books begin result = stmt.execute(as: :array) result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + author: Author.new(id: row[0], name: row[1], bio: row[2]), + book: Book.new(id: row[3], name: row[4], author_id: row[5], description: row[6]) + ) end ensure stmt.close @@ -541,7 +575,10 @@ def get_duplicate_authors begin result = stmt.execute(as: :array) result.map do |row| - GetDuplicateAuthorsRow.new(Author.new(row[0], row[1], row[2]), Author.new(row[3], row[4], row[5])) + GetDuplicateAuthorsRow.new( + author: Author.new(id: row[0], name: row[1], bio: row[2]), + author2: Author.new(id: row[3], name: row[4], bio: row[5]) + ) end ensure stmt.close @@ -556,7 +593,12 @@ def get_authors_by_book_name(name:) begin result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByBookNameRow.new(row[0], row[1], row[2], Book.new(row[3], row[4], row[5], row[6])) + GetAuthorsByBookNameRow.new( + id: row[0], + name: row[1], + bio: row[2], + book: Book.new(id: row[3], name: row[4], author_id: row[5], description: row[6]) + ) end ensure stmt.close @@ -582,9 +624,14 @@ def get_first_extended_bio_by_type(bio_type:) query_params = [bio_type] stmt = client.prepare(GET_FIRST_EXTENDED_BIO_BY_TYPE_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetFirstExtendedBioByTypeRow.new(row['author_name'], row['name'], row['bio_type'], row['author_type']) + row && GetFirstExtendedBioByTypeRow.new( + author_name: row[0], + name: row[1], + bio_type: row[2], + author_type: row[3] + ) ensure stmt.close end @@ -608,6 +655,67 @@ def truncate_extended_bios end end + def count_regressions_by_text(term:) + with_client('count_regressions_by_text') do |client| + query_params = [term, term] + stmt = client.prepare(COUNT_REGRESSIONS_BY_TEXT_SQL) + begin + result = stmt.execute(*query_params, as: :array) + row = result.first + row && CountRegressionsByTextRow.new(count_: row[0]) + ensure + stmt.close + end + end + end + + # Like #count_regressions_by_text, but raises NotFound when there is no row. + def count_regressions_by_text!(...) + count_regressions_by_text(...) || raise(NotFound, 'CountRegressionsByText returned no row') + end + + def list_regressions_by_ids(id:, id_2:, id_3:) + with_client('list_regressions_by_ids') do |client| + query_params = [id, id_2, id_3] + stmt = client.prepare(LIST_REGRESSIONS_BY_IDS_SQL) + begin + result = stmt.execute(*query_params, as: :array) + result.map do |row| + ListRegressionsByIdsRow.new(id: row[0], order: row[1], note: row[2]) + end + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + return if rows.empty? + + Tempfile.create(['copy_regressions', '.csv']) do |file| + rows.each do |row| + file.write(csv_line([row.order, row.note])) + end + file.flush + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + end + nil + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -619,9 +727,9 @@ def many_params(c0:, c1:, c2:, c3:, c4:) ] stmt = client.prepare(MANY_PARAMS_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && ManyParamsRow.new(row['c0'], row['c1']) + row && ManyParamsRow.new(c0: row[0], c1: row[1]) ensure stmt.close end @@ -638,14 +746,14 @@ def many_rows(c0:) query_params = [c0] stmt = client.prepare(MANY_ROWS_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - manyRowsRow.new( - row['c0'], - row['c1'], - row['c2'], - row['c3'], - row['c4'] + ManyRowsRow.new( + c0: row[0], + c1: row[1], + c2: row[2], + c3: row[3], + c4: row[4] ) end ensure @@ -658,9 +766,9 @@ def four_cols with_client('four_cols') do |client| stmt = client.prepare(FOUR_COLS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - FourColsRow.new(row['c0'], row['c1'], row['c2'], row['c3']) + FourColsRow.new(c0: row[0], c1: row[1], c2: row[2], c3: row[3]) end ensure stmt.close @@ -699,10 +807,10 @@ def csv_line(values) when true then '1' when false then '0' when Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') - else value.to_s.gsub('\\') { '\\\\' } + else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""')}") end end - CSV.generate_line(fields) + "#{fields.join(',')}\n" end def slice_placeholders(values) diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rbs b/test/golden/mysql2/ruby31_gemfile/query_sql.rbs index d3dc555..8b61d64 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rbs +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rbs @@ -2,20 +2,22 @@ module Mysql2Codegen GET_AUTHOR_SQL: String - class GetAuthorRow < ::Struct + class GetAuthorRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorRow end LIST_AUTHORS_SQL: String - class ListAuthorsRow < ::Struct + class ListAuthorsRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> ListAuthorsRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsRow end CREATE_AUTHOR_SQL: String @@ -28,7 +30,7 @@ module Mysql2Codegen TEST_SQL: String - class TestRow < ::Struct + class TestRow < ::Struct[untyped] attr_accessor c_bit: String? attr_accessor c_tinyint: Integer? attr_accessor c_bool: Integer? @@ -66,89 +68,133 @@ module Mysql2Codegen attr_accessor c_longblob: String? attr_accessor c_longtext: String? attr_accessor c_json: String? + def initialize: ( + c_bit: String?, + c_tinyint: Integer?, + c_bool: Integer?, + c_boolean: Integer?, + c_smallint: Integer?, + c_mediumint: Integer?, + c_int: Integer?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer, + c_decimal: BigDecimal?, + c_dec: BigDecimal?, + c_numeric: BigDecimal?, + c_fixed: BigDecimal?, + c_float: Float?, + c_double: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: Time?, + c_datetime: Time?, + c_timestamp: Time?, + c_year: Integer?, + c_char: String?, + c_nchar: String?, + c_national_char: String?, + c_varchar: String?, + c_binary: String?, + c_varbinary: String?, + c_tinyblob: String?, + c_tinytext: String?, + c_blob: String?, + c_text: String?, + c_mediumblob: String?, + c_mediumtext: String?, + c_longblob: String?, + c_longtext: String?, + c_json: String? + ) -> void def self.new: ( - String? c_bit, - Integer? c_tinyint, - Integer? c_bool, - Integer? c_boolean, - Integer? c_smallint, - Integer? c_mediumint, - Integer? c_int, - Integer? c_integer, - Integer? c_bigint, - Integer c_serial, - BigDecimal? c_decimal, - BigDecimal? c_dec, - BigDecimal? c_numeric, - BigDecimal? c_fixed, - Float? c_float, - Float? c_double, - Float? c_double_precision, - Date? c_date, - Time? c_time, - Time? c_datetime, - Time? c_timestamp, - Integer? c_year, - String? c_char, - String? c_nchar, - String? c_national_char, - String? c_varchar, - String? c_binary, - String? c_varbinary, - String? c_tinyblob, - String? c_tinytext, - String? c_blob, - String? c_text, - String? c_mediumblob, - String? c_mediumtext, - String? c_longblob, - String? c_longtext, - String? c_json + c_bit: String?, + c_tinyint: Integer?, + c_bool: Integer?, + c_boolean: Integer?, + c_smallint: Integer?, + c_mediumint: Integer?, + c_int: Integer?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer, + c_decimal: BigDecimal?, + c_dec: BigDecimal?, + c_numeric: BigDecimal?, + c_fixed: BigDecimal?, + c_float: Float?, + c_double: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: Time?, + c_datetime: Time?, + c_timestamp: Time?, + c_year: Integer?, + c_char: String?, + c_nchar: String?, + c_national_char: String?, + c_varchar: String?, + c_binary: String?, + c_varbinary: String?, + c_tinyblob: String?, + c_tinytext: String?, + c_blob: String?, + c_text: String?, + c_mediumblob: String?, + c_mediumtext: String?, + c_longblob: String?, + c_longtext: String?, + c_json: String? ) -> TestRow end LIST_AUTHORS_PAGED_SQL: String - class ListAuthorsPagedRow < ::Struct + class ListAuthorsPagedRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> ListAuthorsPagedRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsPagedRow end GET_AUTHOR_BY_NAME_PATTERN_SQL: String - class GetAuthorByNamePatternRow < ::Struct + class GetAuthorByNamePatternRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorByNamePatternRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorByNamePatternRow end UPDATE_AUTHOR_BIOS_SQL: String GET_AUTHORS_BY_IDS_SQL: String - class GetAuthorsByIdsRow < ::Struct + class GetAuthorsByIdsRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorsByIdsRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorsByIdsRow end GET_AUTHORS_BY_IDS_AND_NAMES_SQL: String - class GetAuthorsByIdsAndNamesRow < ::Struct + class GetAuthorsByIdsAndNamesRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorsByIdsAndNamesRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorsByIdsAndNamesRow end - class CopyAuthorsArgs < ::Struct + class CopyAuthorsArgs < ::Struct[untyped] attr_accessor name: String attr_accessor bio: String? - def self.new: ( String name, String? bio) -> CopyAuthorsArgs + def initialize: ( name: String, bio: String?) -> void + def self.new: ( name: String, bio: String?) -> CopyAuthorsArgs end TRUNCATE_AUTHORS_SQL: String @@ -157,77 +203,117 @@ module Mysql2Codegen LIST_ALL_AUTHORS_BOOKS_SQL: String - class ListAllAuthorsBooksRow < ::Struct + class ListAllAuthorsBooksRow < ::Struct[untyped] attr_accessor author: Author attr_accessor book: Book - def self.new: ( Author author, Book book) -> ListAllAuthorsBooksRow + def initialize: ( author: Author, book: Book) -> void + def self.new: ( author: Author, book: Book) -> ListAllAuthorsBooksRow end GET_DUPLICATE_AUTHORS_SQL: String - class GetDuplicateAuthorsRow < ::Struct + class GetDuplicateAuthorsRow < ::Struct[untyped] attr_accessor author: Author attr_accessor author2: Author - def self.new: ( Author author, Author author2) -> GetDuplicateAuthorsRow + def initialize: ( author: Author, author2: Author) -> void + def self.new: ( author: Author, author2: Author) -> GetDuplicateAuthorsRow end GET_AUTHORS_BY_BOOK_NAME_SQL: String - class GetAuthorsByBookNameRow < ::Struct + class GetAuthorsByBookNameRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? attr_accessor book: Book - def self.new: ( Integer id, String name, String? bio, Book book) -> GetAuthorsByBookNameRow + def initialize: ( id: Integer, name: String, bio: String?, book: Book) -> void + def self.new: ( id: Integer, name: String, bio: String?, book: Book) -> GetAuthorsByBookNameRow end CREATE_EXTENDED_BIO_SQL: String GET_FIRST_EXTENDED_BIO_BY_TYPE_SQL: String - class GetFirstExtendedBioByTypeRow < ::Struct + class GetFirstExtendedBioByTypeRow < ::Struct[untyped] attr_accessor author_name: String? attr_accessor name: String? attr_accessor bio_type: String? attr_accessor author_type: String? - def self.new: ( String? author_name, String? name, String? bio_type, String? author_type) -> GetFirstExtendedBioByTypeRow + def initialize: ( author_name: String?, name: String?, bio_type: String?, author_type: String?) -> void + def self.new: ( author_name: String?, name: String?, bio_type: String?, author_type: String?) -> GetFirstExtendedBioByTypeRow end TRUNCATE_EXTENDED_BIOS_SQL: String + COUNT_REGRESSIONS_BY_TEXT_SQL: String + + class CountRegressionsByTextRow < ::Struct[untyped] + attr_accessor count_: Integer + def initialize: ( count_: Integer) -> void + def self.new: ( count_: Integer) -> CountRegressionsByTextRow + end + + LIST_REGRESSIONS_BY_IDS_SQL: String + + class ListRegressionsByIdsRow < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor note: String? + def initialize: ( id: Integer, order: String, note: String?) -> void + def self.new: ( id: Integer, order: String, note: String?) -> ListRegressionsByIdsRow + end + + class CopyRegressionsArgs < ::Struct[untyped] + attr_accessor order: String + attr_accessor note: String? + def initialize: ( order: String, note: String?) -> void + def self.new: ( order: String, note: String?) -> CopyRegressionsArgs + end + + TRUNCATE_REGRESSIONS_SQL: String + MANY_PARAMS_SQL: String - class ManyParamsRow < ::Struct + class ManyParamsRow < ::Struct[untyped] attr_accessor c0: Integer attr_accessor c1: String - def self.new: ( Integer c0, String c1) -> ManyParamsRow + def initialize: ( c0: Integer, c1: String) -> void + def self.new: ( c0: Integer, c1: String) -> ManyParamsRow end MANY_ROWS_SQL: String - class ManyRowsRow < ::Struct + class ManyRowsRow < ::Struct[untyped] attr_accessor c0: Integer attr_accessor c1: String attr_accessor c2: Float attr_accessor c3: String attr_accessor c4: Integer + def initialize: ( + c0: Integer, + c1: String, + c2: Float, + c3: String, + c4: Integer + ) -> void def self.new: ( - Integer c0, - String c1, - Float c2, - String c3, - Integer c4 + c0: Integer, + c1: String, + c2: Float, + c3: String, + c4: Integer ) -> ManyRowsRow end FOUR_COLS_SQL: String - class FourColsRow < ::Struct + class FourColsRow < ::Struct[untyped] attr_accessor c0: Integer attr_accessor c1: String attr_accessor c2: Float attr_accessor c3: String - def self.new: ( Integer c0, String c1, Float c2, String c3) -> FourColsRow + def initialize: ( c0: Integer, c1: String, c2: Float, c3: String) -> void + def self.new: ( c0: Integer, c1: String, c2: Float, c3: String) -> FourColsRow end class QuerySql @@ -288,11 +374,21 @@ module Mysql2Codegen def truncate_extended_bios: -> void + def count_regressions_by_text: (term: String) -> CountRegressionsByTextRow? + + def count_regressions_by_text!: (term: String) -> CountRegressionsByTextRow + + def list_regressions_by_ids: (id: Integer, id_2: Integer, id_3: Integer) -> Array[ListRegressionsByIdsRow] + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def truncate_regressions: -> void + def many_params: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow? def many_params!: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow - def many_rows: (c0: Integer) -> Array[manyRowsRow] + def many_rows: (c0: Integer) -> Array[ManyRowsRow] def four_cols: -> Array[FourColsRow] diff --git a/test/golden/mysql2/ruby32_notypes/models.rb b/test/golden/mysql2/ruby32_notypes/models.rb index 3c5c04f..2bce311 100644 --- a/test/golden/mysql2/ruby32_notypes/models.rb +++ b/test/golden/mysql2/ruby32_notypes/models.rb @@ -66,4 +66,7 @@ class Book < Data.define(:id, :name, :author_id, :description) class ExtendedBio < Data.define(:author_name, :name, :bio_type, :author_type) end + + class Regression < Data.define(:id, :order, :note) + end end diff --git a/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb b/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb index 05bd12b..2f677b4 100644 --- a/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb @@ -29,8 +29,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -42,15 +43,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end diff --git a/test/golden/mysql2/ruby32_notypes/other_sql.rb b/test/golden/mysql2/ruby32_notypes/other_sql.rb index f204cec..3d5d7c8 100644 --- a/test/golden/mysql2/ruby32_notypes/other_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/other_sql.rb @@ -42,8 +42,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -55,15 +56,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -91,9 +100,9 @@ def other_one with_client('other_one') do |client| stmt = client.prepare(OTHER_ONE_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first - row && OtherOneRow.new(row['c0']) + row && OtherOneRow.new(row[0]) ensure stmt.close end diff --git a/test/golden/mysql2/ruby32_notypes/query_sql.rb b/test/golden/mysql2/ruby32_notypes/query_sql.rb index a4cfd49..f1d4d24 100644 --- a/test/golden/mysql2/ruby32_notypes/query_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/query_sql.rb @@ -3,7 +3,6 @@ # auto-generated by sqlc - do not edit require 'connection_pool' require 'mysql2' -require 'csv' require 'tempfile' require_relative 'connection' require_relative 'models' @@ -188,6 +187,27 @@ class GetFirstExtendedBioByTypeRow < Data.define(:author_name, :name, :bio_type, DELETE FROM extended_bios SQL + COUNT_REGRESSIONS_BY_TEXT_SQL = <<~'SQL' + SELECT count(*) FROM regressions WHERE `order` = ? OR note = ? + SQL + + class CountRegressionsByTextRow < Data.define(:count) + end + + LIST_REGRESSIONS_BY_IDS_SQL = <<~'SQL' + SELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY id + SQL + + class ListRegressionsByIdsRow < Data.define(:id, :order, :note) + end + + class CopyRegressionsArgs < Data.define(:order, :note) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -235,8 +255,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = !client.in_transaction @@ -248,15 +269,23 @@ def transaction(isolation: nil) else client.query("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.query(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.query(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.query(rollback) raise ensure - client.in_transaction = false if outermost + begin + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.query(Thread.current.status == 'aborting' ? rollback : commit) + end + ensure + client.in_transaction = false if outermost + end end end end @@ -267,9 +296,9 @@ def get_author(id:) query_params = [id] stmt = client.prepare(GET_AUTHOR_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetAuthorRow.new(row['id'], row['name'], row['bio']) + row && GetAuthorRow.new(row[0], row[1], row[2]) ensure stmt.close end @@ -285,9 +314,9 @@ def list_authors with_client('list_authors') do |client| stmt = client.prepare(LIST_AUTHORS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - ListAuthorsRow.new(row['id'], row['name'], row['bio']) + ListAuthorsRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -351,46 +380,46 @@ def test with_client('test') do |client| stmt = client.prepare(TEST_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) row = result.first row && TestRow.new( - row['c_bit'], - row['c_tinyint'], - row['c_bool'], - row['c_boolean'], - row['c_smallint'], - row['c_mediumint'], - row['c_int'], - row['c_integer'], - row['c_bigint'], - row['c_serial'], - row['c_decimal'], - row['c_dec'], - row['c_numeric'], - row['c_fixed'], - row['c_float'], - row['c_double'], - row['c_double_precision'], - row['c_date'], - row['c_time'], - row['c_datetime'], - row['c_timestamp'], - row['c_year'], - row['c_char'], - row['c_nchar'], - row['c_national_char'], - row['c_varchar'], - row['c_binary'], - row['c_varbinary'], - row['c_tinyblob'], - row['c_tinytext'], - row['c_blob'], - row['c_text'], - row['c_mediumblob'], - row['c_mediumtext'], - row['c_longblob'], - row['c_longtext'], - row['c_json'] + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + row[12], + row[13], + row[14], + row[15], + row[16], + row[17], + row[18], + row[19], + row[20], + row[21], + row[22], + row[23], + row[24], + row[25], + row[26], + row[27], + row[28], + row[29], + row[30], + row[31], + row[32], + row[33], + row[34], + row[35], + row[36] ) ensure stmt.close @@ -408,9 +437,9 @@ def list_authors_paged(limit:, offset:) query_params = [limit, offset] stmt = client.prepare(LIST_AUTHORS_PAGED_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - ListAuthorsPagedRow.new(row['id'], row['name'], row['bio']) + ListAuthorsPagedRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -423,9 +452,9 @@ def get_author_by_name_pattern(name_pattern: nil) query_params = [name_pattern] stmt = client.prepare(GET_AUTHOR_BY_NAME_PATTERN_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorByNamePatternRow.new(row['id'], row['name'], row['bio']) + GetAuthorByNamePatternRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -450,12 +479,12 @@ def update_author_bios(bio: nil) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -466,13 +495,13 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - GetAuthorsByIdsAndNamesRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsAndNamesRow.new(row[0], row[1], row[2]) end ensure stmt.close @@ -490,7 +519,7 @@ def copy_authors(rows) file.write(csv_line([row.name, row.bio])) end file.flush - client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE authors FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (name, bio)") + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") end nil end @@ -527,7 +556,10 @@ def list_all_authors_books begin result = stmt.execute(as: :array) result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end ensure stmt.close @@ -582,9 +614,9 @@ def get_first_extended_bio_by_type(bio_type:) query_params = [bio_type] stmt = client.prepare(GET_FIRST_EXTENDED_BIO_BY_TYPE_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && GetFirstExtendedBioByTypeRow.new(row['author_name'], row['name'], row['bio_type'], row['author_type']) + row && GetFirstExtendedBioByTypeRow.new(row[0], row[1], row[2], row[3]) ensure stmt.close end @@ -608,6 +640,67 @@ def truncate_extended_bios end end + def count_regressions_by_text(term:) + with_client('count_regressions_by_text') do |client| + query_params = [term, term] + stmt = client.prepare(COUNT_REGRESSIONS_BY_TEXT_SQL) + begin + result = stmt.execute(*query_params, as: :array) + row = result.first + row && CountRegressionsByTextRow.new(row[0]) + ensure + stmt.close + end + end + end + + # Like #count_regressions_by_text, but raises NotFound when there is no row. + def count_regressions_by_text!(...) + count_regressions_by_text(...) || raise(NotFound, 'CountRegressionsByText returned no row') + end + + def list_regressions_by_ids(id:, id_2:, id_3:) + with_client('list_regressions_by_ids') do |client| + query_params = [id, id_2, id_3] + stmt = client.prepare(LIST_REGRESSIONS_BY_IDS_SQL) + begin + result = stmt.execute(*query_params, as: :array) + result.map do |row| + ListRegressionsByIdsRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + return if rows.empty? + + Tempfile.create(['copy_regressions', '.csv']) do |file| + rows.each do |row| + file.write(csv_line([row.order, row.note])) + end + file.flush + client.query("LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + end + nil + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -619,9 +712,9 @@ def many_params(c0:, c1:, c2:, c3:, c4:) ] stmt = client.prepare(MANY_PARAMS_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) row = result.first - row && ManyParamsRow.new(row['c0'], row['c1']) + row && ManyParamsRow.new(row[0], row[1]) ensure stmt.close end @@ -638,14 +731,14 @@ def many_rows(c0:) query_params = [c0] stmt = client.prepare(MANY_ROWS_SQL) begin - result = stmt.execute(*query_params) + result = stmt.execute(*query_params, as: :array) result.map do |row| - manyRowsRow.new( - row['c0'], - row['c1'], - row['c2'], - row['c3'], - row['c4'] + ManyRowsRow.new( + row[0], + row[1], + row[2], + row[3], + row[4] ) end ensure @@ -658,9 +751,9 @@ def four_cols with_client('four_cols') do |client| stmt = client.prepare(FOUR_COLS_SQL) begin - result = stmt.execute + result = stmt.execute(as: :array) result.map do |row| - FourColsRow.new(row['c0'], row['c1'], row['c2'], row['c3']) + FourColsRow.new(row[0], row[1], row[2], row[3]) end ensure stmt.close @@ -699,10 +792,10 @@ def csv_line(values) when true then '1' when false then '0' when Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') - else value.to_s.gsub('\\') { '\\\\' } + else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""')}") end end - CSV.generate_line(fields) + "#{fields.join(',')}\n" end def slice_placeholders(values) diff --git a/test/golden/pg/default/connection.rb b/test/golden/pg/default/connection.rb index a6a4492..22ecd81 100644 --- a/test/golden/pg/default/connection.rb +++ b/test/golden/pg/default/connection.rb @@ -5,8 +5,14 @@ module PgCodegen # State kept on every PG::Connection the first time it is used. module ConnectionState + # Names of the statements prepared on this connection; forgotten when the server session changes + # (PG::Connection#reset after a failover / terminated backend). def prepared_statements - @prepared_statements ||= Set[] + if @prepared_statements_pid != backend_pid + @prepared_statements = Set[] + @prepared_statements_pid = backend_pid + end + @prepared_statements end end diff --git a/test/golden/pg/default/connection.rbs b/test/golden/pg/default/connection.rbs index 3b51951..af2278b 100644 --- a/test/golden/pg/default/connection.rbs +++ b/test/golden/pg/default/connection.rbs @@ -1,6 +1,13 @@ # auto-generated by sqlc - do not edit module PgCodegen - module ConnectionState + interface _PgConnection + def backend_pid: () -> Integer + end + + module ConnectionState : _PgConnection + @prepared_statements: Set[String] + @prepared_statements_pid: Integer + def prepared_statements: () -> Set[String] end diff --git a/test/golden/pg/default/models.rb b/test/golden/pg/default/models.rb index 6d0eee8..0196340 100644 --- a/test/golden/pg/default/models.rb +++ b/test/golden/pg/default/models.rb @@ -50,6 +50,16 @@ class PostgresExtraType < Data.define( ) end + class Regression < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + class ExtendedBio < Data.define(:author_name, :name, :bio_type) end end diff --git a/test/golden/pg/default/models.rbs b/test/golden/pg/default/models.rbs index b97a45b..a96af6a 100644 --- a/test/golden/pg/default/models.rbs +++ b/test/golden/pg/default/models.rbs @@ -84,6 +84,23 @@ module PgCodegen ) -> void end + class Regression + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + attr_reader bio_types: Array[String]? + attr_reader uids: Array[String]? + def initialize: ( + Integer id, + String order, + String? payload, + untyped doc, + Array[String]? bio_types, + Array[String]? uids + ) -> void + end + class ExtendedBio attr_reader author_name: String attr_reader name: String diff --git a/test/golden/pg/default/my_queries_sql.rb b/test/golden/pg/default/my_queries_sql.rb index 813f87e..28073e2 100644 --- a/test/golden/pg/default/my_queries_sql.rb +++ b/test/golden/pg/default/my_queries_sql.rb @@ -29,8 +29,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -40,13 +41,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -89,10 +96,22 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end end end diff --git a/test/golden/pg/default/my_queries_sql.rbs b/test/golden/pg/default/my_queries_sql.rbs index 4861a8f..d9facc1 100644 --- a/test/golden/pg/default/my_queries_sql.rbs +++ b/test/golden/pg/default/my_queries_sql.rbs @@ -21,5 +21,7 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped end end diff --git a/test/golden/pg/default/other_sql.rb b/test/golden/pg/default/other_sql.rb index 91b8418..a0f1daf 100644 --- a/test/golden/pg/default/other_sql.rb +++ b/test/golden/pg/default/other_sql.rb @@ -42,8 +42,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -53,13 +54,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -71,7 +78,7 @@ def no_cols(c0:, c1:, c2:, c3:, c4:) c1, c2, c3, - c4 + encode_bytea(c4) ] unless client.prepared_statements.include?('other_sql_no_cols') client.prepare('other_sql_no_cols', NO_COLS_SQL) @@ -137,10 +144,27 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end end end diff --git a/test/golden/pg/default/other_sql.rbs b/test/golden/pg/default/other_sql.rbs index e56a652..4275907 100644 --- a/test/golden/pg/default/other_sql.rbs +++ b/test/golden/pg/default/other_sql.rbs @@ -36,5 +36,9 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped + + def encode_bytea: (String? value) -> String? end end diff --git a/test/golden/pg/default/query_sql.rb b/test/golden/pg/default/query_sql.rb index 3221290..92bf313 100644 --- a/test/golden/pg/default/query_sql.rb +++ b/test/golden/pg/default/query_sql.rb @@ -203,6 +203,48 @@ class GetExtraTypesRow < Data.define( TRUNCATE TABLE postgres_extra_types SQL + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids + SQL + + class CreateRegressionRow < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + + CREATE_REGRESSION_IF_NEW_SQL = <<~'SQL' + INSERT INTO regressions (id, "order") VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id + SQL + + class CreateRegressionIfNewRow < Data.define(:id) + end + + class CopyRegressionsArgs < Data.define(:id, :order, :payload, :doc) + end + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", payload, doc, bio_types, uids FROM regressions WHERE ($1::text IS NULL OR "order" = $1) ORDER BY id + SQL + + class ListRegressionsRow < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -250,8 +292,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -261,13 +304,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -406,7 +455,8 @@ def create_author_return_id(name:, bio:) client.prepared_statements.add('query_sql_create_author_return_id') end result = client.exec_prepared('query_sql_create_author_return_id', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -455,7 +505,7 @@ def get_authors_by_ids_and_names(param1:, param2:) def copy_authors(rows) with_client('copy_authors') do |client| encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) - client.copy_data('COPY authors (name, bio) FROM STDIN', encoder) do + client.copy_data('COPY "authors" ("name", "bio") FROM STDIN', encoder) do rows.each do |row| client.put_copy_data([row.name, row.bio]) end @@ -483,7 +533,8 @@ def create_book(name:, author_id:) client.prepared_statements.add('query_sql_create_book') end result = client.exec_prepared('query_sql_create_book', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -495,7 +546,10 @@ def list_all_authors_books end result = client.exec_prepared('query_sql_list_all_authors_books').values result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end end end @@ -572,7 +626,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim with_client('insert_extra_types') do |client| query_params = [ c_uuid, - c_jsonb, + encode_json(c_jsonb), c_text_array, c_integer_array, c_timestamptz, @@ -626,6 +680,95 @@ def truncate_extra_types end end + def create_regression(id:, order:, payload:, doc:, bio_types:, uids:) + with_client('create_regression') do |client| + query_params = [ + id, + order, + encode_bytea(payload), + encode_json(doc), + bio_types, + uids + ] + unless client.prepared_statements.include?('query_sql_create_regression') + client.prepare('query_sql_create_regression', CREATE_REGRESSION_SQL) + client.prepared_statements.add('query_sql_create_regression') + end + result = client.exec_prepared('query_sql_create_regression', query_params) + row = result.first + row && CreateRegressionRow.new( + row['id'], + row['order'], + row['payload'], + row['doc'], + row['bio_types'], + row['uids'] + ) + end + end + + # Like #create_regression, but raises NotFound when there is no row. + def create_regression!(...) + create_regression(...) || raise(NotFound, 'CreateRegression returned no row') + end + + def create_regression_if_new(id:, order:) + with_client('create_regression_if_new') do |client| + query_params = [id, order] + unless client.prepared_statements.include?('query_sql_create_regression_if_new') + client.prepare('query_sql_create_regression_if_new', CREATE_REGRESSION_IF_NEW_SQL) + client.prepared_statements.add('query_sql_create_regression_if_new') + end + result = client.exec_prepared('query_sql_create_regression_if_new', query_params) + row = result.first + row && row['id'] + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) + client.copy_data('COPY "regressions" ("id", "order", "payload", "doc") FROM STDIN', encoder) do + rows.each do |row| + client.put_copy_data([row.id, row.order, encode_bytea(row.payload), encode_json(row.doc)]) + end + end + nil + end + end + + def list_regressions(order_name: nil) + with_client('list_regressions') do |client| + query_params = [order_name] + unless client.prepared_statements.include?('query_sql_list_regressions') + client.prepare('query_sql_list_regressions', LIST_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_list_regressions') + end + result = client.exec_prepared('query_sql_list_regressions', query_params) + result.map do |row| + ListRegressionsRow.new( + row['id'], + row['order'], + row['payload'], + row['doc'], + row['bio_types'], + row['uids'] + ) + end + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + unless client.prepared_statements.include?('query_sql_truncate_regressions') + client.prepare('query_sql_truncate_regressions', TRUNCATE_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_truncate_regressions') + end + client.exec_prepared('query_sql_truncate_regressions') + nil + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -633,7 +776,7 @@ def many_params(c0:, c1:, c2:, c3:, c4:) c1, c2, c3, - c4 + encode_bytea(c4) ] unless client.prepared_statements.include?('query_sql_many_params') client.prepare('query_sql_many_params', MANY_PARAMS_SQL) @@ -659,7 +802,7 @@ def many_rows(c0:) end result = client.exec_prepared('query_sql_many_rows', query_params) result.map do |row| - manyRowsRow.new( + ManyRowsRow.new( row['c0'], row['c1'], row['c2'], @@ -709,10 +852,32 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end + + # json / jsonb parameters: an Array is a JSON array, not a PG array (Hashes and JSON text pass through). + def encode_json(value) + value.is_a?(Array) ? PG::TextEncoder::JSON.new.encode(value) : value + end end end diff --git a/test/golden/pg/default/query_sql.rbs b/test/golden/pg/default/query_sql.rbs index ca94251..f89aa56 100644 --- a/test/golden/pg/default/query_sql.rbs +++ b/test/golden/pg/default/query_sql.rbs @@ -198,6 +198,61 @@ module PgCodegen TRUNCATE_EXTRA_TYPES_SQL: String + CREATE_REGRESSION_SQL: String + + class CreateRegressionRow + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + attr_reader bio_types: Array[String]? + attr_reader uids: Array[String]? + def initialize: ( + Integer id, + String order, + String? payload, + untyped doc, + Array[String]? bio_types, + Array[String]? uids + ) -> void + end + + CREATE_REGRESSION_IF_NEW_SQL: String + + class CreateRegressionIfNewRow + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + class CopyRegressionsArgs + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + def initialize: ( Integer id, String order, String? payload, untyped doc) -> void + end + + LIST_REGRESSIONS_SQL: String + + class ListRegressionsRow + attr_reader id: Integer + attr_reader order: String + attr_reader payload: String? + attr_reader doc: untyped + attr_reader bio_types: Array[String]? + attr_reader uids: Array[String]? + def initialize: ( + Integer id, + String order, + String? payload, + untyped doc, + Array[String]? bio_types, + Array[String]? uids + ) -> void + end + + TRUNCATE_REGRESSIONS_SQL: String + MANY_PARAMS_SQL: String class ManyParamsRow @@ -299,11 +354,23 @@ module PgCodegen def truncate_extra_types: -> void + def create_regression: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow? + + def create_regression!: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow + + def create_regression_if_new: (id: Integer, order: String) -> Integer + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def list_regressions: (?order_name: String?) -> Array[ListRegressionsRow] + + def truncate_regressions: -> void + def many_params: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow? def many_params!: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow - def many_rows: (c0: bool) -> Array[manyRowsRow] + def many_rows: (c0: bool) -> Array[ManyRowsRow] def four_cols: -> Array[FourColsRow] @@ -314,5 +381,11 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped + + def encode_bytea: (String? value) -> String? + + def encode_json: (untyped value) -> untyped end end diff --git a/test/golden/pg/ruby31_gemfile/Gemfile b/test/golden/pg/ruby31_gemfile/Gemfile index 6b58541..10a2344 100644 --- a/test/golden/pg/ruby31_gemfile/Gemfile +++ b/test/golden/pg/ruby31_gemfile/Gemfile @@ -1,4 +1,6 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gem 'connection_pool' -gem 'pg' \ No newline at end of file +gem 'pg' diff --git a/test/golden/pg/ruby31_gemfile/connection.rb b/test/golden/pg/ruby31_gemfile/connection.rb index a6a4492..22ecd81 100644 --- a/test/golden/pg/ruby31_gemfile/connection.rb +++ b/test/golden/pg/ruby31_gemfile/connection.rb @@ -5,8 +5,14 @@ module PgCodegen # State kept on every PG::Connection the first time it is used. module ConnectionState + # Names of the statements prepared on this connection; forgotten when the server session changes + # (PG::Connection#reset after a failover / terminated backend). def prepared_statements - @prepared_statements ||= Set[] + if @prepared_statements_pid != backend_pid + @prepared_statements = Set[] + @prepared_statements_pid = backend_pid + end + @prepared_statements end end diff --git a/test/golden/pg/ruby31_gemfile/connection.rbs b/test/golden/pg/ruby31_gemfile/connection.rbs index 3b51951..af2278b 100644 --- a/test/golden/pg/ruby31_gemfile/connection.rbs +++ b/test/golden/pg/ruby31_gemfile/connection.rbs @@ -1,6 +1,13 @@ # auto-generated by sqlc - do not edit module PgCodegen - module ConnectionState + interface _PgConnection + def backend_pid: () -> Integer + end + + module ConnectionState : _PgConnection + @prepared_statements: Set[String] + @prepared_statements_pid: Integer + def prepared_statements: () -> Set[String] end diff --git a/test/golden/pg/ruby31_gemfile/models.rb b/test/golden/pg/ruby31_gemfile/models.rb index e3d3438..0e1bd5e 100644 --- a/test/golden/pg/ruby31_gemfile/models.rb +++ b/test/golden/pg/ruby31_gemfile/models.rb @@ -10,10 +10,9 @@ module ExtendedBioType ALL = [AUTOBIOGRAPHY, BIOGRAPHY, MEMOIR].freeze end - class Author < Struct.new(:id, :name, :bio) - end + Author = Struct.new(:id, :name, :bio, keyword_init: true) - class NodePostgresType < Struct.new( + NodePostgresType = Struct.new( :c_bit, :c_smallint, :c_boolean, @@ -31,14 +30,19 @@ class NodePostgresType < Struct.new( :c_varchar, :c_bytea, :c_text, - :c_json + :c_json, + keyword_init: true ) - end - class Book < Struct.new(:id, :name, :author_id, :description) - end + Book = Struct.new( + :id, + :name, + :author_id, + :description, + keyword_init: true + ) - class PostgresExtraType < Struct.new( + PostgresExtraType = Struct.new( :c_uuid, :c_jsonb, :c_text_array, @@ -46,10 +50,19 @@ class PostgresExtraType < Struct.new( :c_timestamptz, :c_inet, :c_money, - :c_not_null + :c_not_null, + keyword_init: true ) - end - class ExtendedBio < Struct.new(:author_name, :name, :bio_type) - end + Regression = Struct.new( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids, + keyword_init: true + ) + + ExtendedBio = Struct.new(:author_name, :name, :bio_type, keyword_init: true) end diff --git a/test/golden/pg/ruby31_gemfile/models.rbs b/test/golden/pg/ruby31_gemfile/models.rbs index 9d9bf3c..620fec3 100644 --- a/test/golden/pg/ruby31_gemfile/models.rbs +++ b/test/golden/pg/ruby31_gemfile/models.rbs @@ -7,14 +7,15 @@ module PgCodegen ALL: Array[String] end - class Author < ::Struct + class Author < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> Author + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> Author end - class NodePostgresType < ::Struct + class NodePostgresType < ::Struct[untyped] attr_accessor c_bit: String? attr_accessor c_smallint: Integer? attr_accessor c_boolean: bool? @@ -33,37 +34,58 @@ module PgCodegen attr_accessor c_bytea: String? attr_accessor c_text: String? attr_accessor c_json: untyped + def initialize: ( + c_bit: String?, + c_smallint: Integer?, + c_boolean: bool?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer?, + c_decimal: BigDecimal?, + c_numeric: BigDecimal?, + c_real: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: String?, + c_timestamp: Time?, + c_char: String?, + c_varchar: String?, + c_bytea: String?, + c_text: String?, + c_json: untyped + ) -> void def self.new: ( - String? c_bit, - Integer? c_smallint, - bool? c_boolean, - Integer? c_integer, - Integer? c_bigint, - Integer? c_serial, - BigDecimal? c_decimal, - BigDecimal? c_numeric, - Float? c_real, - Float? c_double_precision, - Date? c_date, - String? c_time, - Time? c_timestamp, - String? c_char, - String? c_varchar, - String? c_bytea, - String? c_text, - untyped c_json + c_bit: String?, + c_smallint: Integer?, + c_boolean: bool?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer?, + c_decimal: BigDecimal?, + c_numeric: BigDecimal?, + c_real: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: String?, + c_timestamp: Time?, + c_char: String?, + c_varchar: String?, + c_bytea: String?, + c_text: String?, + c_json: untyped ) -> NodePostgresType end - class Book < ::Struct + class Book < ::Struct[untyped] attr_accessor id: String attr_accessor name: String attr_accessor author_id: Integer attr_accessor description: String? - def self.new: ( String id, String name, Integer author_id, String? description) -> Book + def initialize: ( id: String, name: String, author_id: Integer, description: String?) -> void + def self.new: ( id: String, name: String, author_id: Integer, description: String?) -> Book end - class PostgresExtraType < ::Struct + class PostgresExtraType < ::Struct[untyped] attr_accessor c_uuid: String? attr_accessor c_jsonb: untyped attr_accessor c_text_array: Array[String]? @@ -72,22 +94,58 @@ module PgCodegen attr_accessor c_inet: IPAddr? attr_accessor c_money: String? attr_accessor c_not_null: String + def initialize: ( + c_uuid: String?, + c_jsonb: untyped, + c_text_array: Array[String]?, + c_integer_array: Array[Integer]?, + c_timestamptz: Time?, + c_inet: IPAddr?, + c_money: String?, + c_not_null: String + ) -> void def self.new: ( - String? c_uuid, - untyped c_jsonb, - Array[String]? c_text_array, - Array[Integer]? c_integer_array, - Time? c_timestamptz, - IPAddr? c_inet, - String? c_money, - String c_not_null + c_uuid: String?, + c_jsonb: untyped, + c_text_array: Array[String]?, + c_integer_array: Array[Integer]?, + c_timestamptz: Time?, + c_inet: IPAddr?, + c_money: String?, + c_not_null: String ) -> PostgresExtraType end - class ExtendedBio < ::Struct + class Regression < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor payload: String? + attr_accessor doc: untyped + attr_accessor bio_types: Array[String]? + attr_accessor uids: Array[String]? + def initialize: ( + id: Integer, + order: String, + payload: String?, + doc: untyped, + bio_types: Array[String]?, + uids: Array[String]? + ) -> void + def self.new: ( + id: Integer, + order: String, + payload: String?, + doc: untyped, + bio_types: Array[String]?, + uids: Array[String]? + ) -> Regression + end + + class ExtendedBio < ::Struct[untyped] attr_accessor author_name: String attr_accessor name: String attr_accessor bio_type: String? - def self.new: ( String author_name, String name, String? bio_type) -> ExtendedBio + def initialize: ( author_name: String, name: String, bio_type: String?) -> void + def self.new: ( author_name: String, name: String, bio_type: String?) -> ExtendedBio end end diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb index 1982f7c..a5cce42 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb @@ -30,8 +30,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -41,13 +42,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -90,10 +97,22 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end end end diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs b/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs index 4861a8f..d9facc1 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs @@ -21,5 +21,7 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped end end diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rb b/test/golden/pg/ruby31_gemfile/other_sql.rb index fc9b664..681af89 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rb +++ b/test/golden/pg/ruby31_gemfile/other_sql.rb @@ -18,8 +18,7 @@ module PgCodegen WHERE a = ? SQL - class OtherOneRow < Struct.new(:c0) - end + OtherOneRow = Struct.new(:c0, keyword_init: true) COUNT_ROWS_SQL = <<~'SQL' SELECT 1 -- ? ? @@ -43,8 +42,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -54,13 +54,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -72,7 +78,7 @@ def no_cols(c0:, c1:, c2:, c3:, c4:) c1, c2, c3, - c4 + encode_bytea(c4) ] unless client.prepared_statements.include?('other_sql_no_cols') client.prepare('other_sql_no_cols', NO_COLS_SQL) @@ -91,7 +97,7 @@ def other_one end result = client.exec_prepared('other_sql_other_one') row = result.first - row && OtherOneRow.new(row['c0']) + row && OtherOneRow.new(c0: row['c0']) end end @@ -138,10 +144,27 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end end end diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rbs b/test/golden/pg/ruby31_gemfile/other_sql.rbs index ea6efd9..c4212d8 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/other_sql.rbs @@ -4,9 +4,10 @@ module PgCodegen OTHER_ONE_SQL: String - class OtherOneRow < ::Struct + class OtherOneRow < ::Struct[untyped] attr_accessor c0: bool - def self.new: ( bool c0) -> OtherOneRow + def initialize: ( c0: bool) -> void + def self.new: ( c0: bool) -> OtherOneRow end COUNT_ROWS_SQL: String @@ -36,5 +37,9 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped + + def encode_bytea: (String? value) -> String? end end diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index 1f47341..fbe0df9 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -13,16 +13,14 @@ module PgCodegen WHERE id = $1 LIMIT 1 SQL - class GetAuthorRow < Struct.new(:id, :name, :bio) - end + GetAuthorRow = Struct.new(:id, :name, :bio, keyword_init: true) LIST_AUTHORS_SQL = <<~'SQL' SELECT id, name, bio FROM authors ORDER BY name SQL - class ListAuthorsRow < Struct.new(:id, :name, :bio) - end + ListAuthorsRow = Struct.new(:id, :name, :bio, keyword_init: true) CREATE_AUTHOR_SQL = <<~'SQL' INSERT INTO authors ( @@ -33,8 +31,7 @@ class ListAuthorsRow < Struct.new(:id, :name, :bio) RETURNING id, name, bio SQL - class CreateAuthorRow < Struct.new(:id, :name, :bio) - end + CreateAuthorRow = Struct.new(:id, :name, :bio, keyword_init: true) DELETE_AUTHOR_SQL = <<~'SQL' DELETE FROM authors @@ -46,7 +43,7 @@ class CreateAuthorRow < Struct.new(:id, :name, :bio) LIMIT 1 SQL - class TestRow < Struct.new( + TestRow = Struct.new( :c_bit, :c_smallint, :c_boolean, @@ -64,9 +61,9 @@ class TestRow < Struct.new( :c_varchar, :c_bytea, :c_text, - :c_json + :c_json, + keyword_init: true ) - end LIST_AUTHORS_PAGED_SQL = <<~'SQL' SELECT id, name, bio FROM authors @@ -75,23 +72,20 @@ class TestRow < Struct.new( OFFSET $1 SQL - class ListAuthorsPagedRow < Struct.new(:id, :name, :bio) - end + ListAuthorsPagedRow = Struct.new(:id, :name, :bio, keyword_init: true) GET_AUTHOR_BY_NAME_PATTERN_SQL = <<~'SQL' SELECT id, name, bio FROM authors WHERE name LIKE COALESCE($1, '%') SQL - class GetAuthorByNamePatternRow < Struct.new(:id, :name, :bio) - end + GetAuthorByNamePatternRow = Struct.new(:id, :name, :bio, keyword_init: true) CREATE_AUTHOR_RETURN_ID_SQL = <<~'SQL' INSERT INTO authors (name, bio) VALUES ($1, $2) RETURNING id SQL - class CreateAuthorReturnIdRow < Struct.new(:id) - end + CreateAuthorReturnIdRow = Struct.new(:id, keyword_init: true) UPDATE_AUTHOR_BIOS_SQL = <<~'SQL' UPDATE authors @@ -104,19 +98,16 @@ class CreateAuthorReturnIdRow < Struct.new(:id) WHERE id = ANY($1::BIGINT []) SQL - class GetAuthorsByIdsRow < Struct.new(:id, :name, :bio) - end + GetAuthorsByIdsRow = Struct.new(:id, :name, :bio, keyword_init: true) GET_AUTHORS_BY_IDS_AND_NAMES_SQL = <<~'SQL' SELECT id, name, bio FROM authors WHERE id = ANY($1::BIGINT []) AND name = ANY($2::TEXT []) SQL - class GetAuthorsByIdsAndNamesRow < Struct.new(:id, :name, :bio) - end + GetAuthorsByIdsAndNamesRow = Struct.new(:id, :name, :bio, keyword_init: true) - class CopyAuthorsArgs < Struct.new(:name, :bio) - end + CopyAuthorsArgs = Struct.new(:name, :bio, keyword_init: true) TRUNCATE_AUTHORS_SQL = <<~'SQL' TRUNCATE TABLE authors CASCADE @@ -126,8 +117,7 @@ class CopyAuthorsArgs < Struct.new(:name, :bio) INSERT INTO books (name, author_id) VALUES ($1, $2) RETURNING id SQL - class CreateBookRow < Struct.new(:id) - end + CreateBookRow = Struct.new(:id, keyword_init: true) LIST_ALL_AUTHORS_BOOKS_SQL = <<~'SQL' SELECT @@ -138,8 +128,7 @@ class CreateBookRow < Struct.new(:id) ORDER BY authors.name SQL - class ListAllAuthorsBooksRow < Struct.new(:author, :book) - end + ListAllAuthorsBooksRow = Struct.new(:author, :book, keyword_init: true) GET_DUPLICATE_AUTHORS_SQL = <<~'SQL' SELECT @@ -150,8 +139,7 @@ class ListAllAuthorsBooksRow < Struct.new(:author, :book) WHERE authors1.id < authors2.id SQL - class GetDuplicateAuthorsRow < Struct.new(:author, :author2) - end + GetDuplicateAuthorsRow = Struct.new(:author, :author2, keyword_init: true) GET_AUTHORS_BY_BOOK_NAME_SQL = <<~'SQL' SELECT @@ -161,8 +149,13 @@ class GetDuplicateAuthorsRow < Struct.new(:author, :author2) WHERE books.name = $1 SQL - class GetAuthorsByBookNameRow < Struct.new(:id, :name, :bio, :book) - end + GetAuthorsByBookNameRow = Struct.new( + :id, + :name, + :bio, + :book, + keyword_init: true + ) CREATE_EXTENDED_BIO_SQL = <<~'SQL' INSERT INTO extended.bios (author_name, name, bio_type) VALUES ($1, $2, $3) @@ -172,8 +165,7 @@ class GetAuthorsByBookNameRow < Struct.new(:id, :name, :bio, :book) SELECT author_name, name, bio_type FROM extended.bios WHERE bio_type = $1 LIMIT 1 SQL - class GetFirstExtendedBioByTypeRow < Struct.new(:author_name, :name, :bio_type) - end + GetFirstExtendedBioByTypeRow = Struct.new(:author_name, :name, :bio_type, keyword_init: true) TRUNCATE_EXTENDED_BIOS_SQL = <<~'SQL' TRUNCATE TABLE extended.bios @@ -188,7 +180,7 @@ class GetFirstExtendedBioByTypeRow < Struct.new(:author_name, :name, :bio_type) SELECT c_uuid, c_jsonb, c_text_array, c_integer_array, c_timestamptz, c_inet, c_money, c_not_null FROM postgres_extra_types LIMIT 1 SQL - class GetExtraTypesRow < Struct.new( + GetExtraTypesRow = Struct.new( :c_uuid, :c_jsonb, :c_text_array, @@ -196,43 +188,93 @@ class GetExtraTypesRow < Struct.new( :c_timestamptz, :c_inet, :c_money, - :c_not_null + :c_not_null, + keyword_init: true ) - end TRUNCATE_EXTRA_TYPES_SQL = <<~'SQL' TRUNCATE TABLE postgres_extra_types SQL + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids + SQL + + CreateRegressionRow = Struct.new( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids, + keyword_init: true + ) + + CREATE_REGRESSION_IF_NEW_SQL = <<~'SQL' + INSERT INTO regressions (id, "order") VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id + SQL + + CreateRegressionIfNewRow = Struct.new(:id, keyword_init: true) + + CopyRegressionsArgs = Struct.new( + :id, + :order, + :payload, + :doc, + keyword_init: true + ) + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", payload, doc, bio_types, uids FROM regressions WHERE ($1::text IS NULL OR "order" = $1) ORDER BY id + SQL + + ListRegressionsRow = Struct.new( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids, + keyword_init: true + ) + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class ManyParamsRow < Struct.new(:c0, :c1) - end + ManyParamsRow = Struct.new(:c0, :c1, keyword_init: true) MANY_ROWS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class ManyRowsRow < Struct.new( + ManyRowsRow = Struct.new( :c0, :c1, :c2, :c3, - :c4 + :c4, + keyword_init: true ) - end FOUR_COLS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class FourColsRow < Struct.new(:c0, :c1, :c2, :c3) - end + FourColsRow = Struct.new( + :c0, + :c1, + :c2, + :c3, + keyword_init: true + ) class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. @@ -251,8 +293,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -262,13 +305,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -283,7 +332,7 @@ def get_author(id:) end result = client.exec_prepared('query_sql_get_author', query_params) row = result.first - row && GetAuthorRow.new(row['id'], row['name'], row['bio']) + row && GetAuthorRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end @@ -300,7 +349,7 @@ def list_authors end result = client.exec_prepared('query_sql_list_authors') result.map do |row| - ListAuthorsRow.new(row['id'], row['name'], row['bio']) + ListAuthorsRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end end @@ -314,7 +363,7 @@ def create_author(name:, bio:) end result = client.exec_prepared('query_sql_create_author', query_params) row = result.first - row && CreateAuthorRow.new(row['id'], row['name'], row['bio']) + row && CreateAuthorRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end @@ -344,24 +393,24 @@ def test result = client.exec_prepared('query_sql_test') row = result.first row && TestRow.new( - row['c_bit'], - row['c_smallint'], - row['c_boolean'], - row['c_integer'], - row['c_bigint'], - row['c_serial'], - row['c_decimal'], - row['c_numeric'], - row['c_real'], - row['c_double_precision'], - row['c_date'], - row['c_time'], - row['c_timestamp'], - row['c_char'], - row['c_varchar'], - row['c_bytea'], - row['c_text'], - row['c_json'] + c_bit: row['c_bit'], + c_smallint: row['c_smallint'], + c_boolean: row['c_boolean'], + c_integer: row['c_integer'], + c_bigint: row['c_bigint'], + c_serial: row['c_serial'], + c_decimal: row['c_decimal'], + c_numeric: row['c_numeric'], + c_real: row['c_real'], + c_double_precision: row['c_double_precision'], + c_date: row['c_date'], + c_time: row['c_time'], + c_timestamp: row['c_timestamp'], + c_char: row['c_char'], + c_varchar: row['c_varchar'], + c_bytea: row['c_bytea'], + c_text: row['c_text'], + c_json: row['c_json'] ) end end @@ -380,7 +429,7 @@ def list_authors_paged(offset:, limit:) end result = client.exec_prepared('query_sql_list_authors_paged', query_params) result.map do |row| - ListAuthorsPagedRow.new(row['id'], row['name'], row['bio']) + ListAuthorsPagedRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end end @@ -394,7 +443,7 @@ def get_author_by_name_pattern(name_pattern: nil) end result = client.exec_prepared('query_sql_get_author_by_name_pattern', query_params) result.map do |row| - GetAuthorByNamePatternRow.new(row['id'], row['name'], row['bio']) + GetAuthorByNamePatternRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end end @@ -407,7 +456,8 @@ def create_author_return_id(name:, bio:) client.prepared_statements.add('query_sql_create_author_return_id') end result = client.exec_prepared('query_sql_create_author_return_id', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -433,7 +483,7 @@ def get_authors_by_ids(param1:) end result = client.exec_prepared('query_sql_get_authors_by_ids', query_params) result.map do |row| - GetAuthorsByIdsRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end end @@ -447,7 +497,7 @@ def get_authors_by_ids_and_names(param1:, param2:) end result = client.exec_prepared('query_sql_get_authors_by_ids_and_names', query_params) result.map do |row| - GetAuthorsByIdsAndNamesRow.new(row['id'], row['name'], row['bio']) + GetAuthorsByIdsAndNamesRow.new(id: row['id'], name: row['name'], bio: row['bio']) end end end @@ -456,7 +506,7 @@ def get_authors_by_ids_and_names(param1:, param2:) def copy_authors(rows) with_client('copy_authors') do |client| encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) - client.copy_data('COPY authors (name, bio) FROM STDIN', encoder) do + client.copy_data('COPY "authors" ("name", "bio") FROM STDIN', encoder) do rows.each do |row| client.put_copy_data([row.name, row.bio]) end @@ -484,7 +534,8 @@ def create_book(name:, author_id:) client.prepared_statements.add('query_sql_create_book') end result = client.exec_prepared('query_sql_create_book', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -496,7 +547,10 @@ def list_all_authors_books end result = client.exec_prepared('query_sql_list_all_authors_books').values result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + author: Author.new(id: row[0], name: row[1], bio: row[2]), + book: Book.new(id: row[3], name: row[4], author_id: row[5], description: row[6]) + ) end end end @@ -509,7 +563,10 @@ def get_duplicate_authors end result = client.exec_prepared('query_sql_get_duplicate_authors').values result.map do |row| - GetDuplicateAuthorsRow.new(Author.new(row[0], row[1], row[2]), Author.new(row[3], row[4], row[5])) + GetDuplicateAuthorsRow.new( + author: Author.new(id: row[0], name: row[1], bio: row[2]), + author2: Author.new(id: row[3], name: row[4], bio: row[5]) + ) end end end @@ -523,7 +580,12 @@ def get_authors_by_book_name(name:) end result = client.exec_prepared('query_sql_get_authors_by_book_name', query_params).values result.map do |row| - GetAuthorsByBookNameRow.new(row[0], row[1], row[2], Book.new(row[3], row[4], row[5], row[6])) + GetAuthorsByBookNameRow.new( + id: row[0], + name: row[1], + bio: row[2], + book: Book.new(id: row[3], name: row[4], author_id: row[5], description: row[6]) + ) end end end @@ -549,7 +611,11 @@ def get_first_extended_bio_by_type(bio_type:) end result = client.exec_prepared('query_sql_get_first_extended_bio_by_type', query_params) row = result.first - row && GetFirstExtendedBioByTypeRow.new(row['author_name'], row['name'], row['bio_type']) + row && GetFirstExtendedBioByTypeRow.new( + author_name: row['author_name'], + name: row['name'], + bio_type: row['bio_type'] + ) end end @@ -573,7 +639,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim with_client('insert_extra_types') do |client| query_params = [ c_uuid, - c_jsonb, + encode_json(c_jsonb), c_text_array, c_integer_array, c_timestamptz, @@ -599,14 +665,14 @@ def get_extra_types result = client.exec_prepared('query_sql_get_extra_types') row = result.first row && GetExtraTypesRow.new( - row['c_uuid'], - row['c_jsonb'], - row['c_text_array'], - row['c_integer_array'], - row['c_timestamptz'], - row['c_inet'], - row['c_money'], - row['c_not_null'] + c_uuid: row['c_uuid'], + c_jsonb: row['c_jsonb'], + c_text_array: row['c_text_array'], + c_integer_array: row['c_integer_array'], + c_timestamptz: row['c_timestamptz'], + c_inet: row['c_inet'], + c_money: row['c_money'], + c_not_null: row['c_not_null'] ) end end @@ -627,6 +693,95 @@ def truncate_extra_types end end + def create_regression(id:, order:, payload:, doc:, bio_types:, uids:) + with_client('create_regression') do |client| + query_params = [ + id, + order, + encode_bytea(payload), + encode_json(doc), + bio_types, + uids + ] + unless client.prepared_statements.include?('query_sql_create_regression') + client.prepare('query_sql_create_regression', CREATE_REGRESSION_SQL) + client.prepared_statements.add('query_sql_create_regression') + end + result = client.exec_prepared('query_sql_create_regression', query_params) + row = result.first + row && CreateRegressionRow.new( + id: row['id'], + order: row['order'], + payload: row['payload'], + doc: row['doc'], + bio_types: row['bio_types'], + uids: row['uids'] + ) + end + end + + # Like #create_regression, but raises NotFound when there is no row. + def create_regression!(...) + create_regression(...) || raise(NotFound, 'CreateRegression returned no row') + end + + def create_regression_if_new(id:, order:) + with_client('create_regression_if_new') do |client| + query_params = [id, order] + unless client.prepared_statements.include?('query_sql_create_regression_if_new') + client.prepare('query_sql_create_regression_if_new', CREATE_REGRESSION_IF_NEW_SQL) + client.prepared_statements.add('query_sql_create_regression_if_new') + end + result = client.exec_prepared('query_sql_create_regression_if_new', query_params) + row = result.first + row && row['id'] + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) + client.copy_data('COPY "regressions" ("id", "order", "payload", "doc") FROM STDIN', encoder) do + rows.each do |row| + client.put_copy_data([row.id, row.order, encode_bytea(row.payload), encode_json(row.doc)]) + end + end + nil + end + end + + def list_regressions(order_name: nil) + with_client('list_regressions') do |client| + query_params = [order_name] + unless client.prepared_statements.include?('query_sql_list_regressions') + client.prepare('query_sql_list_regressions', LIST_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_list_regressions') + end + result = client.exec_prepared('query_sql_list_regressions', query_params) + result.map do |row| + ListRegressionsRow.new( + id: row['id'], + order: row['order'], + payload: row['payload'], + doc: row['doc'], + bio_types: row['bio_types'], + uids: row['uids'] + ) + end + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + unless client.prepared_statements.include?('query_sql_truncate_regressions') + client.prepare('query_sql_truncate_regressions', TRUNCATE_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_truncate_regressions') + end + client.exec_prepared('query_sql_truncate_regressions') + nil + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -634,7 +789,7 @@ def many_params(c0:, c1:, c2:, c3:, c4:) c1, c2, c3, - c4 + encode_bytea(c4) ] unless client.prepared_statements.include?('query_sql_many_params') client.prepare('query_sql_many_params', MANY_PARAMS_SQL) @@ -642,7 +797,7 @@ def many_params(c0:, c1:, c2:, c3:, c4:) end result = client.exec_prepared('query_sql_many_params', query_params) row = result.first - row && ManyParamsRow.new(row['c0'], row['c1']) + row && ManyParamsRow.new(c0: row['c0'], c1: row['c1']) end end @@ -660,12 +815,12 @@ def many_rows(c0:) end result = client.exec_prepared('query_sql_many_rows', query_params) result.map do |row| - manyRowsRow.new( - row['c0'], - row['c1'], - row['c2'], - row['c3'], - row['c4'] + ManyRowsRow.new( + c0: row['c0'], + c1: row['c1'], + c2: row['c2'], + c3: row['c3'], + c4: row['c4'] ) end end @@ -679,7 +834,7 @@ def four_cols end result = client.exec_prepared('query_sql_four_cols') result.map do |row| - FourColsRow.new(row['c0'], row['c1'], row['c2'], row['c3']) + FourColsRow.new(c0: row['c0'], c1: row['c1'], c2: row['c2'], c3: row['c3']) end end end @@ -710,10 +865,32 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end + + # json / jsonb parameters: an Array is a JSON array, not a PG array (Hashes and JSON text pass through). + def encode_json(value) + value.is_a?(Array) ? PG::TextEncoder::JSON.new.encode(value) : value + end end end diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rbs b/test/golden/pg/ruby31_gemfile/query_sql.rbs index 58f9f9d..5bd6b4f 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/query_sql.rbs @@ -2,36 +2,39 @@ module PgCodegen GET_AUTHOR_SQL: String - class GetAuthorRow < ::Struct + class GetAuthorRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorRow end LIST_AUTHORS_SQL: String - class ListAuthorsRow < ::Struct + class ListAuthorsRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> ListAuthorsRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsRow end CREATE_AUTHOR_SQL: String - class CreateAuthorRow < ::Struct + class CreateAuthorRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> CreateAuthorRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> CreateAuthorRow end DELETE_AUTHOR_SQL: String TEST_SQL: String - class TestRow < ::Struct + class TestRow < ::Struct[untyped] attr_accessor c_bit: String? attr_accessor c_smallint: Integer? attr_accessor c_boolean: bool? @@ -50,123 +53,154 @@ module PgCodegen attr_accessor c_bytea: String? attr_accessor c_text: String? attr_accessor c_json: untyped + def initialize: ( + c_bit: String?, + c_smallint: Integer?, + c_boolean: bool?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer?, + c_decimal: BigDecimal?, + c_numeric: BigDecimal?, + c_real: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: String?, + c_timestamp: Time?, + c_char: String?, + c_varchar: String?, + c_bytea: String?, + c_text: String?, + c_json: untyped + ) -> void def self.new: ( - String? c_bit, - Integer? c_smallint, - bool? c_boolean, - Integer? c_integer, - Integer? c_bigint, - Integer? c_serial, - BigDecimal? c_decimal, - BigDecimal? c_numeric, - Float? c_real, - Float? c_double_precision, - Date? c_date, - String? c_time, - Time? c_timestamp, - String? c_char, - String? c_varchar, - String? c_bytea, - String? c_text, - untyped c_json + c_bit: String?, + c_smallint: Integer?, + c_boolean: bool?, + c_integer: Integer?, + c_bigint: Integer?, + c_serial: Integer?, + c_decimal: BigDecimal?, + c_numeric: BigDecimal?, + c_real: Float?, + c_double_precision: Float?, + c_date: Date?, + c_time: String?, + c_timestamp: Time?, + c_char: String?, + c_varchar: String?, + c_bytea: String?, + c_text: String?, + c_json: untyped ) -> TestRow end LIST_AUTHORS_PAGED_SQL: String - class ListAuthorsPagedRow < ::Struct + class ListAuthorsPagedRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> ListAuthorsPagedRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsPagedRow end GET_AUTHOR_BY_NAME_PATTERN_SQL: String - class GetAuthorByNamePatternRow < ::Struct + class GetAuthorByNamePatternRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorByNamePatternRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorByNamePatternRow end CREATE_AUTHOR_RETURN_ID_SQL: String - class CreateAuthorReturnIdRow < ::Struct + class CreateAuthorReturnIdRow < ::Struct[untyped] attr_accessor id: Integer - def self.new: ( Integer id) -> CreateAuthorReturnIdRow + def initialize: ( id: Integer) -> void + def self.new: ( id: Integer) -> CreateAuthorReturnIdRow end UPDATE_AUTHOR_BIOS_SQL: String GET_AUTHORS_BY_IDS_SQL: String - class GetAuthorsByIdsRow < ::Struct + class GetAuthorsByIdsRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorsByIdsRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorsByIdsRow end GET_AUTHORS_BY_IDS_AND_NAMES_SQL: String - class GetAuthorsByIdsAndNamesRow < ::Struct + class GetAuthorsByIdsAndNamesRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorsByIdsAndNamesRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorsByIdsAndNamesRow end - class CopyAuthorsArgs < ::Struct + class CopyAuthorsArgs < ::Struct[untyped] attr_accessor name: String attr_accessor bio: String? - def self.new: ( String name, String? bio) -> CopyAuthorsArgs + def initialize: ( name: String, bio: String?) -> void + def self.new: ( name: String, bio: String?) -> CopyAuthorsArgs end TRUNCATE_AUTHORS_SQL: String CREATE_BOOK_SQL: String - class CreateBookRow < ::Struct + class CreateBookRow < ::Struct[untyped] attr_accessor id: String - def self.new: ( String id) -> CreateBookRow + def initialize: ( id: String) -> void + def self.new: ( id: String) -> CreateBookRow end LIST_ALL_AUTHORS_BOOKS_SQL: String - class ListAllAuthorsBooksRow < ::Struct + class ListAllAuthorsBooksRow < ::Struct[untyped] attr_accessor author: Author attr_accessor book: Book - def self.new: ( Author author, Book book) -> ListAllAuthorsBooksRow + def initialize: ( author: Author, book: Book) -> void + def self.new: ( author: Author, book: Book) -> ListAllAuthorsBooksRow end GET_DUPLICATE_AUTHORS_SQL: String - class GetDuplicateAuthorsRow < ::Struct + class GetDuplicateAuthorsRow < ::Struct[untyped] attr_accessor author: Author attr_accessor author2: Author - def self.new: ( Author author, Author author2) -> GetDuplicateAuthorsRow + def initialize: ( author: Author, author2: Author) -> void + def self.new: ( author: Author, author2: Author) -> GetDuplicateAuthorsRow end GET_AUTHORS_BY_BOOK_NAME_SQL: String - class GetAuthorsByBookNameRow < ::Struct + class GetAuthorsByBookNameRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? attr_accessor book: Book - def self.new: ( Integer id, String name, String? bio, Book book) -> GetAuthorsByBookNameRow + def initialize: ( id: Integer, name: String, bio: String?, book: Book) -> void + def self.new: ( id: Integer, name: String, bio: String?, book: Book) -> GetAuthorsByBookNameRow end CREATE_EXTENDED_BIO_SQL: String GET_FIRST_EXTENDED_BIO_BY_TYPE_SQL: String - class GetFirstExtendedBioByTypeRow < ::Struct + class GetFirstExtendedBioByTypeRow < ::Struct[untyped] attr_accessor author_name: String attr_accessor name: String attr_accessor bio_type: String? - def self.new: ( String author_name, String name, String? bio_type) -> GetFirstExtendedBioByTypeRow + def initialize: ( author_name: String, name: String, bio_type: String?) -> void + def self.new: ( author_name: String, name: String, bio_type: String?) -> GetFirstExtendedBioByTypeRow end TRUNCATE_EXTENDED_BIOS_SQL: String @@ -175,7 +209,7 @@ module PgCodegen GET_EXTRA_TYPES_SQL: String - class GetExtraTypesRow < ::Struct + class GetExtraTypesRow < ::Struct[untyped] attr_accessor c_uuid: String? attr_accessor c_jsonb: untyped attr_accessor c_text_array: Array[String]? @@ -184,53 +218,145 @@ module PgCodegen attr_accessor c_inet: IPAddr? attr_accessor c_money: String? attr_accessor c_not_null: String + def initialize: ( + c_uuid: String?, + c_jsonb: untyped, + c_text_array: Array[String]?, + c_integer_array: Array[Integer]?, + c_timestamptz: Time?, + c_inet: IPAddr?, + c_money: String?, + c_not_null: String + ) -> void def self.new: ( - String? c_uuid, - untyped c_jsonb, - Array[String]? c_text_array, - Array[Integer]? c_integer_array, - Time? c_timestamptz, - IPAddr? c_inet, - String? c_money, - String c_not_null + c_uuid: String?, + c_jsonb: untyped, + c_text_array: Array[String]?, + c_integer_array: Array[Integer]?, + c_timestamptz: Time?, + c_inet: IPAddr?, + c_money: String?, + c_not_null: String ) -> GetExtraTypesRow end TRUNCATE_EXTRA_TYPES_SQL: String + CREATE_REGRESSION_SQL: String + + class CreateRegressionRow < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor payload: String? + attr_accessor doc: untyped + attr_accessor bio_types: Array[String]? + attr_accessor uids: Array[String]? + def initialize: ( + id: Integer, + order: String, + payload: String?, + doc: untyped, + bio_types: Array[String]?, + uids: Array[String]? + ) -> void + def self.new: ( + id: Integer, + order: String, + payload: String?, + doc: untyped, + bio_types: Array[String]?, + uids: Array[String]? + ) -> CreateRegressionRow + end + + CREATE_REGRESSION_IF_NEW_SQL: String + + class CreateRegressionIfNewRow < ::Struct[untyped] + attr_accessor id: Integer + def initialize: ( id: Integer) -> void + def self.new: ( id: Integer) -> CreateRegressionIfNewRow + end + + class CopyRegressionsArgs < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor payload: String? + attr_accessor doc: untyped + def initialize: ( id: Integer, order: String, payload: String?, doc: untyped) -> void + def self.new: ( id: Integer, order: String, payload: String?, doc: untyped) -> CopyRegressionsArgs + end + + LIST_REGRESSIONS_SQL: String + + class ListRegressionsRow < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor payload: String? + attr_accessor doc: untyped + attr_accessor bio_types: Array[String]? + attr_accessor uids: Array[String]? + def initialize: ( + id: Integer, + order: String, + payload: String?, + doc: untyped, + bio_types: Array[String]?, + uids: Array[String]? + ) -> void + def self.new: ( + id: Integer, + order: String, + payload: String?, + doc: untyped, + bio_types: Array[String]?, + uids: Array[String]? + ) -> ListRegressionsRow + end + + TRUNCATE_REGRESSIONS_SQL: String + MANY_PARAMS_SQL: String - class ManyParamsRow < ::Struct + class ManyParamsRow < ::Struct[untyped] attr_accessor c0: bool attr_accessor c1: Integer - def self.new: ( bool c0, Integer c1) -> ManyParamsRow + def initialize: ( c0: bool, c1: Integer) -> void + def self.new: ( c0: bool, c1: Integer) -> ManyParamsRow end MANY_ROWS_SQL: String - class ManyRowsRow < ::Struct + class ManyRowsRow < ::Struct[untyped] attr_accessor c0: bool attr_accessor c1: Integer attr_accessor c2: String attr_accessor c3: Float attr_accessor c4: String + def initialize: ( + c0: bool, + c1: Integer, + c2: String, + c3: Float, + c4: String + ) -> void def self.new: ( - bool c0, - Integer c1, - String c2, - Float c3, - String c4 + c0: bool, + c1: Integer, + c2: String, + c3: Float, + c4: String ) -> ManyRowsRow end FOUR_COLS_SQL: String - class FourColsRow < ::Struct + class FourColsRow < ::Struct[untyped] attr_accessor c0: bool attr_accessor c1: Integer attr_accessor c2: String attr_accessor c3: Float - def self.new: ( bool c0, Integer c1, String c2, Float c3) -> FourColsRow + def initialize: ( c0: bool, c1: Integer, c2: String, c3: Float) -> void + def self.new: ( c0: bool, c1: Integer, c2: String, c3: Float) -> FourColsRow end class QuerySql @@ -299,11 +425,23 @@ module PgCodegen def truncate_extra_types: -> void + def create_regression: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow? + + def create_regression!: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow + + def create_regression_if_new: (id: Integer, order: String) -> Integer + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def list_regressions: (?order_name: String?) -> Array[ListRegressionsRow] + + def truncate_regressions: -> void + def many_params: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow? def many_params!: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow - def many_rows: (c0: bool) -> Array[manyRowsRow] + def many_rows: (c0: bool) -> Array[ManyRowsRow] def four_cols: -> Array[FourColsRow] @@ -314,5 +452,11 @@ module PgCodegen def with_client: [T] (String name) { (untyped) -> T } -> T def configure: (untyped client) -> void + + def string_arrays: (untyped client) -> untyped + + def encode_bytea: (String? value) -> String? + + def encode_json: (untyped value) -> untyped end end diff --git a/test/golden/pg/ruby32_notypes/connection.rb b/test/golden/pg/ruby32_notypes/connection.rb index a6a4492..22ecd81 100644 --- a/test/golden/pg/ruby32_notypes/connection.rb +++ b/test/golden/pg/ruby32_notypes/connection.rb @@ -5,8 +5,14 @@ module PgCodegen # State kept on every PG::Connection the first time it is used. module ConnectionState + # Names of the statements prepared on this connection; forgotten when the server session changes + # (PG::Connection#reset after a failover / terminated backend). def prepared_statements - @prepared_statements ||= Set[] + if @prepared_statements_pid != backend_pid + @prepared_statements = Set[] + @prepared_statements_pid = backend_pid + end + @prepared_statements end end diff --git a/test/golden/pg/ruby32_notypes/models.rb b/test/golden/pg/ruby32_notypes/models.rb index 6d0eee8..0196340 100644 --- a/test/golden/pg/ruby32_notypes/models.rb +++ b/test/golden/pg/ruby32_notypes/models.rb @@ -50,6 +50,16 @@ class PostgresExtraType < Data.define( ) end + class Regression < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + class ExtendedBio < Data.define(:author_name, :name, :bio_type) end end diff --git a/test/golden/pg/ruby32_notypes/my_queries_sql.rb b/test/golden/pg/ruby32_notypes/my_queries_sql.rb index 813f87e..28073e2 100644 --- a/test/golden/pg/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/pg/ruby32_notypes/my_queries_sql.rb @@ -29,8 +29,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -40,13 +41,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -89,10 +96,22 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end end end diff --git a/test/golden/pg/ruby32_notypes/other_sql.rb b/test/golden/pg/ruby32_notypes/other_sql.rb index 91b8418..a0f1daf 100644 --- a/test/golden/pg/ruby32_notypes/other_sql.rb +++ b/test/golden/pg/ruby32_notypes/other_sql.rb @@ -42,8 +42,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -53,13 +54,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -71,7 +78,7 @@ def no_cols(c0:, c1:, c2:, c3:, c4:) c1, c2, c3, - c4 + encode_bytea(c4) ] unless client.prepared_statements.include?('other_sql_no_cols') client.prepare('other_sql_no_cols', NO_COLS_SQL) @@ -137,10 +144,27 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end end end diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index 3221290..92bf313 100644 --- a/test/golden/pg/ruby32_notypes/query_sql.rb +++ b/test/golden/pg/ruby32_notypes/query_sql.rb @@ -203,6 +203,48 @@ class GetExtraTypesRow < Data.define( TRUNCATE TABLE postgres_extra_types SQL + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids + SQL + + class CreateRegressionRow < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + + CREATE_REGRESSION_IF_NEW_SQL = <<~'SQL' + INSERT INTO regressions (id, "order") VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id + SQL + + class CreateRegressionIfNewRow < Data.define(:id) + end + + class CopyRegressionsArgs < Data.define(:id, :order, :payload, :doc) + end + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", payload, doc, bio_types, uids FROM regressions WHERE ($1::text IS NULL OR "order" = $1) ORDER BY id + SQL + + class ListRegressionsRow < Data.define( + :id, + :order, + :payload, + :doc, + :bio_types, + :uids + ) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + TRUNCATE TABLE regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -250,8 +292,9 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns, - # ROLLBACK when it raises. A nested call becomes a SAVEPOINT. Yields self, bound to that connection. + # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns + # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call + # becomes a SAVEPOINT. Yields self, bound to that connection. def transaction(isolation: nil) with_client('transaction') do |client| outermost = client.transaction_status == PG::PQTRANS_IDLE @@ -261,13 +304,19 @@ def transaction(isolation: nil) else client.exec("SAVEPOINT #{savepoint}") end + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - client.exec(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - client.exec(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + client.exec(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + client.exec(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -406,7 +455,8 @@ def create_author_return_id(name:, bio:) client.prepared_statements.add('query_sql_create_author_return_id') end result = client.exec_prepared('query_sql_create_author_return_id', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -455,7 +505,7 @@ def get_authors_by_ids_and_names(param1:, param2:) def copy_authors(rows) with_client('copy_authors') do |client| encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) - client.copy_data('COPY authors (name, bio) FROM STDIN', encoder) do + client.copy_data('COPY "authors" ("name", "bio") FROM STDIN', encoder) do rows.each do |row| client.put_copy_data([row.name, row.bio]) end @@ -483,7 +533,8 @@ def create_book(name:, author_id:) client.prepared_statements.add('query_sql_create_book') end result = client.exec_prepared('query_sql_create_book', query_params) - result.first['id'] + row = result.first + row && row['id'] end end @@ -495,7 +546,10 @@ def list_all_authors_books end result = client.exec_prepared('query_sql_list_all_authors_books').values result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end end end @@ -572,7 +626,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim with_client('insert_extra_types') do |client| query_params = [ c_uuid, - c_jsonb, + encode_json(c_jsonb), c_text_array, c_integer_array, c_timestamptz, @@ -626,6 +680,95 @@ def truncate_extra_types end end + def create_regression(id:, order:, payload:, doc:, bio_types:, uids:) + with_client('create_regression') do |client| + query_params = [ + id, + order, + encode_bytea(payload), + encode_json(doc), + bio_types, + uids + ] + unless client.prepared_statements.include?('query_sql_create_regression') + client.prepare('query_sql_create_regression', CREATE_REGRESSION_SQL) + client.prepared_statements.add('query_sql_create_regression') + end + result = client.exec_prepared('query_sql_create_regression', query_params) + row = result.first + row && CreateRegressionRow.new( + row['id'], + row['order'], + row['payload'], + row['doc'], + row['bio_types'], + row['uids'] + ) + end + end + + # Like #create_regression, but raises NotFound when there is no row. + def create_regression!(...) + create_regression(...) || raise(NotFound, 'CreateRegression returned no row') + end + + def create_regression_if_new(id:, order:) + with_client('create_regression_if_new') do |client| + query_params = [id, order] + unless client.prepared_statements.include?('query_sql_create_regression_if_new') + client.prepare('query_sql_create_regression_if_new', CREATE_REGRESSION_IF_NEW_SQL) + client.prepared_statements.add('query_sql_create_regression_if_new') + end + result = client.exec_prepared('query_sql_create_regression_if_new', query_params) + row = result.first + row && row['id'] + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + encoder = PG::TextEncoder::CopyRow.new(type_map: client.type_map_for_queries) + client.copy_data('COPY "regressions" ("id", "order", "payload", "doc") FROM STDIN', encoder) do + rows.each do |row| + client.put_copy_data([row.id, row.order, encode_bytea(row.payload), encode_json(row.doc)]) + end + end + nil + end + end + + def list_regressions(order_name: nil) + with_client('list_regressions') do |client| + query_params = [order_name] + unless client.prepared_statements.include?('query_sql_list_regressions') + client.prepare('query_sql_list_regressions', LIST_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_list_regressions') + end + result = client.exec_prepared('query_sql_list_regressions', query_params) + result.map do |row| + ListRegressionsRow.new( + row['id'], + row['order'], + row['payload'], + row['doc'], + row['bio_types'], + row['uids'] + ) + end + end + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + unless client.prepared_statements.include?('query_sql_truncate_regressions') + client.prepare('query_sql_truncate_regressions', TRUNCATE_REGRESSIONS_SQL) + client.prepared_statements.add('query_sql_truncate_regressions') + end + client.exec_prepared('query_sql_truncate_regressions') + nil + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -633,7 +776,7 @@ def many_params(c0:, c1:, c2:, c3:, c4:) c1, c2, c3, - c4 + encode_bytea(c4) ] unless client.prepared_statements.include?('query_sql_many_params') client.prepare('query_sql_many_params', MANY_PARAMS_SQL) @@ -659,7 +802,7 @@ def many_rows(c0:) end result = client.exec_prepared('query_sql_many_rows', query_params) result.map do |row| - manyRowsRow.new( + ManyRowsRow.new( row['c0'], row['c1'], row['c2'], @@ -709,10 +852,32 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + type_map.default_type_map = string_arrays(client) # uuid, enums, money, ... come back as Strings client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) end + + # Arrays of types without a decoder (uuid[], enum[], ...) become Arrays of Strings, other types Strings. + def string_arrays(client) + strings = PG::TypeMapAllStrings.new + arrays = PG::TypeMapByOid.new + client.exec("SELECT oid, typdelim FROM pg_type WHERE typcategory = 'A'", [], 0, strings).each do |row| + arrays.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, delimiter: row['typdelim'], + elements_type: PG::TextDecoder::String.new)) + end + arrays.default_type_map = strings + arrays + end + + # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. + def encode_bytea(value) + value && PG::TextEncoder::Bytea.new.encode(value) + end + + # json / jsonb parameters: an Array is a JSON array, not a PG array (Hashes and JSON text pass through). + def encode_json(value) + value.is_a?(Array) ? PG::TextEncoder::JSON.new.encode(value) : value + end end end diff --git a/test/golden/sqlite3/default/models.rb b/test/golden/sqlite3/default/models.rb index bf6966f..7df9524 100644 --- a/test/golden/sqlite3/default/models.rb +++ b/test/golden/sqlite3/default/models.rb @@ -8,4 +8,7 @@ class Author < Data.define(:id, :name, :bio) class Book < Data.define(:id, :name, :author_id, :description) end + + class Regression < Data.define(:id, :order, :price) + end end diff --git a/test/golden/sqlite3/default/models.rbs b/test/golden/sqlite3/default/models.rbs index cd9946a..afe0090 100644 --- a/test/golden/sqlite3/default/models.rbs +++ b/test/golden/sqlite3/default/models.rbs @@ -14,4 +14,11 @@ module Sqlite3Codegen attr_reader description: String? def initialize: ( Integer id, String name, Integer author_id, String? description) -> void end + + class Regression + attr_reader id: Integer + attr_reader order: String + attr_reader price: Float? + def initialize: ( Integer id, String order, Float? price) -> void + end end diff --git a/test/golden/sqlite3/default/my_queries_sql.rb b/test/golden/sqlite3/default/my_queries_sql.rb index 11eaa39..93204ef 100644 --- a/test/golden/sqlite3/default/my_queries_sql.rb +++ b/test/golden/sqlite3/default/my_queries_sql.rb @@ -22,20 +22,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end diff --git a/test/golden/sqlite3/default/other_sql.rb b/test/golden/sqlite3/default/other_sql.rb index 677ee1b..0e3e160 100644 --- a/test/golden/sqlite3/default/other_sql.rb +++ b/test/golden/sqlite3/default/other_sql.rb @@ -35,20 +35,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end diff --git a/test/golden/sqlite3/default/query_sql.rb b/test/golden/sqlite3/default/query_sql.rb index a6c17be..604ad50 100644 --- a/test/golden/sqlite3/default/query_sql.rb +++ b/test/golden/sqlite3/default/query_sql.rb @@ -127,6 +127,41 @@ class GetDuplicateAuthorsRow < Data.define(:author, :author2) class GetAuthorsByBookNameRow < Data.define(:id, :name, :bio, :book) end + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions ("order", price) VALUES (?, ?) RETURNING id + SQL + + class CreateRegressionRow < Data.define(:id) + end + + UPDATE_REGRESSION_PRICES_SQL = <<~'SQL' + UPDATE regressions SET price = ? RETURNING id + SQL + + class UpdateRegressionPricesRow < Data.define(:id) + end + + class CopyRegressionsArgs < Data.define(:order, :price) + end + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", price FROM regressions ORDER BY id + SQL + + class ListRegressionsRow < Data.define(:id, :order, :price) + end + + GET_MAX_REGRESSION_PRICE_SQL = <<~'SQL' + SELECT coalesce(max(price), 0) AS max_price FROM regressions + SQL + + class GetMaxRegressionPriceRow < Data.define(:max_price) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + DELETE FROM regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -168,20 +203,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -307,7 +349,7 @@ def update_author_bios(bio:) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) @@ -323,8 +365,8 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) @@ -341,7 +383,7 @@ def get_authors_by_ids_and_names(ids:, names:) def copy_authors(rows) with_client('copy_authors') do |client| rows.each_slice(499) do |batch| - sql = "INSERT INTO authors (name, bio) VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}" + sql = %(INSERT INTO "authors" ("name", "bio") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) client.execute(sql, batch.flat_map { |row| [row.name, row.bio] }) end nil @@ -380,7 +422,10 @@ def list_all_authors_books begin result = stmt.execute result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end ensure stmt.close @@ -417,6 +462,86 @@ def get_authors_by_book_name(name:) end end + def create_regression(order:, price:) + with_client('create_regression') do |client| + query_params = [order, price] + stmt = client.prepare(CREATE_REGRESSION_SQL) + begin + stmt.execute(*query_params).to_a + nil + ensure + stmt.close + end + end + end + + def update_regression_prices(price:) + with_client('update_regression_prices') do |client| + query_params = [price] + stmt = client.prepare(UPDATE_REGRESSION_PRICES_SQL) + begin + stmt.execute(*query_params).to_a + client.changes + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + rows.each_slice(499) do |batch| + sql = %(INSERT INTO "regressions" ("order", "price") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) + client.execute(sql, batch.flat_map { |row| [row.order, row.price] }) + end + nil + end + end + + def list_regressions + with_client('list_regressions') do |client| + stmt = client.prepare(LIST_REGRESSIONS_SQL) + begin + result = stmt.execute + result.map do |row| + ListRegressionsRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def get_max_regression_price + with_client('get_max_regression_price') do |client| + stmt = client.prepare(GET_MAX_REGRESSION_PRICE_SQL) + begin + result = stmt.execute + row = result.first + row && GetMaxRegressionPriceRow.new(row[0]) + ensure + stmt.close + end + end + end + + # Like #get_max_regression_price, but raises NotFound when there is no row. + def get_max_regression_price!(...) + get_max_regression_price(...) || raise(NotFound, 'GetMaxRegressionPrice returned no row') + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -449,7 +574,7 @@ def many_rows(c0:) begin result = stmt.execute(*query_params) result.map do |row| - manyRowsRow.new( + ManyRowsRow.new( row[0], row[1], row[2], diff --git a/test/golden/sqlite3/default/query_sql.rbs b/test/golden/sqlite3/default/query_sql.rbs index 6746cbf..2fa6a24 100644 --- a/test/golden/sqlite3/default/query_sql.rbs +++ b/test/golden/sqlite3/default/query_sql.rbs @@ -108,6 +108,44 @@ module Sqlite3Codegen def initialize: ( Integer id, String name, String? bio, Book book) -> void end + CREATE_REGRESSION_SQL: String + + class CreateRegressionRow + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + UPDATE_REGRESSION_PRICES_SQL: String + + class UpdateRegressionPricesRow + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + class CopyRegressionsArgs + attr_reader order: String + attr_reader price: Float? + def initialize: ( String order, Float? price) -> void + end + + LIST_REGRESSIONS_SQL: String + + class ListRegressionsRow + attr_reader id: Integer + attr_reader order: String + attr_reader price: Float? + def initialize: ( Integer id, String order, Float? price) -> void + end + + GET_MAX_REGRESSION_PRICE_SQL: String + + class GetMaxRegressionPriceRow + attr_reader max_price: untyped + def initialize: ( untyped max_price) -> void + end + + TRUNCATE_REGRESSIONS_SQL: String + MANY_PARAMS_SQL: String class ManyParamsRow @@ -185,11 +223,25 @@ module Sqlite3Codegen def get_authors_by_book_name: (name: String) -> Array[GetAuthorsByBookNameRow] + def create_regression: (order: String, price: Float?) -> void + + def update_regression_prices: (price: Float?) -> Integer + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def list_regressions: -> Array[ListRegressionsRow] + + def get_max_regression_price: -> GetMaxRegressionPriceRow? + + def get_max_regression_price!: -> GetMaxRegressionPriceRow + + def truncate_regressions: -> void + def many_params: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow? def many_params!: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow - def many_rows: (c0: Integer) -> Array[manyRowsRow] + def many_rows: (c0: Integer) -> Array[ManyRowsRow] def four_cols: -> Array[FourColsRow] diff --git a/test/golden/sqlite3/ruby31_gemfile/Gemfile b/test/golden/sqlite3/ruby31_gemfile/Gemfile index ea36ae7..80d2d85 100644 --- a/test/golden/sqlite3/ruby31_gemfile/Gemfile +++ b/test/golden/sqlite3/ruby31_gemfile/Gemfile @@ -1,4 +1,6 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gem 'connection_pool' -gem 'sqlite3' \ No newline at end of file +gem 'sqlite3' diff --git a/test/golden/sqlite3/ruby31_gemfile/models.rb b/test/golden/sqlite3/ruby31_gemfile/models.rb index f5eca1d..8afa7f5 100644 --- a/test/golden/sqlite3/ruby31_gemfile/models.rb +++ b/test/golden/sqlite3/ruby31_gemfile/models.rb @@ -3,9 +3,15 @@ # auto-generated by sqlc - do not edit module Sqlite3Codegen - class Author < Struct.new(:id, :name, :bio) - end + Author = Struct.new(:id, :name, :bio, keyword_init: true) - class Book < Struct.new(:id, :name, :author_id, :description) - end + Book = Struct.new( + :id, + :name, + :author_id, + :description, + keyword_init: true + ) + + Regression = Struct.new(:id, :order, :price, keyword_init: true) end diff --git a/test/golden/sqlite3/ruby31_gemfile/models.rbs b/test/golden/sqlite3/ruby31_gemfile/models.rbs index 857a90e..deb1fbf 100644 --- a/test/golden/sqlite3/ruby31_gemfile/models.rbs +++ b/test/golden/sqlite3/ruby31_gemfile/models.rbs @@ -1,17 +1,27 @@ # auto-generated by sqlc - do not edit module Sqlite3Codegen - class Author < ::Struct + class Author < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> Author + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> Author end - class Book < ::Struct + class Book < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor author_id: Integer attr_accessor description: String? - def self.new: ( Integer id, String name, Integer author_id, String? description) -> Book + def initialize: ( id: Integer, name: String, author_id: Integer, description: String?) -> void + def self.new: ( id: Integer, name: String, author_id: Integer, description: String?) -> Book + end + + class Regression < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor price: Float? + def initialize: ( id: Integer, order: String, price: Float?) -> void + def self.new: ( id: Integer, order: String, price: Float?) -> Regression end end diff --git a/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb b/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb index 11eaa39..93204ef 100644 --- a/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb @@ -22,20 +22,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end diff --git a/test/golden/sqlite3/ruby31_gemfile/other_sql.rb b/test/golden/sqlite3/ruby31_gemfile/other_sql.rb index dc840cd..0bd78f0 100644 --- a/test/golden/sqlite3/ruby31_gemfile/other_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/other_sql.rb @@ -16,8 +16,7 @@ module Sqlite3Codegen WHERE a = ? SQL - class OtherOneRow < Struct.new(:c0) - end + OtherOneRow = Struct.new(:c0, keyword_init: true) COUNT_ROWS_SQL = <<~'SQL' SELECT 1 -- ? ? @@ -35,20 +34,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -78,7 +84,7 @@ def other_one begin result = stmt.execute row = result.first - row && OtherOneRow.new(row[0]) + row && OtherOneRow.new(c0: row[0]) ensure stmt.close end diff --git a/test/golden/sqlite3/ruby31_gemfile/other_sql.rbs b/test/golden/sqlite3/ruby31_gemfile/other_sql.rbs index 6d99965..6cd3045 100644 --- a/test/golden/sqlite3/ruby31_gemfile/other_sql.rbs +++ b/test/golden/sqlite3/ruby31_gemfile/other_sql.rbs @@ -4,9 +4,10 @@ module Sqlite3Codegen OTHER_ONE_SQL: String - class OtherOneRow < ::Struct + class OtherOneRow < ::Struct[untyped] attr_accessor c0: Integer - def self.new: ( Integer c0) -> OtherOneRow + def initialize: ( c0: Integer) -> void + def self.new: ( c0: Integer) -> OtherOneRow end COUNT_ROWS_SQL: String diff --git a/test/golden/sqlite3/ruby31_gemfile/query_sql.rb b/test/golden/sqlite3/ruby31_gemfile/query_sql.rb index 33e66f8..3a41c66 100644 --- a/test/golden/sqlite3/ruby31_gemfile/query_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/query_sql.rb @@ -11,16 +11,14 @@ module Sqlite3Codegen WHERE id = ? LIMIT 1 SQL - class GetAuthorRow < Struct.new(:id, :name, :bio) - end + GetAuthorRow = Struct.new(:id, :name, :bio, keyword_init: true) LIST_AUTHORS_SQL = <<~'SQL' SELECT id, name, bio FROM authors ORDER BY name SQL - class ListAuthorsRow < Struct.new(:id, :name, :bio) - end + ListAuthorsRow = Struct.new(:id, :name, :bio, keyword_init: true) CREATE_AUTHOR_SQL = <<~'SQL' INSERT INTO authors ( @@ -41,23 +39,20 @@ class ListAuthorsRow < Struct.new(:id, :name, :bio) LIMIT ?2 OFFSET ?1 SQL - class ListAuthorsPagedRow < Struct.new(:id, :name, :bio) - end + ListAuthorsPagedRow = Struct.new(:id, :name, :bio, keyword_init: true) GET_AUTHOR_BY_NAME_PATTERN_SQL = <<~'SQL' SELECT id, name, bio FROM authors WHERE name LIKE COALESCE(?1, '%') SQL - class GetAuthorByNamePatternRow < Struct.new(:id, :name, :bio) - end + GetAuthorByNamePatternRow = Struct.new(:id, :name, :bio, keyword_init: true) CREATE_AUTHOR_RETURN_ID_SQL = <<~'SQL' INSERT INTO authors (name, bio) VALUES (?, ?) RETURNING id SQL - class CreateAuthorReturnIdRow < Struct.new(:id) - end + CreateAuthorReturnIdRow = Struct.new(:id, keyword_init: true) UPDATE_AUTHOR_BIOS_SQL = <<~'SQL' UPDATE authors @@ -69,18 +64,15 @@ class CreateAuthorReturnIdRow < Struct.new(:id) SELECT id, name, bio FROM authors WHERE id IN (/*SLICE:ids*/?) SQL - class GetAuthorsByIdsRow < Struct.new(:id, :name, :bio) - end + GetAuthorsByIdsRow = Struct.new(:id, :name, :bio, keyword_init: true) GET_AUTHORS_BY_IDS_AND_NAMES_SQL = <<~'SQL' SELECT id, name, bio FROM authors WHERE id IN (/*SLICE:ids*/?) AND name IN (/*SLICE:names*/?) SQL - class GetAuthorsByIdsAndNamesRow < Struct.new(:id, :name, :bio) - end + GetAuthorsByIdsAndNamesRow = Struct.new(:id, :name, :bio, keyword_init: true) - class CopyAuthorsArgs < Struct.new(:name, :bio) - end + CopyAuthorsArgs = Struct.new(:name, :bio, keyword_init: true) TRUNCATE_AUTHORS_SQL = <<~'SQL' DELETE FROM authors @@ -90,8 +82,7 @@ class CopyAuthorsArgs < Struct.new(:name, :bio) INSERT INTO books (name, author_id) VALUES (?, ?) RETURNING id SQL - class CreateBookRow < Struct.new(:id) - end + CreateBookRow = Struct.new(:id, keyword_init: true) LIST_ALL_AUTHORS_BOOKS_SQL = <<~'SQL' SELECT @@ -101,8 +92,7 @@ class CreateBookRow < Struct.new(:id) ORDER BY authors.name SQL - class ListAllAuthorsBooksRow < Struct.new(:author, :book) - end + ListAllAuthorsBooksRow = Struct.new(:author, :book, keyword_init: true) GET_DUPLICATE_AUTHORS_SQL = <<~'SQL' SELECT @@ -113,8 +103,7 @@ class ListAllAuthorsBooksRow < Struct.new(:author, :book) WHERE authors1.id < authors2.id SQL - class GetDuplicateAuthorsRow < Struct.new(:author, :author2) - end + GetDuplicateAuthorsRow = Struct.new(:author, :author2, keyword_init: true) GET_AUTHORS_BY_BOOK_NAME_SQL = <<~'SQL' SELECT @@ -124,38 +113,77 @@ class GetDuplicateAuthorsRow < Struct.new(:author, :author2) WHERE books.name = ? SQL - class GetAuthorsByBookNameRow < Struct.new(:id, :name, :bio, :book) - end + GetAuthorsByBookNameRow = Struct.new( + :id, + :name, + :bio, + :book, + keyword_init: true + ) + + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions ("order", price) VALUES (?, ?) RETURNING id + SQL + + CreateRegressionRow = Struct.new(:id, keyword_init: true) + + UPDATE_REGRESSION_PRICES_SQL = <<~'SQL' + UPDATE regressions SET price = ? RETURNING id + SQL + + UpdateRegressionPricesRow = Struct.new(:id, keyword_init: true) + + CopyRegressionsArgs = Struct.new(:order, :price, keyword_init: true) + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", price FROM regressions ORDER BY id + SQL + + ListRegressionsRow = Struct.new(:id, :order, :price, keyword_init: true) + + GET_MAX_REGRESSION_PRICE_SQL = <<~'SQL' + SELECT coalesce(max(price), 0) AS max_price FROM regressions + SQL + + GetMaxRegressionPriceRow = Struct.new(:max_price, keyword_init: true) + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + DELETE FROM regressions + SQL MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class ManyParamsRow < Struct.new(:c0, :c1) - end + ManyParamsRow = Struct.new(:c0, :c1, keyword_init: true) MANY_ROWS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class ManyRowsRow < Struct.new( + ManyRowsRow = Struct.new( :c0, :c1, :c2, :c3, - :c4 + :c4, + keyword_init: true ) - end FOUR_COLS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? SQL - class FourColsRow < Struct.new(:c0, :c1, :c2, :c3) - end + FourColsRow = Struct.new( + :c0, + :c1, + :c2, + :c3, + keyword_init: true + ) class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. @@ -168,20 +196,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -194,7 +229,7 @@ def get_author(id:) begin result = stmt.execute(*query_params) row = result.first - row && GetAuthorRow.new(row[0], row[1], row[2]) + row && GetAuthorRow.new(id: row[0], name: row[1], bio: row[2]) ensure stmt.close end @@ -212,7 +247,7 @@ def list_authors begin result = stmt.execute result.map do |row| - ListAuthorsRow.new(row[0], row[1], row[2]) + ListAuthorsRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -253,7 +288,7 @@ def list_authors_paged(offset:, limit:) begin result = stmt.execute(*query_params) result.map do |row| - ListAuthorsPagedRow.new(row[0], row[1], row[2]) + ListAuthorsPagedRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -268,7 +303,7 @@ def get_author_by_name_pattern(name_pattern: nil) begin result = stmt.execute(*query_params) result.map do |row| - GetAuthorByNamePatternRow.new(row[0], row[1], row[2]) + GetAuthorByNamePatternRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -307,12 +342,12 @@ def update_author_bios(bio:) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) result.map do |row| - GetAuthorsByIdsRow.new(row[0], row[1], row[2]) + GetAuthorsByIdsRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -323,13 +358,13 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) result.map do |row| - GetAuthorsByIdsAndNamesRow.new(row[0], row[1], row[2]) + GetAuthorsByIdsAndNamesRow.new(id: row[0], name: row[1], bio: row[2]) end ensure stmt.close @@ -341,7 +376,7 @@ def get_authors_by_ids_and_names(ids:, names:) def copy_authors(rows) with_client('copy_authors') do |client| rows.each_slice(499) do |batch| - sql = "INSERT INTO authors (name, bio) VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}" + sql = %(INSERT INTO "authors" ("name", "bio") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) client.execute(sql, batch.flat_map { |row| [row.name, row.bio] }) end nil @@ -380,7 +415,10 @@ def list_all_authors_books begin result = stmt.execute result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + author: Author.new(id: row[0], name: row[1], bio: row[2]), + book: Book.new(id: row[3], name: row[4], author_id: row[5], description: row[6]) + ) end ensure stmt.close @@ -394,7 +432,10 @@ def get_duplicate_authors begin result = stmt.execute result.map do |row| - GetDuplicateAuthorsRow.new(Author.new(row[0], row[1], row[2]), Author.new(row[3], row[4], row[5])) + GetDuplicateAuthorsRow.new( + author: Author.new(id: row[0], name: row[1], bio: row[2]), + author2: Author.new(id: row[3], name: row[4], bio: row[5]) + ) end ensure stmt.close @@ -409,7 +450,12 @@ def get_authors_by_book_name(name:) begin result = stmt.execute(*query_params) result.map do |row| - GetAuthorsByBookNameRow.new(row[0], row[1], row[2], Book.new(row[3], row[4], row[5], row[6])) + GetAuthorsByBookNameRow.new( + id: row[0], + name: row[1], + bio: row[2], + book: Book.new(id: row[3], name: row[4], author_id: row[5], description: row[6]) + ) end ensure stmt.close @@ -417,6 +463,86 @@ def get_authors_by_book_name(name:) end end + def create_regression(order:, price:) + with_client('create_regression') do |client| + query_params = [order, price] + stmt = client.prepare(CREATE_REGRESSION_SQL) + begin + stmt.execute(*query_params).to_a + nil + ensure + stmt.close + end + end + end + + def update_regression_prices(price:) + with_client('update_regression_prices') do |client| + query_params = [price] + stmt = client.prepare(UPDATE_REGRESSION_PRICES_SQL) + begin + stmt.execute(*query_params).to_a + client.changes + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + rows.each_slice(499) do |batch| + sql = %(INSERT INTO "regressions" ("order", "price") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) + client.execute(sql, batch.flat_map { |row| [row.order, row.price] }) + end + nil + end + end + + def list_regressions + with_client('list_regressions') do |client| + stmt = client.prepare(LIST_REGRESSIONS_SQL) + begin + result = stmt.execute + result.map do |row| + ListRegressionsRow.new(id: row[0], order: row[1], price: row[2]) + end + ensure + stmt.close + end + end + end + + def get_max_regression_price + with_client('get_max_regression_price') do |client| + stmt = client.prepare(GET_MAX_REGRESSION_PRICE_SQL) + begin + result = stmt.execute + row = result.first + row && GetMaxRegressionPriceRow.new(max_price: row[0]) + ensure + stmt.close + end + end + end + + # Like #get_max_regression_price, but raises NotFound when there is no row. + def get_max_regression_price!(...) + get_max_regression_price(...) || raise(NotFound, 'GetMaxRegressionPrice returned no row') + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -430,7 +556,7 @@ def many_params(c0:, c1:, c2:, c3:, c4:) begin result = stmt.execute(*query_params) row = result.first - row && ManyParamsRow.new(row[0], row[1]) + row && ManyParamsRow.new(c0: row[0], c1: row[1]) ensure stmt.close end @@ -449,12 +575,12 @@ def many_rows(c0:) begin result = stmt.execute(*query_params) result.map do |row| - manyRowsRow.new( - row[0], - row[1], - row[2], - row[3], - row[4] + ManyRowsRow.new( + c0: row[0], + c1: row[1], + c2: row[2], + c3: row[3], + c4: row[4] ) end ensure @@ -469,7 +595,7 @@ def four_cols begin result = stmt.execute result.map do |row| - FourColsRow.new(row[0], row[1], row[2], row[3]) + FourColsRow.new(c0: row[0], c1: row[1], c2: row[2], c3: row[3]) end ensure stmt.close diff --git a/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs b/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs index 21c81c0..34ca552 100644 --- a/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs +++ b/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs @@ -2,20 +2,22 @@ module Sqlite3Codegen GET_AUTHOR_SQL: String - class GetAuthorRow < ::Struct + class GetAuthorRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorRow end LIST_AUTHORS_SQL: String - class ListAuthorsRow < ::Struct + class ListAuthorsRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> ListAuthorsRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsRow end CREATE_AUTHOR_SQL: String @@ -24,123 +26,185 @@ module Sqlite3Codegen LIST_AUTHORS_PAGED_SQL: String - class ListAuthorsPagedRow < ::Struct + class ListAuthorsPagedRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> ListAuthorsPagedRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsPagedRow end GET_AUTHOR_BY_NAME_PATTERN_SQL: String - class GetAuthorByNamePatternRow < ::Struct + class GetAuthorByNamePatternRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorByNamePatternRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorByNamePatternRow end CREATE_AUTHOR_RETURN_ID_SQL: String - class CreateAuthorReturnIdRow < ::Struct + class CreateAuthorReturnIdRow < ::Struct[untyped] attr_accessor id: Integer - def self.new: ( Integer id) -> CreateAuthorReturnIdRow + def initialize: ( id: Integer) -> void + def self.new: ( id: Integer) -> CreateAuthorReturnIdRow end UPDATE_AUTHOR_BIOS_SQL: String GET_AUTHORS_BY_IDS_SQL: String - class GetAuthorsByIdsRow < ::Struct + class GetAuthorsByIdsRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorsByIdsRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorsByIdsRow end GET_AUTHORS_BY_IDS_AND_NAMES_SQL: String - class GetAuthorsByIdsAndNamesRow < ::Struct + class GetAuthorsByIdsAndNamesRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? - def self.new: ( Integer id, String name, String? bio) -> GetAuthorsByIdsAndNamesRow + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> GetAuthorsByIdsAndNamesRow end - class CopyAuthorsArgs < ::Struct + class CopyAuthorsArgs < ::Struct[untyped] attr_accessor name: String attr_accessor bio: String? - def self.new: ( String name, String? bio) -> CopyAuthorsArgs + def initialize: ( name: String, bio: String?) -> void + def self.new: ( name: String, bio: String?) -> CopyAuthorsArgs end TRUNCATE_AUTHORS_SQL: String CREATE_BOOK_SQL: String - class CreateBookRow < ::Struct + class CreateBookRow < ::Struct[untyped] attr_accessor id: Integer - def self.new: ( Integer id) -> CreateBookRow + def initialize: ( id: Integer) -> void + def self.new: ( id: Integer) -> CreateBookRow end LIST_ALL_AUTHORS_BOOKS_SQL: String - class ListAllAuthorsBooksRow < ::Struct + class ListAllAuthorsBooksRow < ::Struct[untyped] attr_accessor author: Author attr_accessor book: Book - def self.new: ( Author author, Book book) -> ListAllAuthorsBooksRow + def initialize: ( author: Author, book: Book) -> void + def self.new: ( author: Author, book: Book) -> ListAllAuthorsBooksRow end GET_DUPLICATE_AUTHORS_SQL: String - class GetDuplicateAuthorsRow < ::Struct + class GetDuplicateAuthorsRow < ::Struct[untyped] attr_accessor author: Author attr_accessor author2: Author - def self.new: ( Author author, Author author2) -> GetDuplicateAuthorsRow + def initialize: ( author: Author, author2: Author) -> void + def self.new: ( author: Author, author2: Author) -> GetDuplicateAuthorsRow end GET_AUTHORS_BY_BOOK_NAME_SQL: String - class GetAuthorsByBookNameRow < ::Struct + class GetAuthorsByBookNameRow < ::Struct[untyped] attr_accessor id: Integer attr_accessor name: String attr_accessor bio: String? attr_accessor book: Book - def self.new: ( Integer id, String name, String? bio, Book book) -> GetAuthorsByBookNameRow + def initialize: ( id: Integer, name: String, bio: String?, book: Book) -> void + def self.new: ( id: Integer, name: String, bio: String?, book: Book) -> GetAuthorsByBookNameRow end + CREATE_REGRESSION_SQL: String + + class CreateRegressionRow < ::Struct[untyped] + attr_accessor id: Integer + def initialize: ( id: Integer) -> void + def self.new: ( id: Integer) -> CreateRegressionRow + end + + UPDATE_REGRESSION_PRICES_SQL: String + + class UpdateRegressionPricesRow < ::Struct[untyped] + attr_accessor id: Integer + def initialize: ( id: Integer) -> void + def self.new: ( id: Integer) -> UpdateRegressionPricesRow + end + + class CopyRegressionsArgs < ::Struct[untyped] + attr_accessor order: String + attr_accessor price: Float? + def initialize: ( order: String, price: Float?) -> void + def self.new: ( order: String, price: Float?) -> CopyRegressionsArgs + end + + LIST_REGRESSIONS_SQL: String + + class ListRegressionsRow < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor order: String + attr_accessor price: Float? + def initialize: ( id: Integer, order: String, price: Float?) -> void + def self.new: ( id: Integer, order: String, price: Float?) -> ListRegressionsRow + end + + GET_MAX_REGRESSION_PRICE_SQL: String + + class GetMaxRegressionPriceRow < ::Struct[untyped] + attr_accessor max_price: untyped + def initialize: ( max_price: untyped) -> void + def self.new: ( max_price: untyped) -> GetMaxRegressionPriceRow + end + + TRUNCATE_REGRESSIONS_SQL: String + MANY_PARAMS_SQL: String - class ManyParamsRow < ::Struct + class ManyParamsRow < ::Struct[untyped] attr_accessor c0: Integer attr_accessor c1: String - def self.new: ( Integer c0, String c1) -> ManyParamsRow + def initialize: ( c0: Integer, c1: String) -> void + def self.new: ( c0: Integer, c1: String) -> ManyParamsRow end MANY_ROWS_SQL: String - class ManyRowsRow < ::Struct + class ManyRowsRow < ::Struct[untyped] attr_accessor c0: Integer attr_accessor c1: String attr_accessor c2: Float attr_accessor c3: String attr_accessor c4: Integer + def initialize: ( + c0: Integer, + c1: String, + c2: Float, + c3: String, + c4: Integer + ) -> void def self.new: ( - Integer c0, - String c1, - Float c2, - String c3, - Integer c4 + c0: Integer, + c1: String, + c2: Float, + c3: String, + c4: Integer ) -> ManyRowsRow end FOUR_COLS_SQL: String - class FourColsRow < ::Struct + class FourColsRow < ::Struct[untyped] attr_accessor c0: Integer attr_accessor c1: String attr_accessor c2: Float attr_accessor c3: String - def self.new: ( Integer c0, String c1, Float c2, String c3) -> FourColsRow + def initialize: ( c0: Integer, c1: String, c2: Float, c3: String) -> void + def self.new: ( c0: Integer, c1: String, c2: Float, c3: String) -> FourColsRow end class QuerySql @@ -185,11 +249,25 @@ module Sqlite3Codegen def get_authors_by_book_name: (name: String) -> Array[GetAuthorsByBookNameRow] + def create_regression: (order: String, price: Float?) -> void + + def update_regression_prices: (price: Float?) -> Integer + + def copy_regressions: (Array[CopyRegressionsArgs] rows) -> void + + def list_regressions: -> Array[ListRegressionsRow] + + def get_max_regression_price: -> GetMaxRegressionPriceRow? + + def get_max_regression_price!: -> GetMaxRegressionPriceRow + + def truncate_regressions: -> void + def many_params: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow? def many_params!: (c0: Integer, c1: String, c2: Float, c3: String, c4: Integer) -> ManyParamsRow - def many_rows: (c0: Integer) -> Array[manyRowsRow] + def many_rows: (c0: Integer) -> Array[ManyRowsRow] def four_cols: -> Array[FourColsRow] diff --git a/test/golden/sqlite3/ruby32_notypes/models.rb b/test/golden/sqlite3/ruby32_notypes/models.rb index bf6966f..7df9524 100644 --- a/test/golden/sqlite3/ruby32_notypes/models.rb +++ b/test/golden/sqlite3/ruby32_notypes/models.rb @@ -8,4 +8,7 @@ class Author < Data.define(:id, :name, :bio) class Book < Data.define(:id, :name, :author_id, :description) end + + class Regression < Data.define(:id, :order, :price) + end end diff --git a/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb b/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb index 11eaa39..93204ef 100644 --- a/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb @@ -22,20 +22,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end diff --git a/test/golden/sqlite3/ruby32_notypes/other_sql.rb b/test/golden/sqlite3/ruby32_notypes/other_sql.rb index 677ee1b..0e3e160 100644 --- a/test/golden/sqlite3/ruby32_notypes/other_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/other_sql.rb @@ -35,20 +35,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end diff --git a/test/golden/sqlite3/ruby32_notypes/query_sql.rb b/test/golden/sqlite3/ruby32_notypes/query_sql.rb index a6c17be..604ad50 100644 --- a/test/golden/sqlite3/ruby32_notypes/query_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/query_sql.rb @@ -127,6 +127,41 @@ class GetDuplicateAuthorsRow < Data.define(:author, :author2) class GetAuthorsByBookNameRow < Data.define(:id, :name, :bio, :book) end + CREATE_REGRESSION_SQL = <<~'SQL' + INSERT INTO regressions ("order", price) VALUES (?, ?) RETURNING id + SQL + + class CreateRegressionRow < Data.define(:id) + end + + UPDATE_REGRESSION_PRICES_SQL = <<~'SQL' + UPDATE regressions SET price = ? RETURNING id + SQL + + class UpdateRegressionPricesRow < Data.define(:id) + end + + class CopyRegressionsArgs < Data.define(:order, :price) + end + + LIST_REGRESSIONS_SQL = <<~'SQL' + SELECT id, "order", price FROM regressions ORDER BY id + SQL + + class ListRegressionsRow < Data.define(:id, :order, :price) + end + + GET_MAX_REGRESSION_PRICE_SQL = <<~'SQL' + SELECT coalesce(max(price), 0) AS max_price FROM regressions + SQL + + class GetMaxRegressionPriceRow < Data.define(:max_price) + end + + TRUNCATE_REGRESSIONS_SQL = <<~'SQL' + DELETE FROM regressions + SQL + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -168,20 +203,27 @@ def initialize(db, logger: nil) @logger = logger end - # Runs the block in a transaction and returns its value: COMMIT when the block returns, ROLLBACK when it - # raises. A nested call becomes a SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. + # Runs the block in a transaction and returns its value: COMMIT when the block returns (also through + # break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call becomes a + # SAVEPOINT. mode: :deferred (default), :immediate or :exclusive. def transaction(mode: nil) instrument('transaction') do outermost = !@db.transaction_active? savepoint = outermost ? nil : "sqlc_sp_#{rand(0xffff_ffff).to_s(16)}" @db.execute(outermost ? "BEGIN#{" #{mode.to_s.upcase}" if mode}" : "SAVEPOINT #{savepoint}") + rollback = outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}" + failed = false begin - result = yield self - @db.execute(outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}") - result + yield self rescue Exception # rubocop:disable Lint/RescueException - @db.execute(outermost ? 'ROLLBACK' : "ROLLBACK TO SAVEPOINT #{savepoint}") + failed = true + @db.execute(rollback) raise + ensure + unless failed + commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" + @db.execute(Thread.current.status == 'aborting' ? rollback : commit) + end end end end @@ -307,7 +349,7 @@ def update_author_bios(bio:) def get_authors_by_ids(ids:) with_client('get_authors_by_ids') do |client| query_params = [*ids] - sql = GET_AUTHORS_BY_IDS_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = GET_AUTHORS_BY_IDS_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) @@ -323,8 +365,8 @@ def get_authors_by_ids(ids:) def get_authors_by_ids_and_names(ids:, names:) with_client('get_authors_by_ids_and_names') do |client| query_params = [*ids, *names] - sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.sub('/*SLICE:ids*/?', slice_placeholders(ids)) - sql = sql.sub('/*SLICE:names*/?', slice_placeholders(names)) + sql = GET_AUTHORS_BY_IDS_AND_NAMES_SQL.gsub('/*SLICE:ids*/?', slice_placeholders(ids)) + sql = sql.gsub('/*SLICE:names*/?', slice_placeholders(names)) stmt = client.prepare(sql) begin result = stmt.execute(*query_params) @@ -341,7 +383,7 @@ def get_authors_by_ids_and_names(ids:, names:) def copy_authors(rows) with_client('copy_authors') do |client| rows.each_slice(499) do |batch| - sql = "INSERT INTO authors (name, bio) VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}" + sql = %(INSERT INTO "authors" ("name", "bio") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) client.execute(sql, batch.flat_map { |row| [row.name, row.bio] }) end nil @@ -380,7 +422,10 @@ def list_all_authors_books begin result = stmt.execute result.map do |row| - ListAllAuthorsBooksRow.new(Author.new(row[0], row[1], row[2]), Book.new(row[3], row[4], row[5], row[6])) + ListAllAuthorsBooksRow.new( + Author.new(row[0], row[1], row[2]), + Book.new(row[3], row[4], row[5], row[6]) + ) end ensure stmt.close @@ -417,6 +462,86 @@ def get_authors_by_book_name(name:) end end + def create_regression(order:, price:) + with_client('create_regression') do |client| + query_params = [order, price] + stmt = client.prepare(CREATE_REGRESSION_SQL) + begin + stmt.execute(*query_params).to_a + nil + ensure + stmt.close + end + end + end + + def update_regression_prices(price:) + with_client('update_regression_prices') do |client| + query_params = [price] + stmt = client.prepare(UPDATE_REGRESSION_PRICES_SQL) + begin + stmt.execute(*query_params).to_a + client.changes + ensure + stmt.close + end + end + end + + def copy_regressions(rows) + with_client('copy_regressions') do |client| + rows.each_slice(499) do |batch| + sql = %(INSERT INTO "regressions" ("order", "price") VALUES #{Array.new(batch.size, '(?, ?)').join(', ')}) + client.execute(sql, batch.flat_map { |row| [row.order, row.price] }) + end + nil + end + end + + def list_regressions + with_client('list_regressions') do |client| + stmt = client.prepare(LIST_REGRESSIONS_SQL) + begin + result = stmt.execute + result.map do |row| + ListRegressionsRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def get_max_regression_price + with_client('get_max_regression_price') do |client| + stmt = client.prepare(GET_MAX_REGRESSION_PRICE_SQL) + begin + result = stmt.execute + row = result.first + row && GetMaxRegressionPriceRow.new(row[0]) + ensure + stmt.close + end + end + end + + # Like #get_max_regression_price, but raises NotFound when there is no row. + def get_max_regression_price!(...) + get_max_regression_price(...) || raise(NotFound, 'GetMaxRegressionPrice returned no row') + end + + def truncate_regressions + with_client('truncate_regressions') do |client| + stmt = client.prepare(TRUNCATE_REGRESSIONS_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -449,7 +574,7 @@ def many_rows(c0:) begin result = stmt.execute(*query_params) result.map do |row| - manyRowsRow.new( + ManyRowsRow.new( row[0], row[1], row[2], diff --git a/test/options_test.rb b/test/options_test.rb index a83a33e..1a09708 100644 --- a/test/options_test.rb +++ b/test/options_test.rb @@ -24,9 +24,9 @@ def test_driver_is_case_insensitive_and_validated end def test_version_bounds - %w[3.1 3.2 3.3 3].each { |v| RubyVersion.parse(v) } - err = assert_raises(ArgumentError) { RubyVersion.parse('3.4') } - assert_equal 'Provided version 3.4 exceeds max version 3.3', err.message + %w[3.1 3.2 3.3 3.4 3].each { |v| RubyVersion.parse(v) } + err = assert_raises(ArgumentError) { RubyVersion.parse('3.5') } + assert_equal 'Provided version 3.5 exceeds max version 3.4', err.message err = assert_raises(ArgumentError) { RubyVersion.parse('3.0') } assert_equal 'Provided version 3.0 exceeds min version 3.1', err.message assert_raises(ArgumentError) { RubyVersion.parse('2.7') } @@ -36,7 +36,7 @@ def test_version_bounds # The C# comparison treats a patch component as greater than a missing one. def test_patch_versions_compare_like_the_csharp_implementation - assert_raises(ArgumentError) { RubyVersion.parse('3.3.1') } + assert_raises(ArgumentError) { RubyVersion.parse('3.4.1') } RubyVersion.parse('3.2.9') end diff --git a/tests/end2end_mysql2.rb b/tests/end2end_mysql2.rb index 8f038cd..04cb030 100755 --- a/tests/end2end_mysql2.rb +++ b/tests/end2end_mysql2.rb @@ -27,6 +27,7 @@ def setup @query_sql = QuerySql.connect(mysql2_params) @query_sql.truncate_authors # cascades to books @query_sql.truncate_extended_bios + @query_sql.truncate_regressions end def create_author(name, bio = nil) @@ -346,4 +347,43 @@ def test_models_are_generated_for_every_table assert_equal %i[id name author_id description], Book.members assert_equal %i[author_name name bio_type author_type], ExtendedBio.members end + + # ---- regressions ----------------------------------------------------------------------------------------------- + + def test_reused_named_parameter_and_unaliased_columns + @query_sql.copy_regressions([CopyRegressionsArgs.new(order: 'x', note: 'y'), + CopyRegressionsArgs.new(order: 'z', note: 'x'), + CopyRegressionsArgs.new(order: 'z', note: 'z')]) + assert_equal 2, @query_sql.count_regressions_by_text!(term: 'x').count + end + + def test_repeated_positional_parameters_get_distinct_names + @query_sql.copy_regressions(%w[a b c].map { |o| CopyRegressionsArgs.new(order: o, note: nil) }) + assert_equal [[1, 'a'], [3, 'c']], @query_sql.list_regressions_by_ids(id: 1, id_2: 3, id_3: 99).map { |r| [r.id, r.order] } + end + + def test_copyfrom_keeps_strings_that_look_like_null_and_special_characters + notes = ['NULL', '\N', 'a "quoted", value', 'back\\slash', "new\nline", nil] + @query_sql.copy_regressions(notes.map { |n| CopyRegressionsArgs.new(order: 'o', note: n) }) + rows = @query_sql.list_regressions_by_ids(id: 1, id_2: 2, id_3: 3) + @query_sql.list_regressions_by_ids(id: 4, id_2: 5, id_3: 6) + assert_equal notes, rows.map(&:note) + end + + def test_transaction_left_with_break_or_throw_commits_and_frees_the_connection + pooled = QuerySql.connect(mysql2_params, size: 1) + 2.times do |i| + pooled.transaction do |tx| + tx.create_author_return_id(name: "break #{i}", bio: nil) + break + end + end + catch(:out) do + pooled.transaction do |tx| + tx.create_author_return_id(name: 'throw', bio: nil) + throw :out + end + end + pooled.instance_variable_get(:@db).with { |client| refute client.in_transaction } + assert_equal ['break 0', 'break 1', 'throw'], @query_sql.list_authors.map(&:name).sort + end end diff --git a/tests/end2end_pg.rb b/tests/end2end_pg.rb index 574c789..ab756b1 100755 --- a/tests/end2end_pg.rb +++ b/tests/end2end_pg.rb @@ -31,6 +31,7 @@ def setup @query_sql.truncate_authors # cascades to books @query_sql.truncate_extended_bios @query_sql.truncate_extra_types + @query_sql.truncate_regressions end def create_author(name, bio = nil) @@ -417,4 +418,63 @@ def test_models_are_generated_for_every_table assert_equal %i[id name author_id description], Book.members assert_equal %i[author_name name bio_type], ExtendedBio.members end + + # ---- regressions ----------------------------------------------------------------------------------------------- + + def test_bytea_json_arrays_and_string_arrays_round_trip + binary = "\x00\xFFa\x00".b + uid = SecureRandom.uuid + row = @query_sql.create_regression!(id: 1, order: 'o', payload: binary, doc: [1, { 'a' => 'é' }], + bio_types: ['Memoir', nil], uids: [uid]) + assert_equal [binary, [1, { 'a' => 'é' }], ['Memoir', nil], [uid]], [row.payload, row.doc, row.bio_types, row.uids] + assert_equal row.to_h, @query_sql.list_regressions.first.to_h + end + + def test_execlastid_is_nil_when_no_row_is_returned + assert_equal 7, @query_sql.create_regression_if_new(id: 7, order: 'first') + assert_nil @query_sql.create_regression_if_new(id: 7, order: 'again') + end + + def test_copyfrom_quotes_identifiers_and_encodes_bytea_and_json + binary = "\x00\x01\xFE".b + @query_sql.copy_regressions([CopyRegressionsArgs.new(id: 1, order: 'a', payload: binary, doc: [1, 2]), + CopyRegressionsArgs.new(id: 2, order: 'b', payload: nil, doc: nil)]) + assert_equal [[1, 'a', binary, [1, 2]], [2, 'b', nil, nil]], + @query_sql.list_regressions.map { |r| [r.id, r.order, r.payload, r.doc] } + end + + def test_casted_narg_is_optional + @query_sql.create_regression_if_new(id: 1, order: 'a') + @query_sql.create_regression_if_new(id: 2, order: 'b') + assert_equal [1, 2], @query_sql.list_regressions.map(&:id) + assert_equal [2], @query_sql.list_regressions(order_name: 'b').map(&:id) + end + + def test_prepared_statements_survive_a_connection_reset + client = PG.connect(**pg_params) + query_sql = QuerySql.new(ConnectionPool.new(size: 1) { client }) + query_sql.list_authors + client.reset + assert_empty query_sql.list_authors + ensure + client&.close + end + + def test_transaction_left_with_break_or_throw_commits_and_frees_the_connection + pooled = QuerySql.connect(pg_params, size: 1) + 2.times do |i| + pooled.transaction do |tx| + tx.create_author_return_id(name: "break #{i}", bio: nil) + break + end + end + catch(:out) do + pooled.transaction do |tx| + tx.create_author_return_id(name: 'throw', bio: nil) + throw :out + end + end + pooled.instance_variable_get(:@db).with { |client| assert_equal PG::PQTRANS_IDLE, client.transaction_status } + assert_equal ['break 0', 'break 1', 'throw'], @query_sql.list_authors.map(&:name).sort + end end diff --git a/tests/end2end_sqlite3.rb b/tests/end2end_sqlite3.rb index 5096ca1..71f49a1 100644 --- a/tests/end2end_sqlite3.rb +++ b/tests/end2end_sqlite3.rb @@ -243,4 +243,37 @@ def test_models_are_generated_for_every_table assert_equal %i[id name bio], Author.members assert_equal %i[id name author_id description], Book.members end + + # ---- regressions ----------------------------------------------------------------------------------------------- + + def test_exec_and_execrows_with_returning_run_to_the_end + @query_sql.create_regression(order: 'a', price: 1.5) + @query_sql.create_regression(order: 'b', price: nil) + assert_equal 2, @query_sql.update_regression_prices(price: 2.25) + assert_equal [['a', 2.25], ['b', 2.25]], @query_sql.list_regressions.map { |r| [r.order, r.price] } + end + + def test_copyfrom_quotes_reserved_column_names_and_untyped_expressions + assert_equal 0, @query_sql.get_max_regression_price!.max_price + @query_sql.copy_regressions([CopyRegressionsArgs.new(order: 'x', price: 3.5)]) + assert_equal [['x', 3.5]], @query_sql.list_regressions.map { |r| [r.order, r.price] } + assert_in_delta 3.5, @query_sql.get_max_regression_price!.max_price + end + + def test_transaction_left_with_break_or_throw_commits + 2.times do |i| + @query_sql.transaction do |tx| + tx.create_author_return_id(name: "break #{i}", bio: nil) + break + end + end + catch(:out) do + @query_sql.transaction do |tx| + tx.create_author_return_id(name: 'throw', bio: nil) + throw :out + end + end + refute @db.transaction_active? + assert_equal ['break 0', 'break 1', 'throw'], @query_sql.list_authors.map(&:name).sort + end end From 014089ec122504a4ee8ad7614d05a88cb3ffea7d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:29:56 +0000 Subject: [PATCH 3/3] Load the nested-module test output as Ruby 3.1 code so it runs on every Ruby Co-Authored-By: Doron Eli Rachman --- test/features_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features_test.rb b/test/features_test.rb index 88edf6a..7ca4ad1 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -319,7 +319,7 @@ def test_constant_defined_twice_is_an_error end def test_nested_module_name_is_self_contained - files = generate(load_request('pg', moduleName: 'MyApp::Db')) + files = generate(load_request('pg', moduleName: 'MyApp::Db', rubyVersion: '3.1')) scope = Module.new scope.module_eval(files.fetch('models.rb')) assert scope::MyApp::Db::Author