From 17c6a52bafacc89afec3e57410114f86bf733838 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:02:17 +0000 Subject: [PATCH] Fix interrupted PostgreSQL PREPAREs, SQLite JSON/UUID read as numbers, Has/Is query names; :copyfrom takes any Enumerable on MySQL - pg: prepare and record a statement with interrupts deferred, and use a statement the server already has (PG::DuplicatePstatement) instead of failing on every later call - sqlite3: JSON / UUID columns (NUMERIC affinity) are always read as Strings - query names starting with has_ / is_ / have_ get Naming/PredicatePrefix disabled - mysql2: :copyfrom no longer calls rows.empty?, so lazy enumerators work like on PostgreSQL / SQLite - README: document the sqlc bug that cuts SQLite query text after a non-ASCII character Co-Authored-By: Doron Eli Rachman --- README.md | 12 +- docs/Coverage.md | 4 + docs/Testing.md | 3 +- examples/authors/mysql/query.sql | 3 + examples/authors/postgresql/query.sql | 3 + examples/authors/sqlite/query.sql | 3 + examples/mysql2/query_sql.rb | 48 ++-- examples/mysql2/query_sql.rbs | 11 + examples/pg/query_sql.rb | 48 +++- examples/pg/query_sql.rbs | 12 + examples/sqlite3/query_sql.rb | 33 ++- examples/sqlite3/query_sql.rbs | 11 + lib/sqlc_gen_ruby/drivers/base.rb | 3 + lib/sqlc_gen_ruby/drivers/mysql2.rb | 4 +- lib/sqlc_gen_ruby/drivers/pg.rb | 27 +- lib/sqlc_gen_ruby/drivers/sqlite3.rb | 7 + lib/sqlc_gen_ruby/method_gen.rb | 12 +- steep_expectations.yml | 254 ++++++++++-------- test/features_test.rb | 14 +- test/fixtures/mysql2.bin | 5 +- test/fixtures/pg.bin | 5 +- test/fixtures/sqlite3.bin | Bin 18646 -> 18758 bytes test/golden/mysql2/default/query_sql.rb | 48 ++-- test/golden/mysql2/default/query_sql.rbs | 11 + .../golden/mysql2/ruby31_gemfile/query_sql.rb | 47 ++-- .../mysql2/ruby31_gemfile/query_sql.rbs | 12 + .../golden/mysql2/ruby32_notypes/query_sql.rb | 48 ++-- test/golden/pg/default/my_queries_sql.rb | 24 +- test/golden/pg/default/my_queries_sql.rbs | 1 + test/golden/pg/default/other_sql.rb | 24 +- test/golden/pg/default/other_sql.rbs | 1 + test/golden/pg/default/query_sql.rb | 48 +++- test/golden/pg/default/query_sql.rbs | 12 + .../pg/ruby31_gemfile/my_queries_sql.rb | 24 +- .../pg/ruby31_gemfile/my_queries_sql.rbs | 1 + test/golden/pg/ruby31_gemfile/other_sql.rb | 24 +- test/golden/pg/ruby31_gemfile/other_sql.rbs | 1 + test/golden/pg/ruby31_gemfile/query_sql.rb | 47 +++- test/golden/pg/ruby31_gemfile/query_sql.rbs | 13 + .../pg/ruby32_notypes/my_queries_sql.rb | 24 +- test/golden/pg/ruby32_notypes/other_sql.rb | 24 +- test/golden/pg/ruby32_notypes/query_sql.rb | 48 +++- test/golden/sqlite3/default/query_sql.rb | 33 ++- test/golden/sqlite3/default/query_sql.rbs | 11 + .../sqlite3/ruby31_gemfile/query_sql.rb | 32 ++- .../sqlite3/ruby31_gemfile/query_sql.rbs | 12 + .../sqlite3/ruby32_notypes/query_sql.rb | 33 ++- tests/end2end_mysql2.rb | 15 ++ tests/end2end_pg.rb | 60 +++++ tests/end2end_sqlite3.rb | 28 ++ 50 files changed, 944 insertions(+), 284 deletions(-) diff --git a/README.md b/README.md index 8949ffb..49e4d1a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,9 @@ per table, one module of constants per enum), `connection.rb` (shared helpers) a one at a time. * **Style:** SQL lives in frozen heredoc constants (`GET_AUTHOR_SQL`), comments above a query become its doc comment, table / column / enum comments of the schema become doc comments of the models. The code is RuboCop-clean; where a - name from your schema breaks a RuboCop naming rule (`"UserId"`, `naïve`), that class or method gets a `rubocop:disable`. + name from your schema breaks a RuboCop naming rule (`"UserId"`, `naïve`), that class or method gets a `rubocop:disable` + (on SQLite keep the query files ASCII, see [Limitations](#limitations)). Query names such as `HasAuthors` / `IsActive` + keep their name (`has_authors`) with `Naming/PredicatePrefix` disabled. A result column without a usable name (`SELECT 1, now()`) is named by its position: `column_1`, `column_2`, ... ### Transactions @@ -156,7 +158,7 @@ end | `:execlastid` | the new id | PostgreSQL / SQLite: needs `RETURNING id`; MySQL: the auto-increment id | | `:execresult` | `ExecResult` (`rows_affected`, `last_insert_id`) | `last_insert_id` is `nil` on PostgreSQL (use `RETURNING`) | | `:batchexec` / `:batchone` / `:batchmany` | `nil` / `Array` of rows or `nil` / `Array` of `Array`s of rows | take an `Array` of `Args` and run the query once per element, on one connection | -| `:copyfrom` | `nil` | bulk insert of an `Array` of `Args`; all rows or none (see below) | +| `:copyfrom` | `nil` | bulk insert of an `Array` (or any `Enumerable`, also lazy) of `Args`; all rows or none (see below) | `:copyfrom` uses `COPY ... FROM STDIN` on PostgreSQL and batched multi-row `INSERT`s on SQLite. On MySQL it uses `LOAD DATA LOCAL INFILE`, which needs `local_infile: true` in the `Mysql2::Client` params and `local_infile=1` on the @@ -191,7 +193,8 @@ server; duplicate keys and invalid values raise `Mysql2::Error` instead of being are not valid constant names still get one: `'end'` → `END_`, `'all'` → `ALL_`, `'2x'` → `VALUE_2X`, `'a b'` → `A_B`; values that end up with the same name get `_2`, `_3`, ... * SQLite follows its [type affinity rules](https://www.sqlite.org/datatype3.html#determination_of_column_affinity) - (`VARCHAR(255)` → `String`, `DECIMAL(10,5)` → `Float`, `UNSIGNED BIG INT` → `Integer`); `JSON` and `UUID` columns are `String`s. + (`VARCHAR(255)` → `String`, `DECIMAL(10,5)` → `Float`, `UNSIGNED BIG INT` → `Integer`); `JSON` and `UUID` columns are `String`s, + also when SQLite stored numeric-looking text as a number (`'42'` → `"42"`, in SQLite's spelling: `'1.50'` → `"1.5"`). The generated SQLite code uses `Monitor`: if you run Steep, add `library "monitor"` to your `Steepfile`. ### Names @@ -217,6 +220,9 @@ server; duplicate keys and invalid values raise `Mysql2::Error` instead of being and fibers stop too; `busy_handler_timeout=` waits without blocking them. * SQLite transactions belong to the thread that opened them; fibers sharing the same database take turns, and a transaction holds it until it ends. Only the yielded `tx` runs inside the transaction from other fibers / threads. +* SQLite: sqlc (1.27 – 1.31 at least) misplaces the SQL of a query file that contains a non-ASCII character (in a + name, literal or comment): that query and the following ones lose characters at the end (`FROM user` instead of + `FROM users`). This is a sqlc bug, not a plugin one; keep SQLite query files ASCII and pass such values as parameters. * Not applicable to Ruby (they exist in the C# plugin): `withAsyncSuffix` / `withCancellationToken` (use the Fiber scheduler), `useDapper`, target framework / csproj options (`moduleName` replaces the namespace). diff --git a/docs/Coverage.md b/docs/Coverage.md index 3b2c0ea..04afe42 100644 --- a/docs/Coverage.md +++ b/docs/Coverage.md @@ -43,3 +43,7 @@ CI runs `test/coverage_map_test.rb`, which checks every linked test method exist | Interrupts at `BEGIN` / `COMMIT` (`Thread#raise`, raised by the statement) roll back and leave no open transaction | [`test_interrupts_around_begin_and_commit_never_leave_the_connection_in_a_transaction`](../tests/end2end_pg.rb#test_interrupts_around_begin_and_commit_never_leave_the_connection_in_a_transaction) | [`test_interrupts_around_begin_and_commit_never_leave_the_connection_in_a_transaction`](../tests/end2end_mysql2.rb#test_interrupts_around_begin_and_commit_never_leave_the_connection_in_a_transaction) | [`test_interrupts_around_begin_and_commit_never_leave_the_database_in_a_transaction`](../tests/end2end_sqlite3.rb#test_interrupts_around_begin_and_commit_never_leave_the_database_in_a_transaction) | | | PostgreSQL statements dropped by `DEALLOCATE ALL` / `DISCARD ALL` are prepared again | [`test_statements_are_prepared_again_after_deallocate_all`](../tests/end2end_pg.rb#test_statements_are_prepared_again_after_deallocate_all) | not MySQL (no server-side prepared statements) | not SQLite | [`test_statements_are_named_after_the_module_and_query_class`](../test/features_test.rb#test_statements_are_named_after_the_module_and_query_class) | | `:execresult` with `RETURNING` runs the statement | covered by `:execresult` semantics | covered by `:execresult` semantics | [`test_execresult_with_returning_runs_the_statement`](../tests/end2end_sqlite3.rb#test_execresult_with_returning_runs_the_statement) | | +| `:copyfrom` takes any `Enumerable` (empty, lazy, `Enumerator`) | [`test_copyfrom_takes_any_enumerable`](../tests/end2end_pg.rb#test_copyfrom_takes_any_enumerable) | [`test_copyfrom_takes_any_enumerable`](../tests/end2end_mysql2.rb#test_copyfrom_takes_any_enumerable) | [`test_copyfrom_takes_any_enumerable`](../tests/end2end_sqlite3.rb#test_copyfrom_takes_any_enumerable) | | +| Query names like `HasAuthors` / `IsActive` keep their name and stay RuboCop-clean | [`test_predicate_like_query_names_are_kept`](../tests/end2end_pg.rb#test_predicate_like_query_names_are_kept) | [`test_predicate_like_query_names_are_kept`](../tests/end2end_mysql2.rb#test_predicate_like_query_names_are_kept) | [`test_predicate_like_query_names_are_kept`](../tests/end2end_sqlite3.rb#test_predicate_like_query_names_are_kept) | [`test_predicate_like_query_names_are_rubocop_clean`](../test/features_test.rb#test_predicate_like_query_names_are_rubocop_clean) | +| SQLite `JSON` / `UUID` columns are `String`s, also numeric-looking text | not PostgreSQL | not MySQL | [`test_json_and_uuid_columns_return_strings_that_look_numeric`](../tests/end2end_sqlite3.rb#test_json_and_uuid_columns_return_strings_that_look_numeric) | | +| PostgreSQL `PREPARE` interrupted (Timeout / `Thread#raise`, an exception raised by it) after the server ran it leaves the statement usable | [`test_an_interrupt_during_prepare_leaves_the_statement_usable`](../tests/end2end_pg.rb#test_an_interrupt_during_prepare_leaves_the_statement_usable), [`test_a_statement_left_prepared_by_a_failed_prepare_is_used`](../tests/end2end_pg.rb#test_a_statement_left_prepared_by_a_failed_prepare_is_used) | not MySQL (no server-side prepared statements) | not SQLite | | diff --git a/docs/Testing.md b/docs/Testing.md index 701f853..8c8b669 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -40,7 +40,8 @@ They `require` the generated code from `examples/` and run it against a real dat isolation, `break` / `throw`, a rescued failed statement on PostgreSQL, SQLite threads sharing a database, the yielded `tx` used from other fibers / threads), models named like Ruby constants (`Exception`, `Data`, ...), pool injection, logging, bang finders, row members named like row methods (`initialize`), MySQL changed vs. matched - rows, SQLite busy waiting, `:copyfrom` with awkward and binary values and its all-or-nothing behaviour, + rows, SQLite busy waiting, `:copyfrom` with awkward and binary values, lazy enumerators and its all-or-nothing behaviour, + PostgreSQL `PREPARE`s interrupted after the server ran them, SQLite JSON / UUID text that looks numeric, Fiber-scheduler concurrency (`async` gem) and statement-leak checks. ## CI workflows (`.github/workflows/`) diff --git a/examples/authors/mysql/query.sql b/examples/authors/mysql/query.sql index c8c252d..ee8809b 100644 --- a/examples/authors/mysql/query.sql +++ b/examples/authors/mysql/query.sql @@ -224,3 +224,6 @@ SELECT COUNT(*) AS n FROM exceptions; -- name: TruncateExceptions :exec TRUNCATE TABLE exceptions; + +-- name: HasAuthors :one +SELECT EXISTS (SELECT 1 FROM authors) AS has_any; diff --git a/examples/authors/postgresql/query.sql b/examples/authors/postgresql/query.sql index f54b6f9..e16e37f 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -263,3 +263,6 @@ INSERT INTO late.feelings (id, moods) VALUES ($1, $2); -- name: GetLateFeeling :one SELECT moods FROM late.feelings WHERE id = $1; + +-- name: HasAuthors :one +SELECT EXISTS (SELECT 1 FROM authors) AS has_any; diff --git a/examples/authors/sqlite/query.sql b/examples/authors/sqlite/query.sql index 7a16031..88dcd2d 100644 --- a/examples/authors/sqlite/query.sql +++ b/examples/authors/sqlite/query.sql @@ -220,3 +220,6 @@ INSERT INTO exceptions (id) VALUES (NULL); -- name: CountExceptions :one SELECT COUNT(*) AS n FROM exceptions; + +-- name: HasAuthors :one +SELECT EXISTS (SELECT 1 FROM authors) AS has_any; diff --git a/examples/mysql2/query_sql.rb b/examples/mysql2/query_sql.rb index 9967f26..3b1a875 100644 --- a/examples/mysql2/query_sql.rb +++ b/examples/mysql2/query_sql.rb @@ -490,6 +490,13 @@ class CountExceptionsRow < ::Data.define(:n) TRUNCATE TABLE exceptions SQL + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < ::StandardError; end @@ -757,14 +764,12 @@ def get_authors_by_ids_and_names(ids:, names:) # Bulk insert: pass an Array of CopyAuthorsArgs. def copy_authors(rows) with_client('copy_authors') do |client| - return if rows.empty? - ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.name, row.bio])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? end nil end @@ -912,14 +917,12 @@ def list_regressions_by_ids(id:, id_2:, id_3:) 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 - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? end nil end @@ -927,14 +930,12 @@ def copy_regressions(rows) def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| - return if rows.empty? - ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.order, row.note, row.payload])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? end nil end @@ -1001,14 +1002,12 @@ def truncate_regressions def copy_copy_types(rows) with_client('copy_copy_types') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? end nil end @@ -1016,14 +1015,12 @@ def copy_copy_types(rows) def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? end nil end @@ -1031,14 +1028,12 @@ def copy_copy_types_ignore(rows) def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? end nil end @@ -1314,6 +1309,23 @@ def truncate_exceptions end end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = client.query(bind_params(client, HAS_AUTHORS_SQL, []), as: :array) + row = result.first + row && HasAuthorsRow.new(row[0]) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + private def instrument(name) diff --git a/examples/mysql2/query_sql.rbs b/examples/mysql2/query_sql.rbs index 73d3edf..6d27522 100644 --- a/examples/mysql2/query_sql.rbs +++ b/examples/mysql2/query_sql.rbs @@ -531,6 +531,13 @@ module Mysql2Codegen TRUNCATE_EXCEPTIONS_SQL: ::String + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow + attr_reader has_any: untyped + def initialize: ( untyped has_any) -> void + end + class QuerySql class NotFound < ::StandardError end @@ -687,6 +694,10 @@ module Mysql2Codegen def truncate_exceptions: -> void + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + private def instrument: [T] (::String name) { () -> T } -> T diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index bfa65cb..9e3e6d7 100644 --- a/examples/pg/query_sql.rb +++ b/examples/pg/query_sql.rb @@ -581,6 +581,13 @@ class CountExceptionsRow < ::Data.define(:n) class GetLateFeelingRow < ::Data.define(:moods) end + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < ::StandardError; end @@ -1527,6 +1534,23 @@ def get_late_feeling!(...) get_late_feeling(...) || raise(NotFound, 'GetLateFeeling returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = run_prepared(client, 'PgCodegen::QuerySql#has_authors', HAS_AUTHORS_SQL) + row = result.first + row && HasAuthorsRow.new(row['has_any']) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + private def instrument(name) @@ -1589,22 +1613,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + 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) diff --git a/examples/pg/query_sql.rbs b/examples/pg/query_sql.rbs index c9f59f8..e05f7f6 100644 --- a/examples/pg/query_sql.rbs +++ b/examples/pg/query_sql.rbs @@ -603,6 +603,13 @@ module PgCodegen def initialize: ( ::Array[::String] moods) -> void end + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow + attr_reader has_any: bool + def initialize: ( bool has_any) -> void + end + class QuerySql class NotFound < ::StandardError end @@ -793,6 +800,10 @@ module PgCodegen def get_late_feeling!: (id: ::Integer) -> GetLateFeelingRow + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + private def instrument: [T] (::String name) { () -> T } -> T @@ -806,6 +817,7 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void def encode_bytea: (::String? value) -> ::String? diff --git a/examples/sqlite3/query_sql.rb b/examples/sqlite3/query_sql.rb index 9d2b157..00150ef 100644 --- a/examples/sqlite3/query_sql.rb +++ b/examples/sqlite3/query_sql.rb @@ -421,6 +421,13 @@ class CreateTagResultRow < ::Data.define(:name) class CountExceptionsRow < ::Data.define(:n) end + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < ::StandardError; end @@ -902,8 +909,8 @@ def get_declared_types row[2], row[3], row[4], - row[5], - row[6] + row[5]&.to_s, + row[6]&.to_s ) ensure stmt.close @@ -1351,6 +1358,28 @@ def count_exceptions!(...) count_exceptions(...) || raise(NotFound, 'CountExceptions returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + stmt = client.prepare(HAS_AUTHORS_SQL) + begin + result = execute_statement(stmt) + row = result.first + row && HasAuthorsRow.new(row[0]) + ensure + stmt.close + end + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + private def instrument(name) diff --git a/examples/sqlite3/query_sql.rbs b/examples/sqlite3/query_sql.rbs index 236fd42..3f602f1 100644 --- a/examples/sqlite3/query_sql.rbs +++ b/examples/sqlite3/query_sql.rbs @@ -417,6 +417,13 @@ module Sqlite3Codegen def initialize: ( ::Integer n) -> void end + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow + attr_reader has_any: ::Integer + def initialize: ( ::Integer has_any) -> void + end + class QuerySql class NotFound < ::StandardError end @@ -557,6 +564,10 @@ module Sqlite3Codegen def count_exceptions!: -> CountExceptionsRow + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + private def instrument: [T] (::String name) { () -> T } -> T diff --git a/lib/sqlc_gen_ruby/drivers/base.rb b/lib/sqlc_gen_ruby/drivers/base.rb index ce4cfdb..18b4d52 100644 --- a/lib/sqlc_gen_ruby/drivers/base.rb +++ b/lib/sqlc_gen_ruby/drivers/base.rb @@ -92,6 +92,9 @@ 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 + # Ruby expression that turns a value read from a result row into the declared Ruby type. + def decode_value(expr, _column) = expr + # :execresult -> [*statements, rows_affected expression, last_insert_id expression] def exec_result_lines(_exec, _query) = raise(NotImplementedError) diff --git a/lib/sqlc_gen_ruby/drivers/mysql2.rb b/lib/sqlc_gen_ruby/drivers/mysql2.rb index 07d9165..648fe18 100644 --- a/lib/sqlc_gen_ruby/drivers/mysql2.rb +++ b/lib/sqlc_gen_ruby/drivers/mysql2.rb @@ -75,9 +75,7 @@ def copy_from_lines(rows_var, table, columns, values, label, query_name:, source load_data = format(LOAD_DATA, table: table, **load_columns(columns, sources), mode: load_mode(verb), strict: verb.split.include?('IGNORE') ? ', strict: false' : '') [ - "return if #{rows_var}.empty?", - '', - Code.block("::Tempfile.create(['#{label}', '.csv']) do |file|", "#{write}\nfile.flush\n#{load_data}"), + Code.block("::Tempfile.create(['#{label}', '.csv']) do |file|", "#{write}\nfile.flush\n#{load_data} if file.pos.positive?"), 'nil' ] end diff --git a/lib/sqlc_gen_ruby/drivers/pg.rb b/lib/sqlc_gen_ruby/drivers/pg.rb index 6e1072a..9f1877c 100644 --- a/lib/sqlc_gen_ruby/drivers/pg.rb +++ b/lib/sqlc_gen_ruby/drivers/pg.rb @@ -347,24 +347,35 @@ def transaction(isolation: nil) RUN_PREPARED = <<~'RUBY'.chomp # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end RUBY - RUN_PREPARED_RBS = 'def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped' + RUN_PREPARED_RBS = "def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped\n" \ + 'def prepare: (untyped client, ::String name, ::String sql) -> void' WITH_CLIENT = <<~'RUBY'.chomp def with_client(name) diff --git a/lib/sqlc_gen_ruby/drivers/sqlite3.rb b/lib/sqlc_gen_ruby/drivers/sqlite3.rb index 799a34d..3852cf5 100644 --- a/lib/sqlc_gen_ruby/drivers/sqlite3.rb +++ b/lib/sqlc_gen_ruby/drivers/sqlite3.rb @@ -327,6 +327,13 @@ def atomically(client) end RUBY + # JSON / UUID columns have NUMERIC affinity: SQLite stores numeric-looking text ('42', '1.5') as a number. + def decode_value(expr, column) + TEXT_AS_NUMBER.include?(normalize_type(column&.type&.name.to_s)) ? "#{expr}&.to_s" : expr + end + + TEXT_AS_NUMBER = %w[json jsonb uuid].freeze + private # VARCHAR(255) -> varchar, UNSIGNED BIG INT -> unsignedbigint diff --git a/lib/sqlc_gen_ruby/method_gen.rb b/lib/sqlc_gen_ruby/method_gen.rb index eb1c8b1..0771eaf 100644 --- a/lib/sqlc_gen_ruby/method_gen.rb +++ b/lib/sqlc_gen_ruby/method_gen.rb @@ -16,6 +16,8 @@ class MethodGen BATCH_COMMANDS = { ':batchexec' => ':exec', ':batchone' => ':one', ':batchmany' => ':many' }.freeze # commands whose method takes `rows` (an Array of Args) instead of keyword arguments ROWS_COMMANDS = [':copyfrom', *BATCH_COMMANDS.keys].freeze + # Naming/PredicatePrefix's default NamePrefix + PREDICATE_PREFIX = /\A(is|has|have)_/ # optional: nullable and named by the user (sqlc.arg / sqlc.narg) -> keyword argument defaulting to nil # reused: a later occurrence of a named parameter (MySQL sends one parameter per `?`): bound, not a keyword @@ -77,9 +79,11 @@ def code(query) cops = Code.naming_cops(params: method_params) # Struct rows (Ruby < 3.2) are built with keywords: row_1: (unnamed columns, repeated names) cops << 'Naming/VariableNumber' if keyword_row_names(query).any? { |n| n.match?(/_\d+\z/) } && !cops.include?('Naming/VariableNumber') - method = Code.without_cops(method, cops) + # HasAuthor -> has_author, IsActive -> is_active: names the query author chose + predicate = func.match?(PREDICATE_PREFIX) ? ['Naming/PredicatePrefix'] : [] + method = Code.without_cops(method, cops + predicate) method = "#{doc}\n#{method}" unless doc.empty? - query.cmd == ':one' ? "#{method}\n\n#{bang_method(query, func)}" : method + query.cmd == ':one' ? "#{method}\n\n#{Code.without_cops(bang_method(query, func), predicate)}" : method end # get_author! raises NotFound instead of returning nil (like find_by vs find!) @@ -289,14 +293,14 @@ def new_row(query) index = 0 args = @layout.entries(query).map do |entry| value = if entry.table - values = entry.table.columns.each_index.map { |i| "row[#{index + i}]" } + values = entry.table.columns.each_with_index.map { |c, i| @driver.decode_value("row[#{index + i}]", c) } index += values.size names = entry.table.columns.map { |c| @types.member_name(c.name) } model = "#{entry.table.model_name}.new(#{constructor_args(names, values).join(', ')})" entry.optional ? "#{missing_row_check(entry.table, values)} ? nil : #{model}" : model else index += 1 - array ? "row[#{index - 1}]" : "row[#{Code.string_literal(entry.column.name)}]" + @driver.decode_value(array ? "row[#{index - 1}]" : "row[#{Code.string_literal(entry.column.name)}]", entry.column) end [entry.attr, value] end diff --git a/steep_expectations.yml b/steep_expectations.yml index 6e2df01..8c91942 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -3,90 +3,90 @@ diagnostics: - range: start: - line: 500 + line: 507 character: 12 end: - line: 500 + line: 507 character: 26 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 500 + line: 507 character: 51 end: - line: 500 + line: 507 character: 57 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 500 + line: 507 character: 70 end: - line: 500 + line: 507 character: 85 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 525 + line: 532 character: 54 end: - line: 573 + line: 580 character: 11 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 537 + line: 544 character: 62 end: - line: 539 + line: 546 character: 15 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 545 + line: 552 character: 21 end: - line: 545 + line: 552 character: 27 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 548 + line: 555 character: 37 end: - line: 548 + line: 555 character: 43 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 563 + line: 570 character: 27 end: - line: 563 + line: 570 character: 33 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 1404 + line: 1416 character: 20 end: - line: 1404 + line: 1416 character: 38 severity: ERROR message: |- @@ -99,20 +99,20 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1405 + line: 1417 character: 78 end: - line: 1405 + line: 1417 character: 84 severity: ERROR message: Type `(::String | nil)` does not have method `match?` code: Ruby::NoMethod - range: start: - line: 1407 + line: 1419 character: 20 end: - line: 1407 + line: 1419 character: 39 severity: ERROR message: |- @@ -125,10 +125,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1411 + line: 1423 character: 20 end: - line: 1411 + line: 1423 character: 39 severity: ERROR message: |- @@ -141,10 +141,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1416 + line: 1428 character: 20 end: - line: 1416 + line: 1428 character: 39 severity: ERROR message: |- @@ -157,10 +157,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1425 + line: 1437 character: 20 end: - line: 1425 + line: 1437 character: 24 severity: ERROR message: |- @@ -173,10 +173,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1458 + line: 1470 character: 18 end: - line: 1458 + line: 1470 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' @@ -247,380 +247,410 @@ diagnostics: - range: start: - line: 591 + line: 598 character: 12 end: - line: 591 + line: 598 character: 26 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 591 + line: 598 character: 51 end: - line: 591 + line: 598 character: 53 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 591 + line: 598 character: 62 end: - line: 591 + line: 598 character: 73 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 607 + line: 614 character: 51 end: - line: 607 + line: 614 character: 53 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 620 + line: 627 character: 54 end: - line: 664 + line: 671 character: 11 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 628 + line: 635 character: 62 end: - line: 630 + line: 637 character: 15 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 634 + line: 641 character: 60 end: - line: 634 + line: 641 character: 62 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 636 + line: 643 character: 76 end: - line: 636 + line: 643 character: 78 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 637 + line: 644 character: 65 end: - line: 637 + line: 644 character: 67 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 644 + line: 651 character: 37 end: - line: 644 + line: 651 character: 39 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 646 + line: 653 character: 22 end: - line: 646 + line: 653 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 649 + line: 656 character: 55 end: - line: 649 + line: 656 character: 57 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 655 + line: 662 character: 64 end: - line: 655 + line: 662 character: 66 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 656 + line: 663 character: 42 end: - line: 656 + line: 663 character: 44 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 656 + line: 663 character: 95 end: - line: 656 + line: 663 character: 97 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 661 + line: 668 character: 22 end: - line: 661 + line: 668 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 865 + line: 872 character: 20 end: - line: 865 + line: 872 character: 22 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1243 + line: 1250 character: 20 end: - line: 1243 + line: 1250 character: 22 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1323 + line: 1330 character: 20 end: - line: 1323 + line: 1330 character: 22 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1547 + line: 1571 character: 32 end: - line: 1547 + line: 1571 character: 34 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1549 + line: 1573 character: 20 end: - line: 1549 + line: 1573 character: 22 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1558 + line: 1582 character: 60 end: - line: 1558 + line: 1582 character: 62 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1570 + line: 1594 character: 19 end: - line: 1570 + line: 1594 character: 21 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1573 + line: 1597 character: 18 end: - line: 1573 + line: 1597 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1575 + line: 1599 character: 55 end: - line: 1575 + line: 1599 character: 57 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1582 + line: 1606 character: 18 end: - line: 1582 + line: 1606 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1583 + line: 1607 character: 16 end: - line: 1583 + line: 1607 character: 18 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1584 + line: 1608 character: 68 end: - line: 1584 + line: 1608 character: 70 severity: WARNING message: Empty array doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 1599 + line: 1621 character: 13 end: - line: 1599 + line: 1621 character: 15 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1601 + line: 1623 character: 50 end: - line: 1601 + line: 1623 character: 52 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1610 + line: 1633 + character: 52 + end: + line: 1639 + character: 9 + severity: WARNING + message: The method cannot be called with a block + code: Ruby::UnexpectedBlockGiven + - range: + start: + line: 1636 + character: 15 + end: + line: 1636 + character: 17 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 1638 + character: 52 + end: + line: 1638 + character: 54 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 1644 character: 17 end: - line: 1610 + line: 1644 character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1617 + line: 1651 character: 53 end: - line: 1617 + line: 1651 character: 55 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1633 + line: 1667 character: 8 end: - line: 1633 + line: 1667 character: 10 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1633 + line: 1667 character: 52 end: - line: 1633 + line: 1667 character: 54 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1640 + line: 1674 character: 44 end: - line: 1640 + line: 1674 character: 46 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1641 + line: 1675 character: 27 end: - line: 1641 + line: 1675 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1642 + line: 1676 character: 46 end: - line: 1642 + line: 1676 character: 48 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1643 + line: 1677 character: 27 end: - line: 1643 + line: 1677 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' @@ -629,30 +659,30 @@ diagnostics: - range: start: - line: 454 + line: 461 character: 56 end: - line: 482 + line: 489 character: 13 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 460 + line: 467 character: 64 end: - line: 462 + line: 469 character: 17 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 1378 + line: 1407 character: 17 end: - line: 1378 + line: 1407 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `SQLite3`' diff --git a/test/features_test.rb b/test/features_test.rb index 24ac095..e0d3898 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -362,7 +362,7 @@ def test_logger_support end def test_query_names_cannot_replace_generated_methods - %w[Transaction Initialize Connect WithClient Hash].each do |name| + %w[Transaction Initialize Connect WithClient Hash Prepare].each do |name| request = load_request('pg') query(request, 'DeleteAuthor').name = name error = assert_raises(RuntimeError) { generate(request) } @@ -644,6 +644,18 @@ def test_ruby31_struct_rows_of_unnamed_columns_are_rubocop_clean end end + def test_predicate_like_query_names_are_rubocop_clean + %w[pg mysql2 sqlite3].each do |driver| + request = load_request(driver) + query(request, 'GetAuthor').name = 'IsAuthor' # :one, with a bang method + query(request, 'ListAuthors').name = 'HasAuthorsListed' + code = generate(request).fetch('query_sql.rb') + assert_match(%r{# rubocop:disable Naming/PredicatePrefix\n\s+# Like #is_author, .*\n\s+def is_author!\(\.\.\.\)}, code) + out, status = rubocop(code, '--only', 'Naming') + assert status.success?, "#{driver}: #{out}" + end + end + def rubocop(source, *options) exe = begin Gem.bin_path('rubocop', 'rubocop') diff --git a/test/fixtures/mysql2.bin b/test/fixtures/mysql2.bin index 210c6fc..f52a0e6 100644 --- a/test/fixtures/mysql2.bin +++ b/test/fixtures/mysql2.bin @@ -661,4 +661,7 @@ NSELECT id, name, bio FROM authors WHERE id NOT IN (/*SLICE:ids*/?) ORDER BY id exceptionsf $SELECT COUNT(*) AS n FROM exceptionsCountExceptions:one" n0ÿÿÿÿÿÿÿÿÿ@bbigint: query.sqlA -TRUNCATE TABLE exceptionsTruncateExceptions:exec: query.sql"v1.27.0*T{"driver":"mysql2","generateGemfile":false,"generateTypes":true,"rubyVersion":"3.3"} \ No newline at end of file +TRUNCATE TABLE exceptionsTruncateExceptions:exec: query.sqlo +0SELECT EXISTS (SELECT 1 FROM authors) AS has_any +HasAuthors:one" +has_any0ÿÿÿÿÿÿÿÿÿbbool: 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 465941d..8ab1c49 100644 --- a/test/fixtures/pg.bin +++ b/test/fixtures/pg.bin @@ -726,4 +726,7 @@ $SELECT COUNT(*) AS n FROM exceptionsCountExceptions:one" moods 0ÿÿÿÿÿÿÿÿÿRlatefeelingsb  late.moodzmoodsˆ: query.sqlBlatefeelingsÒ -SELECT moods FROM late.feelings WHERE id = $1GetLateFeeling:one"@ moods 0ÿÿÿÿÿÿÿÿÿRlatefeelingsb latemoodzmoodsˆ*>: -id0ÿÿÿÿÿÿÿÿÿRlatefeelingsbpg_catalog.int4zid: query.sql"v1.27.0*P{"driver":"pg","generateGemfile":false,"generateTypes":true,"rubyVersion":"3.3"} \ No newline at end of file +id0ÿÿÿÿÿÿÿÿÿRlatefeelingsbpg_catalog.int4zid: query.sqlo +0SELECT EXISTS (SELECT 1 FROM authors) AS has_any +HasAuthors:one" +has_any0ÿÿÿÿÿÿÿÿÿbbool: query.sql"v1.27.0*P{"driver":"pg","generateGemfile":false,"generateTypes":true,"rubyVersion":"3.3"} \ No newline at end of file diff --git a/test/fixtures/sqlite3.bin b/test/fixtures/sqlite3.bin index 4bec56f6cdcb7189cdaa9a2ceb5859b9e927b6f2..86a2b9b38c4a5eaf5d940b3bdab1ca52537fb46d 100644 GIT binary patch delta 115 zcmcaMk@46h#tmg2sZx1d2EndAuFfF}t`VNWA;Ah7P_ChZTadr6LSku2Mt)JTrh;Rz yLPlb7d}3au5SK?{u_HuWip45FFI7pFiybN{!D#Rw1{jlArI<7GN+xG}WCH-2sVQ~< delta 13 VcmX>$iSgP*#tmg2lb?Ac001pz22lV2 diff --git a/test/golden/mysql2/default/query_sql.rb b/test/golden/mysql2/default/query_sql.rb index c7ce92d..3e810b6 100644 --- a/test/golden/mysql2/default/query_sql.rb +++ b/test/golden/mysql2/default/query_sql.rb @@ -490,6 +490,13 @@ class CountExceptionsRow < ::Data.define(:n) TRUNCATE TABLE exceptions SQL + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -787,14 +794,12 @@ def get_authors_by_ids_and_names(ids:, names:) # Bulk insert: pass an Array of CopyAuthorsArgs. def copy_authors(rows) with_client('copy_authors') do |client| - return if rows.empty? - ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.name, row.bio])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? end nil end @@ -942,14 +947,12 @@ def list_regressions_by_ids(id:, id_2:, id_3:) 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 - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? end nil end @@ -957,14 +960,12 @@ def copy_regressions(rows) def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| - return if rows.empty? - ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.order, row.note, row.payload])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? end nil end @@ -1031,14 +1032,12 @@ def truncate_regressions def copy_copy_types(rows) with_client('copy_copy_types') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? end nil end @@ -1046,14 +1045,12 @@ def copy_copy_types(rows) def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? end nil end @@ -1061,14 +1058,12 @@ def copy_copy_types_ignore(rows) def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? end nil end @@ -1344,6 +1339,23 @@ def truncate_exceptions end end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = client.query(bind_params(client, HAS_AUTHORS_SQL, []), as: :array) + row = result.first + row && HasAuthorsRow.new(row[0]) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ diff --git a/test/golden/mysql2/default/query_sql.rbs b/test/golden/mysql2/default/query_sql.rbs index 1d5e3d7..bc5da8b 100644 --- a/test/golden/mysql2/default/query_sql.rbs +++ b/test/golden/mysql2/default/query_sql.rbs @@ -531,6 +531,13 @@ module Mysql2Codegen TRUNCATE_EXCEPTIONS_SQL: ::String + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow + attr_reader has_any: untyped + def initialize: ( untyped has_any) -> void + end + MANY_PARAMS_SQL: ::String class ManyParamsRow @@ -722,6 +729,10 @@ module Mysql2Codegen def truncate_exceptions: -> void + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + 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 diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rb b/test/golden/mysql2/ruby31_gemfile/query_sql.rb index ce8e8e4..9a445d7 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rb @@ -483,6 +483,12 @@ module Mysql2Codegen TRUNCATE TABLE exceptions SQL + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + HasAuthorsRow = ::Struct.new(:has_any, keyword_init: true) + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -786,14 +792,12 @@ def get_authors_by_ids_and_names(ids:, names:) # Bulk insert: pass an Array of CopyAuthorsArgs. def copy_authors(rows) with_client('copy_authors') do |client| - return if rows.empty? - ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.name, row.bio])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? end nil end @@ -954,14 +958,12 @@ def list_regressions_by_ids(id:, id_2:, id_3:) 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 - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? end nil end @@ -969,14 +971,12 @@ def copy_regressions(rows) def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| - return if rows.empty? - ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.order, row.note, row.payload])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? end nil end @@ -1043,14 +1043,12 @@ def truncate_regressions def copy_copy_types(rows) with_client('copy_copy_types') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? end nil end @@ -1058,14 +1056,12 @@ def copy_copy_types(rows) def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? end nil end @@ -1073,14 +1069,12 @@ def copy_copy_types_ignore(rows) def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? end nil end @@ -1360,6 +1354,23 @@ def truncate_exceptions end end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = client.query(bind_params(client, HAS_AUTHORS_SQL, []), as: :array) + row = result.first + row && HasAuthorsRow.new(has_any: row[0]) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rbs b/test/golden/mysql2/ruby31_gemfile/query_sql.rbs index 88953a1..e7d0968 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rbs +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rbs @@ -661,6 +661,14 @@ module Mysql2Codegen TRUNCATE_EXCEPTIONS_SQL: ::String + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow < ::Struct[untyped] + attr_accessor has_any: untyped + def initialize: ( has_any: untyped) -> void + def self.new: ( has_any: untyped) -> HasAuthorsRow + end + MANY_PARAMS_SQL: ::String class ManyParamsRow < ::Struct[untyped] @@ -862,6 +870,10 @@ module Mysql2Codegen def truncate_exceptions: -> void + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + 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 diff --git a/test/golden/mysql2/ruby32_notypes/query_sql.rb b/test/golden/mysql2/ruby32_notypes/query_sql.rb index cc42278..9d053b2 100644 --- a/test/golden/mysql2/ruby32_notypes/query_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/query_sql.rb @@ -490,6 +490,13 @@ class CountExceptionsRow < ::Data.define(:n) TRUNCATE TABLE exceptions SQL + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -790,14 +797,12 @@ def get_authors_by_ids_and_names(ids:, names:) # Bulk insert: pass an Array of CopyAuthorsArgs. def copy_authors(rows) with_client('copy_authors') do |client| - return if rows.empty? - ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.name, row.bio])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? end nil end @@ -945,14 +950,12 @@ def list_regressions_by_ids(id:, id_2:, id_3:) 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 - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? end nil end @@ -960,14 +963,12 @@ def copy_regressions(rows) def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| - return if rows.empty? - ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.order, row.note, row.payload])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? end nil end @@ -1034,14 +1035,12 @@ def truncate_regressions def copy_copy_types(rows) with_client('copy_copy_types') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? end nil end @@ -1049,14 +1048,12 @@ def copy_copy_types(rows) def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? end nil end @@ -1064,14 +1061,12 @@ def copy_copy_types_ignore(rows) def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| - return if rows.empty? - ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| file.write(csv_line([row.id, row.code])) end file.flush - load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? end nil end @@ -1347,6 +1342,23 @@ def truncate_exceptions end end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = client.query(bind_params(client, HAS_AUTHORS_SQL, []), as: :array) + row = result.first + row && HasAuthorsRow.new(row[0]) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ diff --git a/test/golden/pg/default/my_queries_sql.rb b/test/golden/pg/default/my_queries_sql.rb index 4fd107a..5cb5d4a 100644 --- a/test/golden/pg/default/my_queries_sql.rb +++ b/test/golden/pg/default/my_queries_sql.rb @@ -166,20 +166,30 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end end end diff --git a/test/golden/pg/default/my_queries_sql.rbs b/test/golden/pg/default/my_queries_sql.rbs index 7680864..d6bdda8 100644 --- a/test/golden/pg/default/my_queries_sql.rbs +++ b/test/golden/pg/default/my_queries_sql.rbs @@ -27,5 +27,6 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void end end diff --git a/test/golden/pg/default/other_sql.rb b/test/golden/pg/default/other_sql.rb index 564d872..83270ab 100644 --- a/test/golden/pg/default/other_sql.rb +++ b/test/golden/pg/default/other_sql.rb @@ -206,22 +206,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + 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) diff --git a/test/golden/pg/default/other_sql.rbs b/test/golden/pg/default/other_sql.rbs index fd9fbc8..c50ee4b 100644 --- a/test/golden/pg/default/other_sql.rbs +++ b/test/golden/pg/default/other_sql.rbs @@ -42,6 +42,7 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void def encode_bytea: (::String? value) -> ::String? diff --git a/test/golden/pg/default/query_sql.rb b/test/golden/pg/default/query_sql.rb index 67248e9..66d07bf 100644 --- a/test/golden/pg/default/query_sql.rb +++ b/test/golden/pg/default/query_sql.rb @@ -581,6 +581,13 @@ class CountExceptionsRow < ::Data.define(:n) class GetLateFeelingRow < ::Data.define(:moods) end + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -1557,6 +1564,23 @@ def get_late_feeling!(...) get_late_feeling(...) || raise(NotFound, 'GetLateFeeling returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = run_prepared(client, 'PgCodegen::QuerySql#has_authors', HAS_AUTHORS_SQL) + row = result.first + row && HasAuthorsRow.new(row['has_any']) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -1664,22 +1688,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + 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) diff --git a/test/golden/pg/default/query_sql.rbs b/test/golden/pg/default/query_sql.rbs index 024b862..9e8bea0 100644 --- a/test/golden/pg/default/query_sql.rbs +++ b/test/golden/pg/default/query_sql.rbs @@ -603,6 +603,13 @@ module PgCodegen def initialize: ( ::Array[::String] moods) -> void end + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow + attr_reader has_any: bool + def initialize: ( bool has_any) -> void + end + MANY_PARAMS_SQL: ::String class ManyParamsRow @@ -828,6 +835,10 @@ module PgCodegen def get_late_feeling!: (id: ::Integer) -> GetLateFeelingRow + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + 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 @@ -849,6 +860,7 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void def encode_bytea: (::String? value) -> ::String? diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb index 636d2c0..c842801 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb @@ -170,22 +170,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end + def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs b/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs index 1a9641c..d7dd1af 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rbs @@ -27,6 +27,7 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void def timeout_exits_with_throw?: () -> bool end diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rb b/test/golden/pg/ruby31_gemfile/other_sql.rb index 1375a0d..fd958ca 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rb +++ b/test/golden/pg/ruby31_gemfile/other_sql.rb @@ -209,22 +209,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end + def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rbs b/test/golden/pg/ruby31_gemfile/other_sql.rbs index 4461836..6f04f1c 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/other_sql.rbs @@ -43,6 +43,7 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void def timeout_exits_with_throw?: () -> bool diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index dde9022..454dfcc 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -566,6 +566,12 @@ module PgCodegen GetLateFeelingRow = ::Struct.new(:moods, keyword_init: true) + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + HasAuthorsRow = ::Struct.new(:has_any, keyword_init: true) + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -1566,6 +1572,23 @@ def get_late_feeling!(...) get_late_feeling(...) || raise(NotFound, 'GetLateFeeling returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = run_prepared(client, 'PgCodegen::QuerySql#has_authors', HAS_AUTHORS_SQL) + row = result.first + row && HasAuthorsRow.new(has_any: row['has_any']) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -1673,22 +1696,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end + def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rbs b/test/golden/pg/ruby31_gemfile/query_sql.rbs index 6386ee1..246b77a 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/query_sql.rbs @@ -761,6 +761,14 @@ module PgCodegen def self.new: ( moods: ::Array[::String]) -> GetLateFeelingRow end + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow < ::Struct[untyped] + attr_accessor has_any: bool + def initialize: ( has_any: bool) -> void + def self.new: ( has_any: bool) -> HasAuthorsRow + end + MANY_PARAMS_SQL: ::String class ManyParamsRow < ::Struct[untyped] @@ -996,6 +1004,10 @@ module PgCodegen def get_late_feeling!: (id: ::Integer) -> GetLateFeelingRow + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + 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 @@ -1017,6 +1029,7 @@ module PgCodegen def string_arrays: (untyped client) -> untyped def run_prepared: (untyped client, ::String name, ::String sql, ?::Array[untyped] params) -> untyped + def prepare: (untyped client, ::String name, ::String sql) -> void def timeout_exits_with_throw?: () -> bool diff --git a/test/golden/pg/ruby32_notypes/my_queries_sql.rb b/test/golden/pg/ruby32_notypes/my_queries_sql.rb index ddbf24c..a4078f5 100644 --- a/test/golden/pg/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/pg/ruby32_notypes/my_queries_sql.rb @@ -169,22 +169,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end + def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) diff --git a/test/golden/pg/ruby32_notypes/other_sql.rb b/test/golden/pg/ruby32_notypes/other_sql.rb index 5b7fd27..cc9c27c 100644 --- a/test/golden/pg/ruby32_notypes/other_sql.rb +++ b/test/golden/pg/ruby32_notypes/other_sql.rb @@ -209,22 +209,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end + def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index 2ba17b4..ea9007c 100644 --- a/test/golden/pg/ruby32_notypes/query_sql.rb +++ b/test/golden/pg/ruby32_notypes/query_sql.rb @@ -581,6 +581,13 @@ class CountExceptionsRow < ::Data.define(:n) class GetLateFeelingRow < ::Data.define(:moods) end + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -1560,6 +1567,23 @@ def get_late_feeling!(...) get_late_feeling(...) || raise(NotFound, 'GetLateFeeling returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + result = run_prepared(client, 'PgCodegen::QuerySql#has_authors', HAS_AUTHORS_SQL) + row = result.first + row && HasAuthorsRow.new(row['has_any']) + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -1667,22 +1691,32 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # prepared again; inside a transaction the error has already aborted it, so it is raised. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used; inside a + # transaction the error has already aborted it, so it is raised. def run_prepared(client, name, sql, params = []) - unless client.prepared_statements.include?(name) - client.prepare(name, sql) - client.prepared_statements.add(name) - end + prepare(client, name, sql) unless client.prepared_statements.include?(name) client.exec_prepared(name, params) rescue ::PG::InvalidSqlStatementName client.prepared_statements.delete(name) raise unless client.transaction_status == ::PG::PQTRANS_IDLE - client.prepare(name, sql) - client.prepared_statements.add(name) + prepare(client, name, sql) client.exec_prepared(name, params) end + # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself + # (a stopped Async task) can still leave the statement prepared on the server: it is recorded when PREPARE fails + # because the statement exists. + def prepare(client, name, sql) + ::Thread.handle_interrupt(::Object => :never) do + client.prepare(name, sql) + client.prepared_statements.add(name) + rescue ::PG::DuplicatePstatement + client.prepared_statements.add(name) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end + def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) diff --git a/test/golden/sqlite3/default/query_sql.rb b/test/golden/sqlite3/default/query_sql.rb index 656152a..6e3e811 100644 --- a/test/golden/sqlite3/default/query_sql.rb +++ b/test/golden/sqlite3/default/query_sql.rb @@ -421,6 +421,13 @@ class CreateTagResultRow < ::Data.define(:name) class CountExceptionsRow < ::Data.define(:n) end + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -932,8 +939,8 @@ def get_declared_types row[2], row[3], row[4], - row[5], - row[6] + row[5]&.to_s, + row[6]&.to_s ) ensure stmt.close @@ -1381,6 +1388,28 @@ def count_exceptions!(...) count_exceptions(...) || raise(NotFound, 'CountExceptions returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + stmt = client.prepare(HAS_AUTHORS_SQL) + begin + result = execute_statement(stmt) + row = result.first + row && HasAuthorsRow.new(row[0]) + ensure + stmt.close + end + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ diff --git a/test/golden/sqlite3/default/query_sql.rbs b/test/golden/sqlite3/default/query_sql.rbs index 3894f2b..ee394fe 100644 --- a/test/golden/sqlite3/default/query_sql.rbs +++ b/test/golden/sqlite3/default/query_sql.rbs @@ -417,6 +417,13 @@ module Sqlite3Codegen def initialize: ( ::Integer n) -> void end + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow + attr_reader has_any: ::Integer + def initialize: ( ::Integer has_any) -> void + end + MANY_PARAMS_SQL: ::String class ManyParamsRow @@ -592,6 +599,10 @@ module Sqlite3Codegen def count_exceptions!: -> CountExceptionsRow + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + 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 diff --git a/test/golden/sqlite3/ruby31_gemfile/query_sql.rb b/test/golden/sqlite3/ruby31_gemfile/query_sql.rb index 64a2879..5b6fb29 100644 --- a/test/golden/sqlite3/ruby31_gemfile/query_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/query_sql.rb @@ -398,6 +398,12 @@ module Sqlite3Codegen CountExceptionsRow = ::Struct.new(:n, keyword_init: true) + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + HasAuthorsRow = ::Struct.new(:has_any, keyword_init: true) + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -923,8 +929,8 @@ def get_declared_types c_unsigned_big_int: row[2], c_native: row[3], c_double: row[4], - c_json: row[5], - c_uuid: row[6] + c_json: row[5]&.to_s, + c_uuid: row[6]&.to_s ) ensure stmt.close @@ -1374,6 +1380,28 @@ def count_exceptions!(...) count_exceptions(...) || raise(NotFound, 'CountExceptions returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + stmt = client.prepare(HAS_AUTHORS_SQL) + begin + result = execute_statement(stmt) + row = result.first + row && HasAuthorsRow.new(has_any: row[0]) + ensure + stmt.close + end + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ diff --git a/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs b/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs index b93ab43..0ccc023 100644 --- a/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs +++ b/test/golden/sqlite3/ruby31_gemfile/query_sql.rbs @@ -489,6 +489,14 @@ module Sqlite3Codegen def self.new: ( n: ::Integer) -> CountExceptionsRow end + HAS_AUTHORS_SQL: ::String + + class HasAuthorsRow < ::Struct[untyped] + attr_accessor has_any: ::Integer + def initialize: ( has_any: ::Integer) -> void + def self.new: ( has_any: ::Integer) -> HasAuthorsRow + end + MANY_PARAMS_SQL: ::String class ManyParamsRow < ::Struct[untyped] @@ -674,6 +682,10 @@ module Sqlite3Codegen def count_exceptions!: -> CountExceptionsRow + def has_authors: -> HasAuthorsRow? + + def has_authors!: -> HasAuthorsRow + 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 diff --git a/test/golden/sqlite3/ruby32_notypes/query_sql.rb b/test/golden/sqlite3/ruby32_notypes/query_sql.rb index 4652633..fe0dc10 100644 --- a/test/golden/sqlite3/ruby32_notypes/query_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/query_sql.rb @@ -421,6 +421,13 @@ class CreateTagResultRow < ::Data.define(:name) class CountExceptionsRow < ::Data.define(:n) end + HAS_AUTHORS_SQL = <<~'SQL' + SELECT EXISTS (SELECT 1 FROM authors) AS has_any + SQL + + class HasAuthorsRow < ::Data.define(:has_any) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -935,8 +942,8 @@ def get_declared_types row[2], row[3], row[4], - row[5], - row[6] + row[5]&.to_s, + row[6]&.to_s ) ensure stmt.close @@ -1384,6 +1391,28 @@ def count_exceptions!(...) count_exceptions(...) || raise(NotFound, 'CountExceptions returned no row') end + # rubocop:disable Naming/PredicatePrefix + def has_authors + with_client('has_authors') do |client| + stmt = client.prepare(HAS_AUTHORS_SQL) + begin + result = execute_statement(stmt) + row = result.first + row && HasAuthorsRow.new(row[0]) + ensure + stmt.close + end + end + end + # rubocop:enable Naming/PredicatePrefix + + # rubocop:disable Naming/PredicatePrefix + # Like #has_authors, but raises NotFound when there is no row. + def has_authors!(...) + has_authors(...) || raise(NotFound, 'HasAuthors returned no row') + end + # rubocop:enable Naming/PredicatePrefix + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ diff --git a/tests/end2end_mysql2.rb b/tests/end2end_mysql2.rb index 2e6d056..6ce5f4f 100755 --- a/tests/end2end_mysql2.rb +++ b/tests/end2end_mysql2.rb @@ -263,6 +263,21 @@ def test_copyfrom_many_rows_and_none assert_equal 2500, @query_sql.list_authors.size end + def test_copyfrom_takes_any_enumerable + @query_sql.copy_authors([].lazy) + assert_empty @query_sql.list_authors + @query_sql.copy_authors((1..3).lazy.map { |i| CopyAuthorsArgs.new(name: "lazy #{i}", bio: nil) }) + @query_sql.copy_authors(::Enumerator.new { |rows| rows << CopyAuthorsArgs.new(name: 'streamed', bio: nil) }) + assert_equal ['lazy 1', 'lazy 2', 'lazy 3', 'streamed'], @query_sql.list_authors.map(&:name).sort + end + + # HasAuthors -> has_authors: the query's own name, RuboCop's Naming/PredicatePrefix is disabled for it + def test_predicate_like_query_names_are_kept + assert_equal 0, @query_sql.has_authors!.has_any + create_author('someone') + assert_equal 1, @query_sql.has_authors.has_any + end + # ---- sqlc.embed ------------------------------------------------------------------------------------------------ def test_embed diff --git a/tests/end2end_pg.rb b/tests/end2end_pg.rb index b3c5d3b..a5b89cc 100755 --- a/tests/end2end_pg.rb +++ b/tests/end2end_pg.rb @@ -233,6 +233,21 @@ def test_copyfrom_many_rows_and_none assert_equal 2500, @query_sql.list_authors.size end + def test_copyfrom_takes_any_enumerable + @query_sql.copy_authors([].lazy) + assert_empty @query_sql.list_authors + @query_sql.copy_authors((1..3).lazy.map { |i| CopyAuthorsArgs.new(name: "lazy #{i}", bio: nil) }) + @query_sql.copy_authors(::Enumerator.new { |rows| rows << CopyAuthorsArgs.new(name: 'streamed', bio: nil) }) + assert_equal ['lazy 1', 'lazy 2', 'lazy 3', 'streamed'], @query_sql.list_authors.map(&:name).sort + end + + # HasAuthors -> has_authors: the query's own name, RuboCop's Naming/PredicatePrefix is disabled for it + def test_predicate_like_query_names_are_kept + assert_equal false, @query_sql.has_authors!.has_any + create_author('someone') + assert_equal true, @query_sql.has_authors.has_any + end + # ---- sqlc.embed ------------------------------------------------------------------------------------------------ def test_embed @@ -1198,4 +1213,49 @@ def test_statements_are_prepared_again_after_deallocate_all ensure client&.close end + + # A connection whose next PREPAREs run the given procs after the server prepared the statement + def client_with_hooks_after_prepare(hooks) + client = PG.connect(**pg_params) + client.singleton_class.prepend(Module.new do + define_method(:prepare) { |*args| super(*args).tap { hooks.shift&.call } } + end) + client + end + + def test_an_interrupt_during_prepare_leaves_the_statement_usable + skip 'prepared statements are disabled' if ENV['PREPARED_STATEMENTS'] == 'false' + prepared = ::Queue.new + client = client_with_hooks_after_prepare([-> { prepared << true; sleep 1 }]) # rubocop:disable Style/Semicolon + query_sql = QuerySql.new(::ConnectionPool.new(size: 1) { client }) + create_author('kept') + worker = ::Thread.new { query_sql.list_authors } + worker.report_on_exception = false + prepared.pop + worker.raise(::Timeout::Error, 'interrupted while preparing') + assert_raises(::Timeout::Error) { worker.join } + assert_equal ['kept'], query_sql.list_authors.map(&:name) + ensure + client&.close + end + + # an exception raised by PREPARE itself (a stopped Async task) after the server prepared the statement + def test_a_statement_left_prepared_by_a_failed_prepare_is_used + skip 'prepared statements are disabled' if ENV['PREPARED_STATEMENTS'] == 'false' + stopped = ::Class.new(::StandardError) + hooks = [-> { raise stopped }] + client = client_with_hooks_after_prepare(hooks) + query_sql = QuerySql.new(::ConnectionPool.new(size: 1) { client }) + create_author('kept') + assert_raises(stopped) { query_sql.list_authors } + assert_equal ['kept'], query_sql.list_authors.map(&:name) + # inside a transaction the failed PREPARE has aborted it: it rolls back, later calls use the statement + hooks << -> { raise stopped } + assert_raises(stopped) { query_sql.has_authors } + assert_raises(PG::DuplicatePstatement) { query_sql.transaction(&:has_authors) } + assert query_sql.transaction(&:has_authors!).has_any + assert query_sql.has_authors!.has_any + ensure + client&.close + end end diff --git a/tests/end2end_sqlite3.rb b/tests/end2end_sqlite3.rb index 79fc0e0..4f51682 100644 --- a/tests/end2end_sqlite3.rb +++ b/tests/end2end_sqlite3.rb @@ -153,6 +153,19 @@ def test_declared_types_with_parameters assert_empty RbsTypes.mismatches(row, File.join(EXAMPLES_DIR, 'sqlite3/query_sql.rbs'), 'GetDeclaredTypesRow') end + # JSON / UUID columns have NUMERIC affinity: SQLite stores numeric-looking text as a number + def test_json_and_uuid_columns_return_strings_that_look_numeric + @query_sql.insert_declared_types(c_varchar: 'v', c_decimal: 1.5, c_unsigned_big_int: 7, c_native: 'n', c_double: 2.5, + c_json: '42', c_uuid: '12345678') + row = @query_sql.get_declared_types + assert_equal %w[42 12345678], [row.c_json, row.c_uuid] + assert_empty RbsTypes.mismatches(row, File.join(EXAMPLES_DIR, 'sqlite3/query_sql.rbs'), 'GetDeclaredTypesRow') + @query_sql.truncate_declared_types + @query_sql.insert_declared_types(c_varchar: 'v', c_decimal: 1.5, c_unsigned_big_int: 7, c_native: 'n', c_double: 2.5, + c_json: '1.5', c_uuid: '-7') + assert_equal %w[1.5 -7], @query_sql.get_declared_types.then { |r| [r.c_json, r.c_uuid] } + end + def test_execrows_returns_affected_row_count create_author('with bio', 'bio') create_author('with bio 2', 'bio') @@ -296,6 +309,21 @@ def test_copyfrom_is_batched_below_the_bound_parameter_limit assert_equal 2500, @query_sql.list_authors.size end + def test_copyfrom_takes_any_enumerable + @query_sql.copy_authors([].lazy) + assert_empty @query_sql.list_authors + @query_sql.copy_authors((1..3).lazy.map { |i| CopyAuthorsArgs.new(name: "lazy #{i}", bio: nil) }) + @query_sql.copy_authors(::Enumerator.new { |rows| rows << CopyAuthorsArgs.new(name: 'streamed', bio: nil) }) + assert_equal ['lazy 1', 'lazy 2', 'lazy 3', 'streamed'], @query_sql.list_authors.map(&:name).sort + end + + # HasAuthors -> has_authors: the query's own name, RuboCop's Naming/PredicatePrefix is disabled for it + def test_predicate_like_query_names_are_kept + assert_equal 0, @query_sql.has_authors!.has_any + create_author('someone') + assert_equal 1, @query_sql.has_authors.has_any + end + # ---- sqlc.embed ------------------------------------------------------------------------------------------------ def test_embed