Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ jobs:
include:
- ruby-version: '4.0'
variant: 'module-no-prepared'
- ruby-version: '3.1'
variant: 'bundled-timeout'

services:
postgres:
Expand All @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion Gemfile.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/Coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | |
Expand All @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion docs/Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
13 changes: 9 additions & 4 deletions examples/pg/query_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading