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) |
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..7ca4ad1 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', rubyVersion: '3.1'))
+ 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_typezauthor_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ÿRpublicregressionsb varcharzorder*:6
+note0ÿÿÿÿÿÿÿÿÿRpublicregressionsbtextznote: 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 75b705d..5846fce 100644
Binary files a/test/fixtures/pg.bin and b/test/fixtures/pg.bin differ
diff --git a/test/fixtures/sqlite3.bin b/test/fixtures/sqlite3.bin
index c41d644..f27e086 100644
Binary files a/test/fixtures/sqlite3.bin and b/test/fixtures/sqlite3.bin differ
diff --git a/test/golden/mysql2/default/models.rb b/test/golden/mysql2/default/models.rb
index 3c5c04f..2bce311 100644
--- a/test/golden/mysql2/default/models.rb
+++ b/test/golden/mysql2/default/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/default/models.rbs b/test/golden/mysql2/default/models.rbs
index f1926fc..e19b214 100644
--- a/test/golden/mysql2/default/models.rbs
+++ b/test/golden/mysql2/default/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/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