From f5ca6c9505b96f485df5b832e5ba13261c33432e 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:52:52 +0000 Subject: [PATCH] Keep DateTime instants in pg timestamp params and Timeout::Error on Ruby 3.1's bundled timeout Co-Authored-By: Doron Eli Rachman --- .github/workflows/tests.yml | 3 ++ Gemfile.e2e | 10 ++++++- README.md | 16 ++++++---- docs/Coverage.md | 4 +-- docs/Testing.md | 2 +- examples/pg/query_sql.rb | 13 ++++++--- lib/sqlc_gen_ruby/drivers/pg.rb | 19 ++++++++++-- steep_expectations.yml | 24 +++++++-------- test/golden/pg/default/query_sql.rb | 13 ++++++--- .../pg/ruby31_gemfile/my_queries_sql.rb | 2 +- test/golden/pg/ruby31_gemfile/other_sql.rb | 2 +- test/golden/pg/ruby31_gemfile/query_sql.rb | 15 ++++++---- .../pg/ruby32_notypes/my_queries_sql.rb | 2 +- test/golden/pg/ruby32_notypes/other_sql.rb | 2 +- test/golden/pg/ruby32_notypes/query_sql.rb | 15 ++++++---- tests/end2end_pg.rb | 29 +++++++++++++++++-- 16 files changed, 123 insertions(+), 48 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2dfd095..2b365ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -77,6 +77,8 @@ jobs: include: - ruby-version: '4.0' variant: 'module-no-prepared' + - ruby-version: '3.1' + variant: 'bundled-timeout' services: postgres: @@ -98,6 +100,7 @@ jobs: EXAMPLES_DRIVER: pg EXAMPLES_MODULE: ${{ matrix.variant == 'module-no-prepared' && 'MyApp::Db' || 'PgCodegen' }} PREPARED_STATEMENTS: ${{ matrix.variant == 'module-no-prepared' && 'false' || 'true' }} + E2E_TIMEOUT_GEM: ${{ matrix.variant == 'bundled-timeout' && '0.2.0' || '' }} BUNDLE_GEMFILE: ${{ (matrix.ruby-version == '3.1' || matrix.ruby-version == '3.2') && 'Gemfile.e2e' || 'Gemfile' }} steps: diff --git a/Gemfile.e2e b/Gemfile.e2e index 4b41ee8..83e10e0 100644 --- a/Gemfile.e2e +++ b/Gemfile.e2e @@ -4,7 +4,15 @@ source 'https://rubygems.org' examples_dir = ENV.fetch('EXAMPLES_DIR') { raise 'EXAMPLES_DIR must point at generated scratch examples' } driver = ENV.fetch('EXAMPLES_DRIVER') { raise 'EXAMPLES_DRIVER must be set for E2E bundling' } -eval_gemfile File.join(examples_dir, driver, 'Gemfile') +generated = File.join(examples_dir, driver, 'Gemfile') +timeout = ENV.fetch('E2E_TIMEOUT_GEM', '') +if !timeout.empty? + # run against an older timeout gem (Ruby 3.1 bundles 0.2.0) instead of the generated Gemfile's '>= 0.4' + eval_gemfile generated, File.read(generated).lines.grep_v(/^gem 'timeout'/).join + gem 'timeout', timeout +else + eval_gemfile generated +end gem 'bigdecimal' # tests/; bundled (not default) gems since Ruby 3.4 / 4.0 gem 'logger' diff --git a/README.md b/README.md index eddf2d3..2cc0863 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ end 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 - to keep those exits committing. A nested `transaction` rolls back only its own part. With `connection_pool` or + to keep those exits committing; an old `Timeout` that interrupts a PostgreSQL query in a transaction still + raises `Timeout::Error` (not the `PG::InFailedSqlTransaction` of the cancelled query) after rolling back. A nested `transaction` rolls back only its own part. With `connection_pool` or SQLite, parent-object calls from the same thread reuse the transaction connection; injected `#with` objects must do the same. * `tx` is a copy of the query object bound to the transaction: its queries stay in the transaction from any fiber or @@ -197,10 +198,15 @@ 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. +* PostgreSQL: a `Time` or `DateTime` 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). A `DateTime` of a + `timestamptz` column keeps its instant too (the pg gem alone would send its date only); the RBS still declares + `::Time`, the type the rows return. Elements of `timestamp[]` arrays are written as given. +* A column without a time zone (PostgreSQL `timestamp`, MySQL `DATETIME`) stores the local wall-clock time, so an + instant in the hour repeated when the local zone falls back from daylight saving time (e.g. 02:30 on the last + Sunday of October in Europe/Berlin happens twice) reads back as the first of the two, one hour off for the second. + Use `timestamptz` / `TIMESTAMP`, or run the process in UTC (`TZ=UTC`), to keep every instant. * 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 b1d6319..6e2e4e7 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; 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) | +| PostgreSQL arrays, JSON/JSONB, type round trips and RBS; a `Time` keeps its instant in a `timestamp` column whatever its offset; a `DateTime` keeps its instant in `timestamp` / `timestamptz` | [`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_datetime_parameters_keep_their_instant`](../tests/end2end_pg.rb#test_datetime_parameters_keep_their_instant), [`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) | | @@ -31,7 +31,7 @@ only when the behavior is rejected or decided during generation and generated co | RuboCop style, query/schema comments and source SQL constants | [`test_flow`](../tests/end2end_pg.rb#test_flow) | [`test_flow`](../tests/end2end_mysql2.rb#test_flow) | [`test_flow`](../tests/end2end_sqlite3.rb#test_flow) | [`test_generated_ruby_is_formatted_like_ruby`](../test/features_test.rb#test_generated_ruby_is_formatted_like_ruby), [`test_ruby31_struct_rows_of_unnamed_columns_are_rubocop_clean`](../test/features_test.rb#test_ruby31_struct_rows_of_unnamed_columns_are_rubocop_clean), [`test_query_comments_become_doc_comments`](../test/features_test.rb#test_query_comments_become_doc_comments), [`test_table_column_and_enum_comments_become_doc_comments`](../test/features_test.rb#test_table_column_and_enum_comments_become_doc_comments), [`test_sql_is_a_frozen_raw_heredoc`](../test/features_test.rb#test_sql_is_a_frozen_raw_heredoc) | | Unnamed result columns (`column_1`, ...) and aliases with quotes | [`test_unnamed_result_columns_are_read_by_position`](../tests/end2end_pg.rb#test_unnamed_result_columns_are_read_by_position), [`test_alias_with_a_quote_is_read`](../tests/end2end_pg.rb#test_alias_with_a_quote_is_read) | [`test_unnamed_result_columns_are_read_by_position`](../tests/end2end_mysql2.rb#test_unnamed_result_columns_are_read_by_position), [`test_alias_with_a_quote_is_read`](../tests/end2end_mysql2.rb#test_alias_with_a_quote_is_read) | [`test_unnamed_result_columns_are_read_by_position`](../tests/end2end_sqlite3.rb#test_unnamed_result_columns_are_read_by_position), [`test_alias_with_a_quote_is_read`](../tests/end2end_sqlite3.rb#test_alias_with_a_quote_is_read) | | | Empty `sqlc.slice`: `IN` matches nothing, `NOT IN` everything | unit: PostgreSQL uses arrays instead of slices | [`test_empty_slice_matches_nothing`](../tests/end2end_mysql2.rb#test_empty_slice_matches_nothing), [`test_not_in_an_empty_slice_matches_everything`](../tests/end2end_mysql2.rb#test_not_in_an_empty_slice_matches_everything) | [`test_empty_slice_matches_nothing`](../tests/end2end_sqlite3.rb#test_empty_slice_matches_nothing), [`test_not_in_an_empty_slice_matches_everything`](../tests/end2end_sqlite3.rb#test_not_in_an_empty_slice_matches_everything) | | -| A transaction ended by the database keeps the original error; a query abandoned by Timeout is cancelled | [`test_query_abandoned_by_a_timeout_is_cancelled`](../tests/end2end_pg.rb#test_query_abandoned_by_a_timeout_is_cancelled) | [`test_deadlock_in_a_nested_transaction_raises_the_deadlock_error`](../tests/end2end_mysql2.rb#test_deadlock_in_a_nested_transaction_raises_the_deadlock_error) | [`test_conflict_that_ends_the_transaction_raises_the_constraint_error`](../tests/end2end_sqlite3.rb#test_conflict_that_ends_the_transaction_raises_the_constraint_error) | | +| A transaction ended by the database keeps the original error; a query abandoned by Timeout is cancelled; a Timeout in a PostgreSQL transaction raises `Timeout::Error`, also with Ruby 3.1's bundled timeout 0.2.0 | [`test_query_abandoned_by_a_timeout_is_cancelled`](../tests/end2end_pg.rb#test_query_abandoned_by_a_timeout_is_cancelled), [`test_transaction_timeout_rolls_back_and_keeps_connection_usable`](../tests/end2end_pg.rb#test_transaction_timeout_rolls_back_and_keeps_connection_usable) | [`test_deadlock_in_a_nested_transaction_raises_the_deadlock_error`](../tests/end2end_mysql2.rb#test_deadlock_in_a_nested_transaction_raises_the_deadlock_error) | [`test_conflict_that_ends_the_transaction_raises_the_constraint_error`](../tests/end2end_sqlite3.rb#test_conflict_that_ends_the_transaction_raises_the_constraint_error) | | | Top-level Float JSON parameters, Symbol enum values | [`test_top_level_float_json_parameters_round_trip_exactly`](../tests/end2end_pg.rb#test_top_level_float_json_parameters_round_trip_exactly) | [`test_symbol_enum_value_is_bound_as_its_name`](../tests/end2end_mysql2.rb#test_symbol_enum_value_is_bound_as_its_name) | not SQLite (no enums / JSON encoding) | | | Modules sharing a connection keep separate prepared statements | [`test_generator_options_preserve_runtime_values_and_generate_expected_layouts`](../tests/end2end_options.rb#test_generator_options_preserve_runtime_values_and_generate_expected_layouts) | not MySQL (no server-side prepared statements) | not SQLite (statements are per call) | [`test_statements_are_named_after_the_module_and_query_class`](../test/features_test.rb#test_statements_are_named_after_the_module_and_query_class) | | SQLite parameters sqlc does not report (`ON CONFLICT ... DO UPDATE SET`) are generation errors | not PostgreSQL | not MySQL | unit: rejected at generation | [`test_sqlite_parameters_sqlc_does_not_report_are_generation_errors`](../test/features_test.rb#test_sqlite_parameters_sqlc_does_not_report_are_generation_errors), [`test_sqlite_placeholders_in_literals_and_comments_are_not_parameters`](../test/features_test.rb#test_sqlite_placeholders_in_literals_and_comments_are_not_parameters) | diff --git a/docs/Testing.md b/docs/Testing.md index 8c8b669..8ce6c1f 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -48,7 +48,7 @@ They `require` the generated code from `examples/` and run it against a real dat | Workflow | When | What | |---|---|---| | `build.yml` | push to `main`, pull requests (not for Markdown-only changes), release | build `plugin.wasm`; lint; `sqlc diff` with the WASM and the process plugin; Steep; generate with option combinations (Ruby 3.1/3.2/3.3/4.0 × types/Gemfile) and check the output with the matching Ruby | -| `tests.yml` | same | unit tests; plugin compatibility on Ruby 3.1 / 3.2 / 3.4 / 4.0; full E2E matrix on Ruby 3.1 / 3.2 / 3.3 / 3.4 / 4.0; Ruby 4.0 module-name variants for MySQL and SQLite and module-name + no-prepared-statements variant for PostgreSQL; PostgreSQL 16.2, MySQL 8.3.0, and SQLite; generator-options E2E on Ruby 3.1 / 4.0 | +| `tests.yml` | same | unit tests; plugin compatibility on Ruby 3.1 / 3.2 / 3.4 / 4.0; full E2E matrix on Ruby 3.1 / 3.2 / 3.3 / 3.4 / 4.0; Ruby 4.0 module-name variants for MySQL and SQLite and module-name + no-prepared-statements variant for PostgreSQL; a Ruby 3.1 PostgreSQL variant with Ruby's bundled timeout 0.2.0 (`E2E_TIMEOUT_GEM`, `Gemfile.e2e`); PostgreSQL 16.2, MySQL 8.3.0, and SQLite; generator-options E2E on Ruby 3.1 / 4.0 | | `cross-platform.yml` | called by `build.yml` after `build-wasm` | macOS and Windows × Ruby 3.1 / 4.0 (oldest and newest supported), as two parallel jobs each. *Codegen*: unit + golden tests; `sqlc diff` with the Linux-built `plugin.wasm` and with the process plugin (`exe\plugin.cmd` on Windows); WASM codegen check. *End-to-End*: full E2E for SQLite, PostgreSQL (16 on macOS, 17 on Windows) and MySQL 8.4, installed natively (`ankane/setup-postgres`, `ankane/setup-mysql`, the image's MySQL on Windows), using the generated Gemfiles (installed gems cached per runner image); generator-options E2E | | `docs.yml` | `examples/` change on `main`, manual | regenerates `docs/Examples.md` and opens a PR | | `release.yml` | manual only | see [Releasing](Releasing.md) | diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index 21c9919..f02d144 100644 --- a/examples/pg/query_sql.rb +++ b/examples/pg/query_sql.rb @@ -995,7 +995,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim encode_json(c_jsonb), encode_array(c_text_array), encode_array(c_integer_array), - c_timestamptz, + encode_timestamp(c_timestamptz), c_inet, c_money, c_not_null @@ -1048,7 +1048,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_numeric, c_bigint, encode_timestamp(c_timestamp), - c_timestamptz, + encode_timestamp(c_timestamptz), encode_array(c_text_matrix) ] run_prepared(client, 'PgCodegen::QuerySql#insert_remaining_types', INSERT_REMAINING_TYPES_SQL, query_params) @@ -1720,9 +1720,14 @@ def encode_json(value) 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. + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. A + # DateTime is converted too (the pg gem would encode it as a Date, dropping its time). def encode_timestamp(value) - value.is_a?(::Time) ? value.getlocal : value + case value + when ::Time then value.getlocal + when ::DateTime then value.to_time.getlocal + else value + end end # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). diff --git a/lib/sqlc_gen_ruby/drivers/pg.rb b/lib/sqlc_gen_ruby/drivers/pg.rb index cf281de..a575e3a 100644 --- a/lib/sqlc_gen_ruby/drivers/pg.rb +++ b/lib/sqlc_gen_ruby/drivers/pg.rb @@ -28,6 +28,12 @@ def class_methods(ruby_version) "rescued || ::Thread.current.status == 'aborting' ? rollback : commit", "rescued || ::Thread.current.status == 'aborting' || (!completed && timeout_exits_with_throw?) ? rollback : commit" ) + # an old Timeout leaves the block with throw after its query was cancelled: keep the throw (Timeout::Error) + if ruby_version.legacy_timeout? + raise 'transaction template without the InFailedSqlTransaction raise' unless transaction.include?(IN_FAILED_RAISE) + + transaction = transaction.sub(IN_FAILED_RAISE, "#{IN_FAILED_RAISE} && (completed || !timeout_exits_with_throw?)") + end [ [NOT_FOUND, "class NotFound < ::StandardError\nend"], [CONNECT, 'def self.connect: (::Hash[untyped, untyped] pg_params, ?logger: untyped, **untyped pool_options) -> instance'], @@ -127,9 +133,14 @@ def encode_json(value) 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. + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. A + # DateTime is converted too (the pg gem would encode it as a Date, dropping its time). def encode_timestamp(value) - value.is_a?(::Time) ? value.getlocal : value + case value + when ::Time then value.getlocal + when ::DateTime then value.to_time.getlocal + else value + end end RUBY 'encode_float' => [<<~'RUBY'.chomp, 'def encode_float: (untyped value) -> untyped'], @@ -294,6 +305,8 @@ def self.connect(pg_params, logger: nil, **pool_options) end RUBY + IN_FAILED_RAISE = "raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting'" + TRANSACTION = <<~'RUBY'.chomp # Runs the block in a transaction on one connection and returns its value: COMMIT when the block returns # (also through break / return / throw), ROLLBACK when it raises or the thread is killed. A nested call @@ -475,7 +488,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[timestamp timestamptz].include?(type) 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 20a8859..523bfd3 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -657,60 +657,60 @@ code: Ruby::UnknownConstant - range: start: - line: 1738 + line: 1743 character: 8 end: - line: 1738 + line: 1743 character: 10 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1738 + line: 1743 character: 52 end: - line: 1738 + line: 1743 character: 54 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1745 + line: 1750 character: 44 end: - line: 1745 + line: 1750 character: 46 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1746 + line: 1751 character: 27 end: - line: 1746 + line: 1751 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1747 + line: 1752 character: 46 end: - line: 1747 + line: 1752 character: 48 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 1748 + line: 1753 character: 27 end: - line: 1748 + line: 1753 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' diff --git a/test/golden/pg/default/query_sql.rb b/test/golden/pg/default/query_sql.rb index 45df9d2..f16a258 100644 --- a/test/golden/pg/default/query_sql.rb +++ b/test/golden/pg/default/query_sql.rb @@ -1025,7 +1025,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim encode_json(c_jsonb), encode_array(c_text_array), encode_array(c_integer_array), - c_timestamptz, + encode_timestamp(c_timestamptz), c_inet, c_money, c_not_null @@ -1078,7 +1078,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_numeric, c_bigint, encode_timestamp(c_timestamp), - c_timestamptz, + encode_timestamp(c_timestamptz), encode_array(c_text_matrix) ] run_prepared(client, 'PgCodegen::QuerySql#insert_remaining_types', INSERT_REMAINING_TYPES_SQL, query_params) @@ -1795,9 +1795,14 @@ def encode_json(value) 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. + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. A + # DateTime is converted too (the pg gem would encode it as a Date, dropping its time). def encode_timestamp(value) - value.is_a?(::Time) ? value.getlocal : value + case value + when ::Time then value.getlocal + when ::DateTime then value.to_time.getlocal + else value + end end # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb index c48f0fd..097c98b 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb @@ -95,7 +95,7 @@ def transaction(isolation: nil) raise end client.exec("RELEASE SAVEPOINT #{savepoint}") if rescued && savepoint - raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' + raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' && (completed || !timeout_exits_with_throw?) end end end diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rb b/test/golden/pg/ruby31_gemfile/other_sql.rb index 2920ce0..1ae3eaf 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rb +++ b/test/golden/pg/ruby31_gemfile/other_sql.rb @@ -107,7 +107,7 @@ def transaction(isolation: nil) raise end client.exec("RELEASE SAVEPOINT #{savepoint}") if rescued && savepoint - raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' + raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' && (completed || !timeout_exits_with_throw?) end end end diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index d96a369..d445cc1 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -702,7 +702,7 @@ def transaction(isolation: nil) raise end client.exec("RELEASE SAVEPOINT #{savepoint}") if rescued && savepoint - raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' + raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' && (completed || !timeout_exits_with_throw?) end end end @@ -1025,7 +1025,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim encode_json(c_jsonb), encode_array(c_text_array), encode_array(c_integer_array), - c_timestamptz, + encode_timestamp(c_timestamptz), c_inet, c_money, c_not_null @@ -1078,7 +1078,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_numeric, c_bigint, encode_timestamp(c_timestamp), - c_timestamptz, + encode_timestamp(c_timestamptz), encode_array(c_text_matrix) ] run_prepared(client, 'PgCodegen::QuerySql#insert_remaining_types', INSERT_REMAINING_TYPES_SQL, query_params) @@ -1806,9 +1806,14 @@ def encode_json(value) 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. + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. A + # DateTime is converted too (the pg gem would encode it as a Date, dropping its time). def encode_timestamp(value) - value.is_a?(::Time) ? value.getlocal : value + case value + when ::Time then value.getlocal + when ::DateTime then value.to_time.getlocal + else value + end end # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). diff --git a/test/golden/pg/ruby32_notypes/my_queries_sql.rb b/test/golden/pg/ruby32_notypes/my_queries_sql.rb index 27ba8db..96bcd42 100644 --- a/test/golden/pg/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/pg/ruby32_notypes/my_queries_sql.rb @@ -94,7 +94,7 @@ def transaction(isolation: nil) raise end client.exec("RELEASE SAVEPOINT #{savepoint}") if rescued && savepoint - raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' + raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' && (completed || !timeout_exits_with_throw?) end end end diff --git a/test/golden/pg/ruby32_notypes/other_sql.rb b/test/golden/pg/ruby32_notypes/other_sql.rb index 4bb6c56..e6f9b59 100644 --- a/test/golden/pg/ruby32_notypes/other_sql.rb +++ b/test/golden/pg/ruby32_notypes/other_sql.rb @@ -107,7 +107,7 @@ def transaction(isolation: nil) raise end client.exec("RELEASE SAVEPOINT #{savepoint}") if rescued && savepoint - raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' + raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' && (completed || !timeout_exits_with_throw?) end end end diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index a2a2776..189d5f1 100644 --- a/test/golden/pg/ruby32_notypes/query_sql.rb +++ b/test/golden/pg/ruby32_notypes/query_sql.rb @@ -716,7 +716,7 @@ def transaction(isolation: nil) raise end client.exec("RELEASE SAVEPOINT #{savepoint}") if rescued && savepoint - raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' + raise ::PG::InFailedSqlTransaction, 'transaction rolled back: a statement in it failed' if rescued && ::Thread.current.status != 'aborting' && (completed || !timeout_exits_with_throw?) end end end @@ -1028,7 +1028,7 @@ def insert_extra_types(c_uuid:, c_jsonb:, c_text_array:, c_integer_array:, c_tim encode_json(c_jsonb), encode_array(c_text_array), encode_array(c_integer_array), - c_timestamptz, + encode_timestamp(c_timestamptz), c_inet, c_money, c_not_null @@ -1081,7 +1081,7 @@ def insert_remaining_types(c_hstore:, c_ltree:, c_regclass:, c_cidr:, c_macaddr: c_numeric, c_bigint, encode_timestamp(c_timestamp), - c_timestamptz, + encode_timestamp(c_timestamptz), encode_array(c_text_matrix) ] run_prepared(client, 'PgCodegen::QuerySql#insert_remaining_types', INSERT_REMAINING_TYPES_SQL, query_params) @@ -1803,9 +1803,14 @@ def encode_json(value) 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. + # a timestamp literal: a Time is written in the local zone, so it keeps its instant whatever its offset. A + # DateTime is converted too (the pg gem would encode it as a Date, dropping its time). def encode_timestamp(value) - value.is_a?(::Time) ? value.getlocal : value + case value + when ::Time then value.getlocal + when ::DateTime then value.to_time.getlocal + else value + end end # Float#to_s is the shortest text that reads back as the same Float (the pg gem's encoder drops digits). diff --git a/tests/end2end_pg.rb b/tests/end2end_pg.rb index 4bf79a6..39d0e46 100755 --- a/tests/end2end_pg.rb +++ b/tests/end2end_pg.rb @@ -6,6 +6,7 @@ require 'async' require 'async/barrier' require 'bigdecimal' +require 'date' require 'ipaddr' require 'securerandom' require 'timeout' @@ -755,13 +756,19 @@ def test_transaction_left_with_break_or_throw_commits_and_frees_the_connection assert_equal expected, @query_sql.list_authors.map(&:name).sort end + # Also with Ruby 3.1's bundled timeout 0.2.0 (CI variant bundled-timeout), whose Timeout leaves the block with throw: + # the cancelled query must not replace Timeout::Error with PG::InFailedSqlTransaction. def test_transaction_timeout_rolls_back_and_keeps_connection_usable - [:database, :ruby_sleep].each do |kind| + %i[database ruby_sleep nested_database].each do |kind| assert_raises(Timeout::Error) do Timeout.timeout(0.05) do @query_sql.transaction do |tx| tx.create_author_return_id(name: kind.to_s, bio: nil) - kind == :database ? tx.pause_query(seconds: 0.2) : sleep(0.2) + case kind + when :database then tx.pause_query(seconds: 0.2) + when :ruby_sleep then sleep(0.2) + else tx.transaction { |inner| inner.pause_query(seconds: 0.2) } + end end end end @@ -1282,6 +1289,24 @@ def test_statements_are_prepared_again_after_a_column_type_change admin&.close end + # A DateTime is written as the instant it stands for (the pg gem alone would encode it as a Date: midnight). + def test_datetime_parameters_keep_their_instant + saved = ENV.fetch('TZ', nil) + ENV['TZ'] = 'XYZ-3' # UTC+3, also on Windows + [::DateTime.new(2024, 1, 1, 10, 0, Rational(61, 2), '+02:00'), ::DateTime.new(2024, 7, 1, 23, 30, 0, '-07:00')].each do |datetime| + @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: datetime, c_timestamptz: datetime, c_text_matrix: nil + ) + row = @query_sql.get_remaining_types + assert_equal datetime.to_time, row.c_timestamp, datetime.inspect + assert_equal datetime.to_time, row.c_timestamptz, datetime.inspect + @query_sql.truncate_remaining_types + end + ensure + saved ? ENV['TZ'] = saved : ENV.delete('TZ') + 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