From f7d30a65cb00cc3df0e681bc4743e4901d0de0b2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:12:48 +0000 Subject: [PATCH] Cancel pg queries interrupted by Thread#kill, re-prepare after column type changes, keep timestamp instants Co-Authored-By: Doron Eli Rachman --- README.md | 8 +- docs/Coverage.md | 6 +- examples/pg/connection.rb | 8 + examples/pg/connection.rbs | 3 + examples/pg/query_sql.rb | 53 ++++-- examples/pg/query_sql.rbs | 2 + lib/sqlc_gen_ruby/drivers/pg.rb | 64 +++++-- steep_expectations.yml | 164 ++++++++++++------ test/golden/pg/default/connection.rb | 8 + test/golden/pg/default/connection.rbs | 3 + test/golden/pg/default/my_queries_sql.rb | 45 +++-- test/golden/pg/default/other_sql.rb | 45 +++-- test/golden/pg/default/query_sql.rb | 53 ++++-- test/golden/pg/default/query_sql.rbs | 2 + test/golden/pg/ruby31_gemfile/connection.rb | 8 + test/golden/pg/ruby31_gemfile/connection.rbs | 3 + .../pg/ruby31_gemfile/my_queries_sql.rb | 45 +++-- test/golden/pg/ruby31_gemfile/other_sql.rb | 45 +++-- test/golden/pg/ruby31_gemfile/query_sql.rb | 53 ++++-- test/golden/pg/ruby31_gemfile/query_sql.rbs | 2 + test/golden/pg/ruby32_notypes/connection.rb | 8 + .../pg/ruby32_notypes/my_queries_sql.rb | 45 +++-- test/golden/pg/ruby32_notypes/other_sql.rb | 45 +++-- test/golden/pg/ruby32_notypes/query_sql.rb | 53 ++++-- tests/end2end_pg.rb | 84 ++++++++- 25 files changed, 646 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index faca082..eddf2d3 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ More: [the generated code for every driver](docs/Examples.md) (it is the plugin' | `overrides` | none | Change the type of columns / parameters, see [below](#overrides). | | `rename` | none | Rename tables (model class) and columns (attribute), e.g. `{authors: Writer, bio: biography}`; `schema.table` works too. | | `exactTableNames` | `false` | Keep table names as model names (`authors` → `Authors`) instead of singularizing them (`Author`). | -| `preparedStatements` | `true` | `pg` only (an error with other drivers). `false` sends the SQL with every call instead of preparing it (needed behind pgbouncer in transaction mode). Statements dropped by `DEALLOCATE ALL` / `DISCARD ALL` are prepared again by their next call outside a transaction (inside one, that call raises and the transaction rolls back). Statements are named `Module::Class#method` (with a hash when longer than PostgreSQL's 63 bytes), so classes sharing a pool never run each other's SQL. | +| `preparedStatements` | `true` | `pg` only (an error with other drivers). `false` sends the SQL with every call instead of preparing it (needed behind pgbouncer in transaction mode). Statements dropped by `DEALLOCATE ALL` / `DISCARD ALL`, and statements PostgreSQL refuses after a migration changed the type of a column they return (`PG::FeatureNotSupported`: cached plan must not change result type), are prepared again by their next call outside a transaction (inside one, that call raises and the transaction rolls back; the next call prepares the statement again). Statements are named `Module::Class#method` (with a hash when longer than PostgreSQL's 63 bytes), so classes sharing a pool never run each other's SQL. | Unknown or misspelled options are errors, with a hint for near misses (`generate_types` → `generateTypes`). Options shared by several `sql:` blocks can go in the top-level `options:` of `sqlc.yaml` under the plugin's name @@ -125,6 +125,8 @@ end and rolls back when it raises or the thread is killed. An interrupt (`Timeout`, `Thread#raise`, `Thread#kill`) that arrives while `BEGIN`, `COMMIT` or `ROLLBACK` runs waits for it to finish, and a `BEGIN` / `COMMIT` that raises itself (a stopped `Async` task) is rolled back: the connection never goes back to the pool inside a transaction. + On PostgreSQL a query interrupted by any of them (inside or outside a transaction) is cancelled at once, so the + connection goes back to the pool idle and a killed transaction rolls back without waiting for its query. On MySQL a `Thread#kill` during a query closes that client (mysql2 cannot send `ROLLBACK` while it waits for the query's result), so the server rolls the transaction back and the pool replaces the client. On Ruby <3.3 with timeout <0.4, `break`, `return`, and `throw` also roll back because Timeout cannot be told apart; add `gem 'timeout', '>= 0.4'` to the generated Gemfile @@ -195,6 +197,10 @@ server; duplicate keys and invalid values raise `Mysql2::Error` instead of being in the zone `mysql2` reads `DATETIME` / `TIMESTAMP` values back in, the process's local zone, or UTC for a client created with `database_timezone: :utc` (`connect(params.merge(database_timezone: :utc))`). The server also converts `TIMESTAMP` columns with the session `time_zone`, so keep that equal to the client's zone. +* PostgreSQL: a `Time` parameter of a `timestamp` (without time zone) column (also in `:copyfrom` and batches) keeps + its instant whatever its UTC offset: it is written in the process's local zone, the zone the pg gem reads + `timestamp` values back in (PostgreSQL itself ignores the offset of a `timestamp` value). Elements of `timestamp[]` + arrays are written as given. `timestamptz` keeps the instant anyway. * A `String` passed to a PostgreSQL `json` / `jsonb` parameter is JSON text (`'{"a":1}'`): pass `'"hi"'` or `'hi'.to_json` for a JSON string. Hashes, Arrays, numbers, `true` / `false` and `nil` are encoded for you. * Enums are `String`s, with constants in `models.rb` (`ExtendedBioType::MEMOIR`, `ExtendedBioType::ALL`). Values that diff --git a/docs/Coverage.md b/docs/Coverage.md index c6098aa..b1d6319 100644 --- a/docs/Coverage.md +++ b/docs/Coverage.md @@ -17,7 +17,7 @@ only when the behavior is rejected or decided during generation and generated co | `sqlc.embed`, outer-join nil, same table embedded twice | [`test_embed`](../tests/end2end_pg.rb#test_embed), [`test_embed_on_the_outer_side_of_a_join_is_nil_without_a_match`](../tests/end2end_pg.rb#test_embed_on_the_outer_side_of_a_join_is_nil_without_a_match), [`test_same_table_embedded_twice`](../tests/end2end_pg.rb#test_same_table_embedded_twice) | [`test_embed`](../tests/end2end_mysql2.rb#test_embed), [`test_outer_join_embed_is_nil_without_a_match`](../tests/end2end_mysql2.rb#test_outer_join_embed_is_nil_without_a_match), [`test_same_table_embedded_twice`](../tests/end2end_mysql2.rb#test_same_table_embedded_twice) | [`test_embed`](../tests/end2end_sqlite3.rb#test_embed), [`test_outer_join_embed_is_nil_without_a_match`](../tests/end2end_sqlite3.rb#test_outer_join_embed_is_nil_without_a_match), [`test_same_table_embedded_twice`](../tests/end2end_sqlite3.rb#test_same_table_embedded_twice) | [`test_embedded_tables_become_model_attributes`](../test/features_test.rb#test_embedded_tables_become_model_attributes) | | Frozen Ruby 3.2+ `Data`, mutable older `Struct`, `with`, `to_h`, pattern matching | [`test_rows_and_embedded_models_have_supported_layout_and_pattern_matching`](../tests/end2end_pg.rb#test_rows_and_embedded_models_have_supported_layout_and_pattern_matching) | [`test_rows_and_embedded_models_have_supported_layout_and_pattern_matching`](../tests/end2end_mysql2.rb#test_rows_and_embedded_models_have_supported_layout_and_pattern_matching) | [`test_rows_and_embedded_models_have_supported_layout_and_pattern_matching`](../tests/end2end_sqlite3.rb#test_rows_and_embedded_models_have_supported_layout_and_pattern_matching) | [`test_struct_classes_are_valid_and_usable`](../test/features_test.rb#test_struct_classes_are_valid_and_usable), [`test_data_classes_are_usable`](../test/features_test.rb#test_data_classes_are_usable) | | Enums, constants (also for keyword / invalid names), database labels and MySQL `SET` | [`test_enum_column`](../tests/end2end_pg.rb#test_enum_column), [`test_enum_values_named_like_ruby_keywords_get_valid_constants`](../tests/end2end_pg.rb#test_enum_values_named_like_ruby_keywords_get_valid_constants) | [`test_enum_and_set_columns`](../tests/end2end_mysql2.rb#test_enum_and_set_columns), [`test_enum_values_named_like_ruby_keywords_get_valid_constants`](../tests/end2end_mysql2.rb#test_enum_values_named_like_ruby_keywords_get_valid_constants) | not supported by the SQLite schema | [`test_models_cover_tables_and_enums`](../test/features_test.rb#test_models_cover_tables_and_enums) | -| PostgreSQL arrays, JSON/JSONB, type round trips and RBS | [`test_array_parameters`](../tests/end2end_pg.rb#test_array_parameters), [`test_remaining_postgresql_types_round_trip`](../tests/end2end_pg.rb#test_remaining_postgresql_types_round_trip), [`test_json_and_jsonb_parameters_round_trip_all_json_value_types`](../tests/end2end_pg.rb#test_json_and_jsonb_parameters_round_trip_all_json_value_types) | not PostgreSQL types | not PostgreSQL types | [`test_multi_dimensional_arrays`](../test/features_test.rb#test_multi_dimensional_arrays), [`test_more_postgres_types_composites_and_domains`](../test/features_test.rb#test_more_postgres_types_composites_and_domains) | +| PostgreSQL arrays, JSON/JSONB, type round trips and RBS; a `Time` keeps its instant in a `timestamp` column whatever its offset | [`test_array_parameters`](../tests/end2end_pg.rb#test_array_parameters), [`test_remaining_postgresql_types_round_trip`](../tests/end2end_pg.rb#test_remaining_postgresql_types_round_trip), [`test_timestamp_parameters_keep_their_instant_whatever_their_offset`](../tests/end2end_pg.rb#test_timestamp_parameters_keep_their_instant_whatever_their_offset), [`test_json_and_jsonb_parameters_round_trip_all_json_value_types`](../tests/end2end_pg.rb#test_json_and_jsonb_parameters_round_trip_all_json_value_types) | not PostgreSQL types | not PostgreSQL types | [`test_multi_dimensional_arrays`](../test/features_test.rb#test_multi_dimensional_arrays), [`test_more_postgres_types_composites_and_domains`](../test/features_test.rb#test_more_postgres_types_composites_and_domains) | | MySQL unsigned integers, BIT, YEAR, DATETIME/TIME (a `Time` keeps its instant in any zone, also in `:copyfrom`), JSON, enums/SET and client quoting | not MySQL | [`test_client_side_quoting_round_trips_supported_values`](../tests/end2end_mysql2.rb#test_client_side_quoting_round_trips_supported_values), [`test_time_values_keep_their_instant_whatever_their_offset`](../tests/end2end_mysql2.rb#test_time_values_keep_their_instant_whatever_their_offset), [`test_time_values_follow_a_utc_database_timezone`](../tests/end2end_mysql2.rb#test_time_values_follow_a_utc_database_timezone), [`test_declared_types_match_runtime_values`](../tests/end2end_mysql2.rb#test_declared_types_match_runtime_values) | not MySQL | [`test_mysql_uses_client_side_binding_and_sqlite_closes_prepared_statements`](../test/features_test.rb#test_mysql_uses_client_side_binding_and_sqlite_closes_prepared_statements) | | SQLite affinity, JSON/UUID, and NUMERIC integer/float results | not SQLite | not SQLite | [`test_declared_types_match_runtime_values`](../tests/end2end_sqlite3.rb#test_declared_types_match_runtime_values), [`test_declared_types_with_parameters`](../tests/end2end_sqlite3.rb#test_declared_types_with_parameters) | [`test_sqlite_declared_types_follow_type_affinity`](../test/features_test.rb#test_sqlite_declared_types_follow_type_affinity), [`test_sqlite_json_and_uuid_declarations_are_strings`](../test/features_test.rb#test_sqlite_json_and_uuid_declarations_are_strings) | | SQLite `busy_handler_timeout=` waits for another connection without blocking other threads | not SQLite | not SQLite | [`test_busy_handler_timeout_waits_for_another_connection_without_blocking_threads`](../tests/end2end_sqlite3.rb#test_busy_handler_timeout_waits_for_another_connection_without_blocking_threads) | | @@ -41,8 +41,8 @@ CI runs `test/coverage_map_test.rb`, which checks every linked test method exist | Transaction `tx` used from an `Enumerator`, `Fiber`, `Thread`, `Async` task and nested savepoint; `TransactionEnded` after the transaction | [`test_transaction_query_object_works_from_other_fibers_and_threads`](../tests/end2end_pg.rb#test_transaction_query_object_works_from_other_fibers_and_threads) | [`test_transaction_query_object_works_from_other_fibers_and_threads`](../tests/end2end_mysql2.rb#test_transaction_query_object_works_from_other_fibers_and_threads) | [`test_transaction_query_object_works_from_other_fibers_and_threads`](../tests/end2end_sqlite3.rb#test_transaction_query_object_works_from_other_fibers_and_threads) | | | PostgreSQL arrays of enums created after the connection was opened | [`test_enum_arrays_of_a_type_created_after_connecting_are_decoded`](../tests/end2end_pg.rb#test_enum_arrays_of_a_type_created_after_connecting_are_decoded) | not MySQL | not SQLite | | | 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) | | -| `Thread#kill` inside a transaction rolls back; on MySQL a kill during a query replaces the client and ends its transaction | [`test_thread_kill_rolls_back_and_leaves_connection_idle`](../tests/end2end_pg.rb#test_thread_kill_rolls_back_and_leaves_connection_idle) | [`test_thread_kill_rolls_back_and_leaves_connection_outside_transaction`](../tests/end2end_mysql2.rb#test_thread_kill_rolls_back_and_leaves_connection_outside_transaction), [`test_thread_kill_during_a_query_replaces_the_connection_and_ends_its_transaction`](../tests/end2end_mysql2.rb#test_thread_kill_during_a_query_replaces_the_connection_and_ends_its_transaction) | [`test_thread_kill_rolls_back_and_leaves_connection_inactive`](../tests/end2end_sqlite3.rb#test_thread_kill_rolls_back_and_leaves_connection_inactive) | | -| 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) | +| `Thread#kill` inside a transaction rolls back; on PostgreSQL a kill during a query (inside or outside a transaction) cancels it at once and leaves the connection idle; on MySQL a kill during a query replaces the client and ends its transaction | [`test_thread_kill_rolls_back_and_leaves_connection_idle`](../tests/end2end_pg.rb#test_thread_kill_rolls_back_and_leaves_connection_idle), [`test_thread_kill_during_a_query_cancels_it_and_leaves_the_connection_idle`](../tests/end2end_pg.rb#test_thread_kill_during_a_query_cancels_it_and_leaves_the_connection_idle) | [`test_thread_kill_rolls_back_and_leaves_connection_outside_transaction`](../tests/end2end_mysql2.rb#test_thread_kill_rolls_back_and_leaves_connection_outside_transaction), [`test_thread_kill_during_a_query_replaces_the_connection_and_ends_its_transaction`](../tests/end2end_mysql2.rb#test_thread_kill_during_a_query_replaces_the_connection_and_ends_its_transaction) | [`test_thread_kill_rolls_back_and_leaves_connection_inactive`](../tests/end2end_sqlite3.rb#test_thread_kill_rolls_back_and_leaves_connection_inactive) | | +| PostgreSQL statements dropped by `DEALLOCATE ALL` / `DISCARD ALL`, or refused after a column type change (cached plan must not change result type), are prepared again | [`test_statements_are_prepared_again_after_deallocate_all`](../tests/end2end_pg.rb#test_statements_are_prepared_again_after_deallocate_all), [`test_statements_are_prepared_again_after_a_column_type_change`](../tests/end2end_pg.rb#test_statements_are_prepared_again_after_a_column_type_change) | 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` keeps the case of quoted / mixed-case table and column names (MySQL `LOAD DATA`) | [`test_copyfrom_keeps_the_case_of_table_and_column_names`](../tests/end2end_pg.rb#test_copyfrom_keeps_the_case_of_table_and_column_names) | [`test_copyfrom_keeps_the_case_of_table_and_column_names`](../tests/end2end_mysql2.rb#test_copyfrom_keeps_the_case_of_table_and_column_names) | [`test_copyfrom_keeps_the_case_of_table_and_column_names`](../tests/end2end_sqlite3.rb#test_copyfrom_keeps_the_case_of_table_and_column_names) | | | `: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) | | diff --git a/examples/pg/connection.rb b/examples/pg/connection.rb index 9a0b2bb..3a2a08e 100644 --- a/examples/pg/connection.rb +++ b/examples/pg/connection.rb @@ -10,11 +10,19 @@ module ConnectionState def prepared_statements if @prepared_statements_pid != backend_pid @prepared_statements = ::Set[] + @stale_statements = ::Set[] @prepared_statements_pid = backend_pid end @prepared_statements end + # Statements whose result type changed under them (ALTER TABLE ... TYPE in a migration): deallocated before + # they are prepared again. + def stale_statements + prepared_statements + @stale_statements + end + # Set inside #transaction: a lost connection is then not reconnected until the transaction block ends. attr_accessor :in_transaction end diff --git a/examples/pg/connection.rbs b/examples/pg/connection.rbs index b51a59e..f96bbee 100644 --- a/examples/pg/connection.rbs +++ b/examples/pg/connection.rbs @@ -7,9 +7,12 @@ module PgCodegen module ConnectionState : _PgConnection @prepared_statements: ::Set[::String] @prepared_statements_pid: ::Integer + @stale_statements: ::Set[::String] def prepared_statements: () -> ::Set[::String] + def stale_statements: () -> ::Set[::String] + attr_accessor in_transaction: bool? end diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index 5492406..21c9919 100644 --- a/examples/pg/query_sql.rb +++ b/examples/pg/query_sql.rb @@ -1047,7 +1047,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_xml, c_numeric, c_bigint, - c_timestamp, + encode_timestamp(c_timestamp), c_timestamptz, encode_array(c_text_matrix) ] @@ -1621,12 +1621,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -1659,17 +1667,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -1677,6 +1689,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement @@ -1700,6 +1719,12 @@ def encode_json(value) end end + # timestamp (without time zone) values are read back as local times and PostgreSQL ignores the UTC offset of + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. + def encode_timestamp(value) + value.is_a?(::Time) ? value.getlocal : value + end + # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). def encode_float(value) value.is_a?(::Float) ? value.to_s : value diff --git a/examples/pg/query_sql.rbs b/examples/pg/query_sql.rbs index 353521a..68d4d88 100644 --- a/examples/pg/query_sql.rbs +++ b/examples/pg/query_sql.rbs @@ -845,6 +845,8 @@ module PgCodegen def encode_json: (untyped value) -> untyped + def encode_timestamp: (untyped value) -> untyped + def encode_float: (untyped value) -> untyped def encode_array: (::Array[untyped]? value, ?::Symbol kind) -> untyped diff --git a/lib/sqlc_gen_ruby/drivers/pg.rb b/lib/sqlc_gen_ruby/drivers/pg.rb index f9defe5..cf281de 100644 --- a/lib/sqlc_gen_ruby/drivers/pg.rb +++ b/lib/sqlc_gen_ruby/drivers/pg.rb @@ -125,6 +125,13 @@ def encode_json(value) end end RUBY + 'encode_timestamp' => [<<~'RUBY'.chomp, 'def encode_timestamp: (untyped value) -> untyped'], + # timestamp (without time zone) values are read back as local times and PostgreSQL ignores the UTC offset of + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. + def encode_timestamp(value) + value.is_a?(::Time) ? value.getlocal : value + end + RUBY 'encode_float' => [<<~'RUBY'.chomp, 'def encode_float: (untyped value) -> untyped'], # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). def encode_float(value) @@ -167,11 +174,19 @@ module ConnectionState def prepared_statements if @prepared_statements_pid != backend_pid @prepared_statements = ::Set[] + @stale_statements = ::Set[] @prepared_statements_pid = backend_pid end @prepared_statements end + # Statements whose result type changed under them (ALTER TABLE ... TYPE in a migration): deallocated before + # they are prepared again. + def stale_statements + prepared_statements + @stale_statements + end + # Set inside #transaction: a lost connection is then not reconnected until the transaction block ends. attr_accessor :in_transaction end @@ -185,9 +200,12 @@ def backend_pid: () -> ::Integer module ConnectionState : _PgConnection @prepared_statements: ::Set[::String] @prepared_statements_pid: ::Integer + @stale_statements: ::Set[::String] def prepared_statements: () -> ::Set[::String] + def stale_statements: () -> ::Set[::String] + attr_accessor in_transaction: bool? end RBS @@ -349,17 +367,21 @@ 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, 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -367,6 +389,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement @@ -390,12 +419,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -438,6 +475,7 @@ def encoder_for(column) if column.is_array ['encode_array', ({ 'bytea' => 'bytea', 'json' => 'json', 'jsonb' => 'json' }[type])] elsif type == 'bytea' then ['encode_bytea'] + elsif type == 'timestamp' then ['encode_timestamp'] elsif %w[json jsonb].include?(type) then ['encode_json'] elsif column_type(column, fallback: nil) == 'Float' then ['encode_float'] end diff --git a/steep_expectations.yml b/steep_expectations.yml index 6c30c0e..20a8859 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -185,60 +185,60 @@ diagnostics: - range: start: - line: 70 + line: 78 character: 58 end: - line: 70 + line: 78 character: 60 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 73 + line: 81 character: 15 end: - line: 73 + line: 81 character: 17 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 75 + line: 83 character: 8 end: - line: 75 + line: 83 character: 10 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 75 + line: 83 character: 84 end: - line: 75 + line: 83 character: 86 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 98 + line: 106 character: 29 end: - line: 98 + line: 106 character: 31 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 104 + line: 112 character: 13 end: - line: 104 + line: 112 character: 15 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' @@ -467,200 +467,250 @@ code: Ruby::UnknownConstant - range: start: - line: 1628 - character: 60 + line: 1632 + character: 55 end: - line: 1628 - character: 62 + line: 1632 + character: 57 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1640 + line: 1633 + character: 60 + end: + line: 1636 + character: 17 + severity: WARNING + message: The method cannot be called with a block + code: Ruby::UnexpectedBlockGiven + - range: + start: + line: 1648 character: 19 end: - line: 1640 + line: 1648 character: 21 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1643 + line: 1651 character: 18 end: - line: 1643 + line: 1651 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1645 + line: 1653 character: 55 end: - line: 1645 + line: 1653 character: 57 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1652 + line: 1660 character: 18 end: - line: 1652 + line: 1660 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1653 + line: 1661 character: 16 end: - line: 1653 + line: 1661 character: 18 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1654 + line: 1662 character: 68 end: - line: 1654 + line: 1662 character: 70 severity: WARNING message: Empty array doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 1667 - character: 13 - end: - line: 1667 + line: 1677 character: 15 + end: + line: 1677 + character: 17 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1669 - character: 50 + line: 1677 + character: 46 end: - line: 1669 - character: 52 + line: 1677 + character: 48 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: line: 1679 + character: 55 + end: + line: 1679 + character: 57 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 1680 + character: 52 + end: + line: 1680 + character: 54 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 1691 character: 52 end: - line: 1685 + line: 1704 character: 9 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 1682 + line: 1695 + character: 19 + end: + line: 1695 + character: 21 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 1696 + character: 56 + end: + line: 1696 + character: 58 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 1701 character: 15 end: - line: 1682 + line: 1701 character: 17 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1684 + line: 1703 character: 52 end: - line: 1684 + line: 1703 character: 54 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1690 + line: 1709 character: 17 end: - line: 1690 + line: 1709 character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1697 + line: 1716 character: 53 end: - line: 1697 + line: 1716 character: 55 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1713 + line: 1738 character: 8 end: - line: 1713 + line: 1738 character: 10 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1713 + line: 1738 character: 52 end: - line: 1713 + line: 1738 character: 54 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1720 + line: 1745 character: 44 end: - line: 1720 + line: 1745 character: 46 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1721 + line: 1746 character: 27 end: - line: 1721 + line: 1746 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1722 + line: 1747 character: 46 end: - line: 1722 + line: 1747 character: 48 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1723 + line: 1748 character: 27 end: - line: 1723 + line: 1748 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' diff --git a/test/golden/pg/default/connection.rb b/test/golden/pg/default/connection.rb index 9a0b2bb..3a2a08e 100644 --- a/test/golden/pg/default/connection.rb +++ b/test/golden/pg/default/connection.rb @@ -10,11 +10,19 @@ module ConnectionState def prepared_statements if @prepared_statements_pid != backend_pid @prepared_statements = ::Set[] + @stale_statements = ::Set[] @prepared_statements_pid = backend_pid end @prepared_statements end + # Statements whose result type changed under them (ALTER TABLE ... TYPE in a migration): deallocated before + # they are prepared again. + def stale_statements + prepared_statements + @stale_statements + end + # Set inside #transaction: a lost connection is then not reconnected until the transaction block ends. attr_accessor :in_transaction end diff --git a/test/golden/pg/default/connection.rbs b/test/golden/pg/default/connection.rbs index b51a59e..f96bbee 100644 --- a/test/golden/pg/default/connection.rbs +++ b/test/golden/pg/default/connection.rbs @@ -7,9 +7,12 @@ module PgCodegen module ConnectionState : _PgConnection @prepared_statements: ::Set[::String] @prepared_statements_pid: ::Integer + @stale_statements: ::Set[::String] def prepared_statements: () -> ::Set[::String] + def stale_statements: () -> ::Set[::String] + attr_accessor in_transaction: bool? end diff --git a/test/golden/pg/default/my_queries_sql.rb b/test/golden/pg/default/my_queries_sql.rb index 2eefac1..b5f9c11 100644 --- a/test/golden/pg/default/my_queries_sql.rb +++ b/test/golden/pg/default/my_queries_sql.rb @@ -130,12 +130,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -168,17 +176,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -186,6 +198,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement diff --git a/test/golden/pg/default/other_sql.rb b/test/golden/pg/default/other_sql.rb index aa43191..c70eaa2 100644 --- a/test/golden/pg/default/other_sql.rb +++ b/test/golden/pg/default/other_sql.rb @@ -170,12 +170,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -208,17 +216,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -226,6 +238,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement diff --git a/test/golden/pg/default/query_sql.rb b/test/golden/pg/default/query_sql.rb index 06c6681..45df9d2 100644 --- a/test/golden/pg/default/query_sql.rb +++ b/test/golden/pg/default/query_sql.rb @@ -1077,7 +1077,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_xml, c_numeric, c_bigint, - c_timestamp, + encode_timestamp(c_timestamp), c_timestamptz, encode_array(c_text_matrix) ] @@ -1696,12 +1696,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -1734,17 +1742,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -1752,6 +1764,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement @@ -1775,6 +1794,12 @@ def encode_json(value) end end + # timestamp (without time zone) values are read back as local times and PostgreSQL ignores the UTC offset of + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. + def encode_timestamp(value) + value.is_a?(::Time) ? value.getlocal : value + end + # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). def encode_float(value) value.is_a?(::Float) ? value.to_s : value diff --git a/test/golden/pg/default/query_sql.rbs b/test/golden/pg/default/query_sql.rbs index 22c7f4c..9dcafad 100644 --- a/test/golden/pg/default/query_sql.rbs +++ b/test/golden/pg/default/query_sql.rbs @@ -888,6 +888,8 @@ module PgCodegen def encode_json: (untyped value) -> untyped + def encode_timestamp: (untyped value) -> untyped + def encode_float: (untyped value) -> untyped def encode_array: (::Array[untyped]? value, ?::Symbol kind) -> untyped diff --git a/test/golden/pg/ruby31_gemfile/connection.rb b/test/golden/pg/ruby31_gemfile/connection.rb index 9a0b2bb..3a2a08e 100644 --- a/test/golden/pg/ruby31_gemfile/connection.rb +++ b/test/golden/pg/ruby31_gemfile/connection.rb @@ -10,11 +10,19 @@ module ConnectionState def prepared_statements if @prepared_statements_pid != backend_pid @prepared_statements = ::Set[] + @stale_statements = ::Set[] @prepared_statements_pid = backend_pid end @prepared_statements end + # Statements whose result type changed under them (ALTER TABLE ... TYPE in a migration): deallocated before + # they are prepared again. + def stale_statements + prepared_statements + @stale_statements + end + # Set inside #transaction: a lost connection is then not reconnected until the transaction block ends. attr_accessor :in_transaction end diff --git a/test/golden/pg/ruby31_gemfile/connection.rbs b/test/golden/pg/ruby31_gemfile/connection.rbs index b51a59e..f96bbee 100644 --- a/test/golden/pg/ruby31_gemfile/connection.rbs +++ b/test/golden/pg/ruby31_gemfile/connection.rbs @@ -7,9 +7,12 @@ module PgCodegen module ConnectionState : _PgConnection @prepared_statements: ::Set[::String] @prepared_statements_pid: ::Integer + @stale_statements: ::Set[::String] def prepared_statements: () -> ::Set[::String] + def stale_statements: () -> ::Set[::String] + attr_accessor in_transaction: bool? end diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb index 0883ad5..c48f0fd 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb @@ -134,12 +134,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -172,17 +180,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -190,6 +202,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rb b/test/golden/pg/ruby31_gemfile/other_sql.rb index b3df9bd..2920ce0 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rb +++ b/test/golden/pg/ruby31_gemfile/other_sql.rb @@ -173,12 +173,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -211,17 +219,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -229,6 +241,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index 12db3b4..d96a369 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -1077,7 +1077,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_xml, c_numeric, c_bigint, - c_timestamp, + encode_timestamp(c_timestamp), c_timestamptz, encode_array(c_text_matrix) ] @@ -1702,12 +1702,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -1740,17 +1748,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -1758,6 +1770,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement @@ -1786,6 +1805,12 @@ def encode_json(value) end end + # timestamp (without time zone) values are read back as local times and PostgreSQL ignores the UTC offset of + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. + def encode_timestamp(value) + value.is_a?(::Time) ? value.getlocal : value + end + # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). def encode_float(value) value.is_a?(::Float) ? value.to_s : value diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rbs b/test/golden/pg/ruby31_gemfile/query_sql.rbs index f95235f..bca83a9 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/query_sql.rbs @@ -1061,6 +1061,8 @@ module PgCodegen def encode_json: (untyped value) -> untyped + def encode_timestamp: (untyped value) -> untyped + def encode_float: (untyped value) -> untyped def encode_array: (::Array[untyped]? value, ?::Symbol kind) -> untyped diff --git a/test/golden/pg/ruby32_notypes/connection.rb b/test/golden/pg/ruby32_notypes/connection.rb index 9a0b2bb..3a2a08e 100644 --- a/test/golden/pg/ruby32_notypes/connection.rb +++ b/test/golden/pg/ruby32_notypes/connection.rb @@ -10,11 +10,19 @@ module ConnectionState def prepared_statements if @prepared_statements_pid != backend_pid @prepared_statements = ::Set[] + @stale_statements = ::Set[] @prepared_statements_pid = backend_pid end @prepared_statements end + # Statements whose result type changed under them (ALTER TABLE ... TYPE in a migration): deallocated before + # they are prepared again. + def stale_statements + prepared_statements + @stale_statements + end + # Set inside #transaction: a lost connection is then not reconnected until the transaction block ends. attr_accessor :in_transaction end diff --git a/test/golden/pg/ruby32_notypes/my_queries_sql.rb b/test/golden/pg/ruby32_notypes/my_queries_sql.rb index 48fc3a7..27ba8db 100644 --- a/test/golden/pg/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/pg/ruby32_notypes/my_queries_sql.rb @@ -133,12 +133,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -171,17 +179,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -189,6 +201,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement diff --git a/test/golden/pg/ruby32_notypes/other_sql.rb b/test/golden/pg/ruby32_notypes/other_sql.rb index bbd9695..4bb6c56 100644 --- a/test/golden/pg/ruby32_notypes/other_sql.rb +++ b/test/golden/pg/ruby32_notypes/other_sql.rb @@ -173,12 +173,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -211,17 +219,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -229,6 +241,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index d78eabe..a2a2776 100644 --- a/test/golden/pg/ruby32_notypes/query_sql.rb +++ b/test/golden/pg/ruby32_notypes/query_sql.rb @@ -1080,7 +1080,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_xml, c_numeric, c_bigint, - c_timestamp, + encode_timestamp(c_timestamp), c_timestamptz, encode_array(c_text_matrix) ] @@ -1699,12 +1699,20 @@ def with_client(name) client.reset end configure(client) unless client.is_a?(ConnectionState) + done = false begin - yield client - rescue ::Exception # rubocop:disable Lint/RescueException - # a query left by Timeout / Thread#raise keeps running: cancel it before the connection is reused - client.cancel if client.transaction_status == ::PG::PQTRANS_ACTIVE - raise + result = yield client + done = true + result + ensure + # a query left by Timeout / Thread#raise / Thread#kill keeps running: cancel it and wait for its end, so + # the connection goes back to the pool (or on to ROLLBACK) idle + if !done && client.transaction_status == ::PG::PQTRANS_ACTIVE + ::Thread.handle_interrupt(::Object => :never) do + client.cancel + client.discard_results + end + end end end end @@ -1737,17 +1745,21 @@ def string_arrays(client) end # Executes a statement prepared once per connection. A statement dropped by hand (DEALLOCATE ALL, DISCARD ALL) is - # 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. + # prepared again, and one the server already has (its PREPARE was interrupted after it ran) is used. One whose + # result type changed ("cached plan must not change result type") is deallocated and prepared again. Inside a + # transaction the error has already aborted it, so it is raised (the next call prepares the statement again). def run_prepared(client, name, sql, params = []) 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 + begin + client.exec_prepared(name, params) + rescue ::PG::InvalidSqlStatementName, ::PG::FeatureNotSupported => e + client.prepared_statements.delete(name) + client.stale_statements.add(name) if e.is_a?(::PG::FeatureNotSupported) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE - prepare(client, name, sql) - client.exec_prepared(name, params) + prepare(client, name, sql) + client.exec_prepared(name, params) + end end # Timeout / Thread#raise wait until the statement is prepared and recorded. An exception raised by PREPARE itself @@ -1755,6 +1767,13 @@ def run_prepared(client, name, sql, params = []) # because the statement exists. def prepare(client, name, sql) ::Thread.handle_interrupt(::Object => :never) do + if client.stale_statements.delete?(name) + begin + client.exec("DEALLOCATE #{client.quote_ident(name)}") + rescue ::PG::InvalidSqlStatementName # already dropped (DEALLOCATE ALL) + raise unless client.transaction_status == ::PG::PQTRANS_IDLE + end + end client.prepare(name, sql) client.prepared_statements.add(name) rescue ::PG::DuplicatePstatement @@ -1783,6 +1802,12 @@ def encode_json(value) end end + # timestamp (without time zone) values are read back as local times and PostgreSQL ignores the UTC offset of + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. + def encode_timestamp(value) + value.is_a?(::Time) ? value.getlocal : value + end + # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). def encode_float(value) value.is_a?(::Float) ? value.to_s : value diff --git a/tests/end2end_pg.rb b/tests/end2end_pg.rb index 4822245..4bf79a6 100755 --- a/tests/end2end_pg.rb +++ b/tests/end2end_pg.rb @@ -505,7 +505,7 @@ def test_remaining_postgresql_types_round_trip assert_equal 'Grüße🙂', row.c_xml assert_equal BigDecimal('12345678901234567890.1234567890123456789012345678901234567890'), row.c_numeric assert_equal(-9_223_372_036_854_775_808, row.c_bigint) - assert_equal timestamp.strftime('%Y-%m-%d %H:%M:%S.%6N'), row.c_timestamp.strftime('%Y-%m-%d %H:%M:%S.%6N') + assert_equal timestamp, row.c_timestamp assert_equal timestamptz, row.c_timestamptz assert_equal matrix, row.c_text_matrix assert_empty RbsTypes.mismatches(row, File.join(EXAMPLES_DIR, 'pg/query_sql.rbs'), 'GetRemainingTypesRow') @@ -808,6 +808,45 @@ def test_thread_kill_rolls_back_and_leaves_connection_idle end end + def wait_until_query_runs(admin, pattern) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + 5 + sql = "SELECT 1 FROM pg_stat_activity WHERE state = 'active' AND query LIKE $1 AND pid <> pg_backend_pid()" + until admin.exec_params(sql, [pattern]).ntuples.positive? + flunk "no running query like #{pattern}" if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) > deadline + sleep 0.01 + end + end + + # A kill skips `rescue`: the running query used to stay on the pooled connection (PQTRANS_ACTIVE), so the next + # transaction sent SAVEPOINT instead of BEGIN, and a kill inside a transaction waited for the query to finish. + def test_thread_kill_during_a_query_cancels_it_and_leaves_the_connection_idle + queries = QuerySql.connect(pg_params, size: 1) + admin = PG.connect(**pg_params) + [false, true].each do |in_transaction| + worker = ::Thread.new do + if in_transaction + queries.transaction do |tx| + tx.create_author_return_id(name: 'killed', bio: nil) + tx.pause_query(seconds: 5) + end + else + queries.pause_query(seconds: 5) + end + end + wait_until_query_runs(admin, '%pg_sleep%') + started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + worker.kill.join + assert_operator ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started, :<, 2, "in_transaction: #{in_transaction}" + queries.instance_variable_get(:@db).with do |client| + assert_equal PG::PQTRANS_IDLE, client.transaction_status + end + assert_equal %w[after], queries.transaction { |tx| tx.create_author_return_id(name: 'after', bio: nil) && tx.list_authors.map(&:name) } + queries.truncate_authors + end + ensure + admin&.close + end + def test_nested_savepoint_rolls_back_while_outer_transaction_commits @query_sql.transaction do |tx| tx.create_author_return_id(name: 'outer before', bio: nil) @@ -1220,6 +1259,49 @@ def test_statements_are_prepared_again_after_deallocate_all client&.close end + # A migration changing a column's type: PostgreSQL refuses the prepared plan ("cached plan must not change result + # type"); the statement is prepared again outside a transaction, and by the next call after one it aborted. + def test_statements_are_prepared_again_after_a_column_type_change + queries = QuerySql.connect(pg_params, size: 1) + admin = PG.connect(**pg_params) + id = create_author('kept') + assert_equal 'kept', queries.get_author(id: id).name + admin.exec('ALTER TABLE authors ALTER COLUMN name TYPE varchar(200)') + assert_equal 'kept', queries.get_author(id: id).name + admin.exec('ALTER TABLE authors ALTER COLUMN name TYPE text') + in_transaction = -> { queries.transaction { |tx| tx.get_author(id: id).name } } + if ENV['PREPARED_STATEMENTS'] == 'false' + assert_equal 'kept', in_transaction.call + else + assert_raises(PG::FeatureNotSupported, &in_transaction) + end + assert_equal 'kept', in_transaction.call + assert_equal 'kept', queries.get_author(id: id).name + ensure + admin&.exec('ALTER TABLE authors ALTER COLUMN name TYPE text') + admin&.close + end + + # A Time with any UTC offset keeps its instant in a `timestamp` (without time zone) column, read back as a local + # time; PostgreSQL ignores the offset of a timestamp literal, so it used to store the wall clock of the given zone. + def test_timestamp_parameters_keep_their_instant_whatever_their_offset + saved = ENV.fetch('TZ', nil) + ENV['TZ'] = 'XYZ-3' # UTC+3, also on Windows + [::Time.utc(2024, 1, 15, 12, 0, Rational(1, 2)), ::Time.new(2024, 2, 3, 4, 5, 6, '+05:30'), + ::Time.new(2024, 7, 1, 23, 30, 0, '-07:00'), ::Time.at(::Time.now.to_i)].each do |time| + @query_sql.insert_remaining_types( + c_hstore: nil, c_ltree: nil, c_regclass: nil, c_cidr: nil, c_macaddr: nil, c_interval: nil, c_varbit: nil, + c_xml: nil, c_numeric: nil, c_bigint: nil, c_timestamp: time, c_timestamptz: time, c_text_matrix: nil + ) + row = @query_sql.get_remaining_types + assert_equal time, row.c_timestamp, time.inspect + assert_equal time, row.c_timestamptz, time.inspect + @query_sql.truncate_remaining_types + end + ensure + saved ? ENV['TZ'] = saved : ENV.delete('TZ') + 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)