diff --git a/README.md b/README.md index 6edde00..faca082 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,10 @@ server; duplicate keys and invalid values raise `Mysql2::Error` instead of being (`uuid[]`, enum arrays, ...) are `Array[String]`, also for types created after the connection was opened (a migration run while the application is up). Geometric, range, full-text, `hstore`, `ltree`, `timetz`, `reg*` and composite types are `String`s; domains and unknown types are `untyped`. +* MySQL: a `Time` / `DateTime` parameter (also in `:copyfrom`) keeps its instant whatever its UTC offset: it is written + 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. * 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 @@ -213,6 +217,9 @@ server; duplicate keys and invalid values raise `Mysql2::Error` instead of being * A parameter used twice is one keyword when named with `sqlc.arg`; repeated `?` / `$n` of one column become `id:`, `id_2:`. * Models may be named like Ruby or driver constants (tables `exceptions`, `data`, `threads`, `sets`, ...): the generated code and `.rbs` refer to those as `::Exception`, `::Data`, `::Thread`, ... so the models cannot shadow them. +* SQLite: sqlc before 1.30 (including the pinned 1.27) reports a quoted table name with its quotes, so + `CREATE TABLE "Items"` gives the model `Items` there and `Item` with sqlc 1.30 and later (PostgreSQL / MySQL give + `Item` with any version). Use `rename` or `exactTableNames` to keep the name when upgrading sqlc. * A table `authors` next to a table `author` keeps its plural model name (`Authors`). Query names that would replace a generated method (`Transaction`, `Initialize`, ...) are errors. diff --git a/docs/Coverage.md b/docs/Coverage.md index e288c8e..c6098aa 100644 --- a/docs/Coverage.md +++ b/docs/Coverage.md @@ -18,7 +18,7 @@ only when the behavior is rejected or decided during generation and generated co | 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) | -| MySQL unsigned integers, BIT, YEAR, DATETIME/TIME, 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_match_mysql2_prepared_statement_storage`](../tests/end2end_mysql2.rb#test_time_values_match_mysql2_prepared_statement_storage), [`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) | +| 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) | | | Names: keywords, method collisions (also private row hooks such as `initialize`), acronyms, quoted mixed case, repeated `$n`, plural models | [`test_keyword_and_method_names_keep_distinct_row_and_argument_names`](../tests/end2end_pg.rb#test_keyword_and_method_names_keep_distinct_row_and_argument_names), [`test_repeated_column_parameters_get_distinct_names`](../tests/end2end_pg.rb#test_repeated_column_parameters_get_distinct_names) | [`test_keyword_and_method_names_keep_distinct_row_and_argument_names`](../tests/end2end_mysql2.rb#test_keyword_and_method_names_keep_distinct_row_and_argument_names), [`test_repeated_positional_parameters_get_distinct_names`](../tests/end2end_mysql2.rb#test_repeated_positional_parameters_get_distinct_names) | [`test_keyword_and_method_names_keep_distinct_row_and_argument_names`](../tests/end2end_sqlite3.rb#test_keyword_and_method_names_keep_distinct_row_and_argument_names) | [`test_snake_case_keeps_acronyms_together`](../test/features_test.rb#test_snake_case_keeps_acronyms_together), [`test_query_names_cannot_replace_generated_methods`](../test/features_test.rb#test_query_names_cannot_replace_generated_methods), [`test_tables_with_the_same_singular_get_distinct_models`](../test/features_test.rb#test_tables_with_the_same_singular_get_distinct_models), [`test_columns_named_like_row_methods_still_build_and_read_rows`](../test/features_test.rb#test_columns_named_like_row_methods_still_build_and_read_rows) | diff --git a/examples/authors/mysql/query.sql b/examples/authors/mysql/query.sql index f20c8d3..e05182d 100644 --- a/examples/authors/mysql/query.sql +++ b/examples/authors/mysql/query.sql @@ -189,6 +189,9 @@ SELECT * FROM mysql_binding_values WHERE id = sqlc.arg(id); -- name: TruncateMysqlBindingValues :exec TRUNCATE TABLE mysql_binding_values; +-- name: CopyMysqlBindingTimes :copyfrom +INSERT INTO mysql_binding_values (id, datetime_value, datetime_2) VALUES (?, ?, ?); + -- name: InsertNameCase :exec INSERT INTO name_cases (id, `class`, `end`, `hash`, `method`, `count`, `camelCase`, `HTTPServer`, `initialize`) VALUES (sqlc.arg(id), sqlc.arg(class), sqlc.arg(end_), sqlc.arg(hash), sqlc.arg(method), sqlc.arg(count), diff --git a/examples/mysql2/query_sql.rb b/examples/mysql2/query_sql.rb index 2534526..eeda748 100644 --- a/examples/mysql2/query_sql.rb +++ b/examples/mysql2/query_sql.rb @@ -404,6 +404,11 @@ class GetMysqlBindingValuesRow < ::Data.define( TRUNCATE TABLE mysql_binding_values SQL + # rubocop:disable Naming/VariableNumber + class CopyMysqlBindingTimesArgs < ::Data.define(:id, :datetime_value, :datetime_2) + end + # rubocop:enable Naming/VariableNumber + INSERT_NAME_CASE_SQL = <<~'SQL' INSERT INTO name_cases (id, `class`, `end`, `hash`, `method`, `count`, `camelCase`, `HTTPServer`, `initialize`) VALUES (?, ?, ?, ?, ?, ?, @@ -791,7 +796,7 @@ def copy_authors(rows) with_client('copy_authors') do |client| ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.name, row.bio])) + file.write(csv_line(client, [row.name, row.bio])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? @@ -944,7 +949,7 @@ def copy_regressions(rows) with_client('copy_regressions') do |client| ::Tempfile.create(['copy_regressions', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.order, row.note])) + file.write(csv_line(client, [row.order, row.note])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? @@ -957,7 +962,7 @@ def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.order, row.note, row.payload])) + file.write(csv_line(client, [row.id, row.order, row.note, row.payload])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? @@ -1029,7 +1034,7 @@ def copy_copy_types(rows) with_client('copy_copy_types') do |client| ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) + file.write(csv_line(client, [row.id, row.code, row.doc, row.flags, row.amount])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? @@ -1042,7 +1047,7 @@ def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? @@ -1055,7 +1060,7 @@ def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? @@ -1187,6 +1192,19 @@ def truncate_mysql_binding_values end end + def copy_mysql_binding_times(rows) + with_client('copy_mysql_binding_times') do |client| + ::Tempfile.create(['copy_mysql_binding_times', '.csv']) do |file| + rows.each do |row| + file.write(csv_line(client, [row.id, row.datetime_value, row.datetime_2])) + end + file.flush + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `mysql_binding_values` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `datetime_value`, `datetime_2`)") if file.pos.positive? + end + nil + end + end + def insert_name_case(id:, class_:, end_:, hash:, method:, count:, camel_case:, http_server:, initialize:) with_client('insert_name_case') do |client| query_params = [ @@ -1275,7 +1293,7 @@ def copy_mixed_case_copies(rows) with_client('copy_mixed_case_copies') do |client| ::Tempfile.create(['copy_mixed_case_copies', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.somevalue])) + file.write(csv_line(client, [row.id, row.somevalue])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`Id`, `SomeValue`)") if file.pos.positive? @@ -1288,7 +1306,7 @@ def copy_mixed_case_copies_unquoted(rows) with_client('copy_mixed_case_copies_unquoted') do |client| ::Tempfile.create(['copy_mixed_case_copies_unquoted', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.somevalue, row.id])) + file.write(csv_line(client, [row.somevalue, row.id])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`SomeValue`, `Id`)") if file.pos.positive? @@ -1451,7 +1469,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -1465,6 +1483,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 @@ -1520,13 +1544,13 @@ def bind_params(client, sql, params) end # Formats one row for LOAD DATA ... FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\'. - def csv_line(values) + def csv_line(client, values) fields = values.map do |value| case value when nil then '\N' when true then '1' when false then '0' - when ::Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') + when ::Time, ::DateTime then mysql_time(client, value) else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""').gsub("\x1A") { '\Z' }}") end end diff --git a/examples/mysql2/query_sql.rbs b/examples/mysql2/query_sql.rbs index d139112..c774536 100644 --- a/examples/mysql2/query_sql.rbs +++ b/examples/mysql2/query_sql.rbs @@ -442,6 +442,13 @@ module Mysql2Codegen TRUNCATE_MYSQL_BINDING_VALUES_SQL: ::String + class CopyMysqlBindingTimesArgs + attr_reader id: ::Integer + attr_reader datetime_value: ::Time? + attr_reader datetime_2: ::Time? + def initialize: ( ::Integer id, ::Time? datetime_value, ::Time? datetime_2) -> void + end + INSERT_NAME_CASE_SQL: ::String GET_NAME_CASE_SQL: ::String @@ -686,6 +693,8 @@ module Mysql2Codegen def truncate_mysql_binding_values: -> void + def copy_mysql_binding_times: (::Array[CopyMysqlBindingTimesArgs] rows) -> void + def insert_name_case: (id: ::Integer, class_: ::String, end_: ::String, hash: ::String, method: ::String, count: ::Integer, camel_case: ::String, http_server: ::String, initialize: ::String) -> void def get_name_case: (id: ::Integer, class_: ::String, end_: ::String, hash: ::String, method: ::String, count: ::Integer, http_server: ::String, camel_case: ::String, initialize: ::String) -> GetNameCaseRow? @@ -738,9 +747,11 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String - def csv_line: (::Array[untyped] values) -> ::String + def csv_line: (untyped client, ::Array[untyped] values) -> ::String def load_data: (untyped client, ::String sql, ?strict: bool) -> void diff --git a/lib/sqlc_gen_ruby/drivers/base.rb b/lib/sqlc_gen_ruby/drivers/base.rb index fce8b65..092898e 100644 --- a/lib/sqlc_gen_ruby/drivers/base.rb +++ b/lib/sqlc_gen_ruby/drivers/base.rb @@ -296,7 +296,7 @@ def table_name(identifier) TIMEOUT_EXIT_HELPER = <<~'RUBY'.chomp def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end RUBY TIMEOUT_EXIT_HELPER_RBS = 'def timeout_exits_with_throw?: () -> bool' diff --git a/lib/sqlc_gen_ruby/drivers/mysql2.rb b/lib/sqlc_gen_ruby/drivers/mysql2.rb index ca468f3..99471c5 100644 --- a/lib/sqlc_gen_ruby/drivers/mysql2.rb +++ b/lib/sqlc_gen_ruby/drivers/mysql2.rb @@ -91,7 +91,7 @@ def copy_from_lines(rows_var, table, columns, values, label, query_name:, source 'without ON DUPLICATE KEY UPDATE' end - write = Code.block("#{rows_var}.each do |row|", "file.write(csv_line([#{values.join(', ')}]))") + write = Code.block("#{rows_var}.each do |row|", "file.write(csv_line(client, [#{values.join(', ')}]))") load_data = format(LOAD_DATA, table: table, **load_columns(columns, sources), mode: load_mode(verb), strict: verb.split.include?('IGNORE') ? ', strict: false' : '') [ @@ -125,8 +125,9 @@ def extra_gems(_queries) = %w[bigdecimal date] def helper_methods(queries) helpers = [[QUOTE, 'def quote: (untyped client, untyped value) -> ::String'], + [MYSQL_TIME, 'def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String'], [BIND_PARAMS, 'def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String']] - helpers.concat([[CSV_LINE, 'def csv_line: (::Array[untyped] values) -> ::String'], + helpers.concat([[CSV_LINE, 'def csv_line: (untyped client, ::Array[untyped] values) -> ::String'], [LOAD_DATA_HELPER, 'def load_data: (untyped client, ::String sql, ?strict: bool) -> void']]) if copy_from?(queries) helpers end @@ -306,13 +307,13 @@ def load_data(client, sql, strict: true) # library reads the file in text mode on Windows, where a raw Ctrl-Z ends it. CSV_LINE = <<~'RUBY'.chomp # Formats one row for LOAD DATA ... FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\'. - def csv_line(values) + def csv_line(client, values) fields = values.map do |value| case value when nil then '\N' when true then '1' when false then '0' - when ::Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') + when ::Time, ::DateTime then mysql_time(client, value) else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""').gsub("\x1A") { '\Z' }}") end end @@ -320,6 +321,16 @@ def csv_line(values) end RUBY + # mysql2 reads DATETIME / TIMESTAMP values as wall-clock times in its database_timezone (:local unless the + # client was created with database_timezone: :utc), so a Time is written in that zone to keep its instant. + MYSQL_TIME = <<~'RUBY'.chomp + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + RUBY + QUOTE = <<~'RUBY'.chomp def quote(client, value) case value @@ -337,7 +348,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String diff --git a/lib/sqlc_gen_ruby/options.rb b/lib/sqlc_gen_ruby/options.rb index 2c6d465..5e01251 100644 --- a/lib/sqlc_gen_ruby/options.rb +++ b/lib/sqlc_gen_ruby/options.rb @@ -7,6 +7,7 @@ module SqlcGenRuby # Port of PluginOptions/Options.cs + RawOptions.cs: parses the JSON `options:` block of the sqlc config. class Options DRIVERS = %w[mysql2 pg sqlite3].freeze + CORE_MODULES = %w[BasicObject Object Kernel].freeze KNOWN = %w[driver generateGemfile generateTypes rubyVersion overrides moduleName preparedStatements rename exactTableNames].freeze OVERRIDE_KEYS = %w[column db_type ruby_type].freeze @@ -149,6 +150,9 @@ def fetch_module_name(raw) unless name.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/) raise ArgumentError, "option moduleName must be a Ruby constant path (e.g. MyApp::Db), got #{name.inspect}" end + if CORE_MODULES.include?(name.split('::').first) + raise ArgumentError, "option moduleName must not reopen #{name.split('::').first} (every object would get the generated constants)" + end name end diff --git a/steep_expectations.yml b/steep_expectations.yml index d9bbc34..6c30c0e 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -3,90 +3,90 @@ diagnostics: - range: start: - line: 524 + line: 529 character: 12 end: - line: 524 + line: 529 character: 26 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 524 + line: 529 character: 51 end: - line: 524 + line: 529 character: 57 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 524 + line: 529 character: 70 end: - line: 524 + line: 529 character: 85 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 551 + line: 556 character: 54 end: - line: 605 + line: 610 character: 11 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 563 + line: 568 character: 62 end: - line: 565 + line: 570 character: 15 severity: WARNING message: The method cannot be called with a block code: Ruby::UnexpectedBlockGiven - range: start: - line: 571 + line: 576 character: 21 end: - line: 571 + line: 576 character: 27 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 574 + line: 579 character: 37 end: - line: 574 + line: 579 character: 43 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 594 + line: 599 character: 29 end: - line: 594 + line: 599 character: 35 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 1492 + line: 1516 character: 20 end: - line: 1492 + line: 1516 character: 38 severity: ERROR message: |- @@ -99,20 +99,20 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1493 + line: 1517 character: 78 end: - line: 1493 + line: 1517 character: 84 severity: ERROR message: Type `(::String | nil)` does not have method `match?` code: Ruby::NoMethod - range: start: - line: 1495 + line: 1519 character: 20 end: - line: 1495 + line: 1519 character: 39 severity: ERROR message: |- @@ -125,10 +125,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1499 + line: 1523 character: 20 end: - line: 1499 + line: 1523 character: 39 severity: ERROR message: |- @@ -141,10 +141,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1504 + line: 1528 character: 20 end: - line: 1504 + line: 1528 character: 39 severity: ERROR message: |- @@ -157,10 +157,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1513 + line: 1537 character: 20 end: - line: 1513 + line: 1537 character: 24 severity: ERROR message: |- @@ -173,10 +173,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1546 + line: 1570 character: 18 end: - line: 1546 + line: 1570 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' diff --git a/test/features_test.rb b/test/features_test.rb index f36a20a..e3da9d2 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -198,7 +198,7 @@ def test_mysql_copyfrom_writes_its_own_csv assert_includes files.fetch('query_sql.rb'), "require 'tempfile'" assert_includes files.fetch('query_sql.rb'), "require 'bigdecimal'" assert_includes files.fetch('query_sql.rb'), "require 'date'" - assert_includes files.fetch('query_sql.rb'), 'def csv_line(values)' + assert_includes files.fetch('query_sql.rb'), 'def csv_line(client, values)' assert_equal "# frozen_string_literal: true\n\nsource 'https://rubygems.org'\n\ngem 'bigdecimal'\ngem 'connection_pool'\ngem 'date'\ngem 'mysql2'\n", files.fetch('Gemfile') end diff --git a/test/fixtures/mysql2.bin b/test/fixtures/mysql2.bin index 3b4fdf6..e278824 100644 --- a/test/fixtures/mysql2.bin +++ b/test/fixtures/mysql2.bin @@ -549,7 +549,15 @@ Rmysql_binding_valuesbtimez time_value"G json_unicode0ÿÿÿÿÿÿÿÿÿRmysql_binding_valuesbjsonz json_unicode*=9 id0ÿÿÿÿÿÿÿÿÿ8Rmysql_binding_valuesbbigintzid: query.sqlS -#TRUNCATE TABLE mysql_binding_valuesTruncateMysqlBindingValues:exec: query.sql¬ +#TRUNCATE TABLE mysql_binding_valuesTruncateMysqlBindingValues:exec: query.sqlþ +RINSERT INTO mysql_binding_values (id, datetime_value, datetime_2) VALUES (?, ?, ?)CopyMysqlBindingTimes :copyfrom*C? +id0ÿÿÿÿÿÿÿÿÿRpublicmysql_binding_valuesbbigintzid*RN +datetime_value0Rpublicmysql_binding_valuesb +datetimezdatetime_value*JF + +datetime_20Rpublicmysql_binding_valuesb +datetimez +datetime_2: query.sqlBmysql_binding_values¬ šINSERT INTO name_cases (id, `class`, `end`, `hash`, `method`, `count`, `camelCase`, `HTTPServer`, `initialize`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)InsertNameCase:exec*;7 diff --git a/test/golden/mysql2/default/my_queries_sql.rb b/test/golden/mysql2/default/my_queries_sql.rb index 6f04635..e2e18f5 100644 --- a/test/golden/mysql2/default/my_queries_sql.rb +++ b/test/golden/mysql2/default/my_queries_sql.rb @@ -171,7 +171,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -185,6 +185,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 diff --git a/test/golden/mysql2/default/my_queries_sql.rbs b/test/golden/mysql2/default/my_queries_sql.rbs index 729a392..7f4e929 100644 --- a/test/golden/mysql2/default/my_queries_sql.rbs +++ b/test/golden/mysql2/default/my_queries_sql.rbs @@ -24,6 +24,8 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String end end diff --git a/test/golden/mysql2/default/other_sql.rb b/test/golden/mysql2/default/other_sql.rb index 242cdb8..e67f869 100644 --- a/test/golden/mysql2/default/other_sql.rb +++ b/test/golden/mysql2/default/other_sql.rb @@ -211,7 +211,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -225,6 +225,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 diff --git a/test/golden/mysql2/default/other_sql.rbs b/test/golden/mysql2/default/other_sql.rbs index f96c370..774a32f 100644 --- a/test/golden/mysql2/default/other_sql.rbs +++ b/test/golden/mysql2/default/other_sql.rbs @@ -39,6 +39,8 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String end end diff --git a/test/golden/mysql2/default/query_sql.rb b/test/golden/mysql2/default/query_sql.rb index ddd4ef0..d025567 100644 --- a/test/golden/mysql2/default/query_sql.rb +++ b/test/golden/mysql2/default/query_sql.rb @@ -404,6 +404,11 @@ class GetMysqlBindingValuesRow < ::Data.define( TRUNCATE TABLE mysql_binding_values SQL + # rubocop:disable Naming/VariableNumber + class CopyMysqlBindingTimesArgs < ::Data.define(:id, :datetime_value, :datetime_2) + end + # rubocop:enable Naming/VariableNumber + INSERT_NAME_CASE_SQL = <<~'SQL' INSERT INTO name_cases (id, `class`, `end`, `hash`, `method`, `count`, `camelCase`, `HTTPServer`, `initialize`) VALUES (?, ?, ?, ?, ?, ?, @@ -821,7 +826,7 @@ def copy_authors(rows) with_client('copy_authors') do |client| ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.name, row.bio])) + file.write(csv_line(client, [row.name, row.bio])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? @@ -974,7 +979,7 @@ def copy_regressions(rows) with_client('copy_regressions') do |client| ::Tempfile.create(['copy_regressions', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.order, row.note])) + file.write(csv_line(client, [row.order, row.note])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? @@ -987,7 +992,7 @@ def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.order, row.note, row.payload])) + file.write(csv_line(client, [row.id, row.order, row.note, row.payload])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? @@ -1059,7 +1064,7 @@ def copy_copy_types(rows) with_client('copy_copy_types') do |client| ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) + file.write(csv_line(client, [row.id, row.code, row.doc, row.flags, row.amount])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? @@ -1072,7 +1077,7 @@ def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? @@ -1085,7 +1090,7 @@ def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? @@ -1217,6 +1222,19 @@ def truncate_mysql_binding_values end end + def copy_mysql_binding_times(rows) + with_client('copy_mysql_binding_times') do |client| + ::Tempfile.create(['copy_mysql_binding_times', '.csv']) do |file| + rows.each do |row| + file.write(csv_line(client, [row.id, row.datetime_value, row.datetime_2])) + end + file.flush + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `mysql_binding_values` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `datetime_value`, `datetime_2`)") if file.pos.positive? + end + nil + end + end + def insert_name_case(id:, class_:, end_:, hash:, method:, count:, camel_case:, http_server:, initialize:) with_client('insert_name_case') do |client| query_params = [ @@ -1305,7 +1323,7 @@ def copy_mixed_case_copies(rows) with_client('copy_mixed_case_copies') do |client| ::Tempfile.create(['copy_mixed_case_copies', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.somevalue])) + file.write(csv_line(client, [row.id, row.somevalue])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`Id`, `SomeValue`)") if file.pos.positive? @@ -1318,7 +1336,7 @@ def copy_mixed_case_copies_unquoted(rows) with_client('copy_mixed_case_copies_unquoted') do |client| ::Tempfile.create(['copy_mixed_case_copies_unquoted', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.somevalue, row.id])) + file.write(csv_line(client, [row.somevalue, row.id])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`SomeValue`, `Id`)") if file.pos.positive? @@ -1526,7 +1544,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -1540,6 +1558,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 @@ -1595,13 +1619,13 @@ def bind_params(client, sql, params) end # Formats one row for LOAD DATA ... FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\'. - def csv_line(values) + def csv_line(client, values) fields = values.map do |value| case value when nil then '\N' when true then '1' when false then '0' - when ::Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') + when ::Time, ::DateTime then mysql_time(client, value) else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""').gsub("\x1A") { '\Z' }}") end end diff --git a/test/golden/mysql2/default/query_sql.rbs b/test/golden/mysql2/default/query_sql.rbs index 9916a66..4b2af12 100644 --- a/test/golden/mysql2/default/query_sql.rbs +++ b/test/golden/mysql2/default/query_sql.rbs @@ -442,6 +442,13 @@ module Mysql2Codegen TRUNCATE_MYSQL_BINDING_VALUES_SQL: ::String + class CopyMysqlBindingTimesArgs + attr_reader id: ::Integer + attr_reader datetime_value: ::Time? + attr_reader datetime_2: ::Time? + def initialize: ( ::Integer id, ::Time? datetime_value, ::Time? datetime_2) -> void + end + INSERT_NAME_CASE_SQL: ::String GET_NAME_CASE_SQL: ::String @@ -721,6 +728,8 @@ module Mysql2Codegen def truncate_mysql_binding_values: -> void + def copy_mysql_binding_times: (::Array[CopyMysqlBindingTimesArgs] rows) -> void + def insert_name_case: (id: ::Integer, class_: ::String, end_: ::String, hash: ::String, method: ::String, count: ::Integer, camel_case: ::String, http_server: ::String, initialize: ::String) -> void def get_name_case: (id: ::Integer, class_: ::String, end_: ::String, hash: ::String, method: ::String, count: ::Integer, http_server: ::String, camel_case: ::String, initialize: ::String) -> GetNameCaseRow? @@ -781,9 +790,11 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String - def csv_line: (::Array[untyped] values) -> ::String + def csv_line: (untyped client, ::Array[untyped] values) -> ::String def load_data: (untyped client, ::String sql, ?strict: bool) -> void diff --git a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb index 396cbbf..541bc12 100644 --- a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb @@ -160,7 +160,7 @@ def bound_to(connection) = dup.tap { |tx| tx.instance_variable_set(:@db, connect def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def quote(client, value) @@ -179,7 +179,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -193,6 +193,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 diff --git a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rbs b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rbs index c7b7e0c..f7b21a3 100644 --- a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rbs +++ b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rbs @@ -26,6 +26,8 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String end end diff --git a/test/golden/mysql2/ruby31_gemfile/other_sql.rb b/test/golden/mysql2/ruby31_gemfile/other_sql.rb index 79e644f..00cb5ea 100644 --- a/test/golden/mysql2/ruby31_gemfile/other_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/other_sql.rb @@ -199,7 +199,7 @@ def bound_to(connection) = dup.tap { |tx| tx.instance_variable_set(:@db, connect def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def quote(client, value) @@ -218,7 +218,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -232,6 +232,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 diff --git a/test/golden/mysql2/ruby31_gemfile/other_sql.rbs b/test/golden/mysql2/ruby31_gemfile/other_sql.rbs index 0f1ec19..c9c1699 100644 --- a/test/golden/mysql2/ruby31_gemfile/other_sql.rbs +++ b/test/golden/mysql2/ruby31_gemfile/other_sql.rbs @@ -42,6 +42,8 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String end end diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rb b/test/golden/mysql2/ruby31_gemfile/query_sql.rb index eb8f9e1..b8fb3ff 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rb @@ -401,6 +401,10 @@ module Mysql2Codegen TRUNCATE TABLE mysql_binding_values SQL + # rubocop:disable Naming/VariableNumber + CopyMysqlBindingTimesArgs = ::Struct.new(:id, :datetime_value, :datetime_2, keyword_init: true) + # rubocop:enable Naming/VariableNumber + INSERT_NAME_CASE_SQL = <<~'SQL' INSERT INTO name_cases (id, `class`, `end`, `hash`, `method`, `count`, `camelCase`, `HTTPServer`, `initialize`) VALUES (?, ?, ?, ?, ?, ?, @@ -816,7 +820,7 @@ def copy_authors(rows) with_client('copy_authors') do |client| ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.name, row.bio])) + file.write(csv_line(client, [row.name, row.bio])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? @@ -982,7 +986,7 @@ def copy_regressions(rows) with_client('copy_regressions') do |client| ::Tempfile.create(['copy_regressions', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.order, row.note])) + file.write(csv_line(client, [row.order, row.note])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? @@ -995,7 +999,7 @@ def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.order, row.note, row.payload])) + file.write(csv_line(client, [row.id, row.order, row.note, row.payload])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? @@ -1067,7 +1071,7 @@ def copy_copy_types(rows) with_client('copy_copy_types') do |client| ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) + file.write(csv_line(client, [row.id, row.code, row.doc, row.flags, row.amount])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? @@ -1080,7 +1084,7 @@ def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? @@ -1093,7 +1097,7 @@ def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? @@ -1227,6 +1231,19 @@ def truncate_mysql_binding_values end end + def copy_mysql_binding_times(rows) + with_client('copy_mysql_binding_times') do |client| + ::Tempfile.create(['copy_mysql_binding_times', '.csv']) do |file| + rows.each do |row| + file.write(csv_line(client, [row.id, row.datetime_value, row.datetime_2])) + end + file.flush + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `mysql_binding_values` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `datetime_value`, `datetime_2`)") if file.pos.positive? + end + nil + end + end + def insert_name_case(id:, class_:, end_:, hash:, method:, count:, camel_case:, http_server:, initialize:) with_client('insert_name_case') do |client| query_params = [ @@ -1315,7 +1332,7 @@ def copy_mixed_case_copies(rows) with_client('copy_mixed_case_copies') do |client| ::Tempfile.create(['copy_mixed_case_copies', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.somevalue])) + file.write(csv_line(client, [row.id, row.somevalue])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`Id`, `SomeValue`)") if file.pos.positive? @@ -1328,7 +1345,7 @@ def copy_mixed_case_copies_unquoted(rows) with_client('copy_mixed_case_copies_unquoted') do |client| ::Tempfile.create(['copy_mixed_case_copies_unquoted', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.somevalue, row.id])) + file.write(csv_line(client, [row.somevalue, row.id])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`SomeValue`, `Id`)") if file.pos.positive? @@ -1524,7 +1541,7 @@ def bound_to(connection) = dup.tap { |tx| tx.instance_variable_set(:@db, connect def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def quote(client, value) @@ -1543,7 +1560,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -1557,6 +1574,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 @@ -1612,13 +1635,13 @@ def bind_params(client, sql, params) end # Formats one row for LOAD DATA ... FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\'. - def csv_line(values) + def csv_line(client, values) fields = values.map do |value| case value when nil then '\N' when true then '1' when false then '0' - when ::Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') + when ::Time, ::DateTime then mysql_time(client, value) else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""').gsub("\x1A") { '\Z' }}") end end diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rbs b/test/golden/mysql2/ruby31_gemfile/query_sql.rbs index 399400e..0409243 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rbs +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rbs @@ -546,6 +546,14 @@ module Mysql2Codegen TRUNCATE_MYSQL_BINDING_VALUES_SQL: ::String + class CopyMysqlBindingTimesArgs < ::Struct[untyped] + attr_accessor id: ::Integer + attr_accessor datetime_value: ::Time? + attr_accessor datetime_2: ::Time? + def initialize: ( id: ::Integer, datetime_value: ::Time?, datetime_2: ::Time?) -> void + def self.new: ( id: ::Integer, datetime_value: ::Time?, datetime_2: ::Time?) -> CopyMysqlBindingTimesArgs + end + INSERT_NAME_CASE_SQL: ::String GET_NAME_CASE_SQL: ::String @@ -865,6 +873,8 @@ module Mysql2Codegen def truncate_mysql_binding_values: -> void + def copy_mysql_binding_times: (::Array[CopyMysqlBindingTimesArgs] rows) -> void + def insert_name_case: (id: ::Integer, class_: ::String, end_: ::String, hash: ::String, method: ::String, count: ::Integer, camel_case: ::String, http_server: ::String, initialize: ::String) -> void def get_name_case: (id: ::Integer, class_: ::String, end_: ::String, hash: ::String, method: ::String, count: ::Integer, http_server: ::String, camel_case: ::String, initialize: ::String) -> GetNameCaseRow? @@ -927,9 +937,11 @@ module Mysql2Codegen def quote: (untyped client, untyped value) -> ::String + def mysql_time: (untyped client, ::Time | ::DateTime value) -> ::String + def bind_params: (untyped client, ::String sql, ::Array[untyped] params) -> ::String - def csv_line: (::Array[untyped] values) -> ::String + def csv_line: (untyped client, ::Array[untyped] values) -> ::String def load_data: (untyped client, ::String sql, ?strict: bool) -> void diff --git a/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb b/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb index 396cbbf..541bc12 100644 --- a/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb @@ -160,7 +160,7 @@ def bound_to(connection) = dup.tap { |tx| tx.instance_variable_set(:@db, connect def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def quote(client, value) @@ -179,7 +179,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -193,6 +193,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 diff --git a/test/golden/mysql2/ruby32_notypes/other_sql.rb b/test/golden/mysql2/ruby32_notypes/other_sql.rb index 6336980..ea09242 100644 --- a/test/golden/mysql2/ruby32_notypes/other_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/other_sql.rb @@ -200,7 +200,7 @@ def bound_to(connection) = dup.tap { |tx| tx.instance_variable_set(:@db, connect def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def quote(client, value) @@ -219,7 +219,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -233,6 +233,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 diff --git a/test/golden/mysql2/ruby32_notypes/query_sql.rb b/test/golden/mysql2/ruby32_notypes/query_sql.rb index 86df827..ded568f 100644 --- a/test/golden/mysql2/ruby32_notypes/query_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/query_sql.rb @@ -404,6 +404,11 @@ class GetMysqlBindingValuesRow < ::Data.define( TRUNCATE TABLE mysql_binding_values SQL + # rubocop:disable Naming/VariableNumber + class CopyMysqlBindingTimesArgs < ::Data.define(:id, :datetime_value, :datetime_2) + end + # rubocop:enable Naming/VariableNumber + INSERT_NAME_CASE_SQL = <<~'SQL' INSERT INTO name_cases (id, `class`, `end`, `hash`, `method`, `count`, `camelCase`, `HTTPServer`, `initialize`) VALUES (?, ?, ?, ?, ?, ?, @@ -824,7 +829,7 @@ def copy_authors(rows) with_client('copy_authors') do |client| ::Tempfile.create(['copy_authors', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.name, row.bio])) + file.write(csv_line(client, [row.name, row.bio])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `authors` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`name`, `bio`)") if file.pos.positive? @@ -977,7 +982,7 @@ def copy_regressions(rows) with_client('copy_regressions') do |client| ::Tempfile.create(['copy_regressions', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.order, row.note])) + file.write(csv_line(client, [row.order, row.note])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`order`, `note`)") if file.pos.positive? @@ -990,7 +995,7 @@ def copy_regression_rows(rows) with_client('copy_regression_rows') do |client| ::Tempfile.create(['copy_regression_rows', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.order, row.note, row.payload])) + file.write(csv_line(client, [row.id, row.order, row.note, row.payload])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `regressions` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `order`, `note`, `payload`)") if file.pos.positive? @@ -1062,7 +1067,7 @@ def copy_copy_types(rows) with_client('copy_copy_types') do |client| ::Tempfile.create(['copy_copy_types', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code, row.doc, row.flags, row.amount])) + file.write(csv_line(client, [row.id, row.code, row.doc, row.flags, row.amount])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`, @sqlc_v2, `flags`, `amount`) SET `doc` = CONVERT(@sqlc_v2 USING utf8mb4)") if file.pos.positive? @@ -1075,7 +1080,7 @@ def copy_copy_types_ignore(rows) with_client('copy_copy_types_ignore') do |client| ::Tempfile.create(['copy_copy_types_ignore', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' IGNORE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)", strict: false) if file.pos.positive? @@ -1088,7 +1093,7 @@ def copy_copy_types_replace(rows) with_client('copy_copy_types_replace') do |client| ::Tempfile.create(['copy_copy_types_replace', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.code])) + file.write(csv_line(client, [row.id, row.code])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' REPLACE INTO TABLE `copy_types` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `code`)") if file.pos.positive? @@ -1220,6 +1225,19 @@ def truncate_mysql_binding_values end end + def copy_mysql_binding_times(rows) + with_client('copy_mysql_binding_times') do |client| + ::Tempfile.create(['copy_mysql_binding_times', '.csv']) do |file| + rows.each do |row| + file.write(csv_line(client, [row.id, row.datetime_value, row.datetime_2])) + end + file.flush + load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `mysql_binding_values` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`id`, `datetime_value`, `datetime_2`)") if file.pos.positive? + end + nil + end + end + def insert_name_case(id:, class_:, end_:, hash:, method:, count:, camel_case:, http_server:, initialize:) with_client('insert_name_case') do |client| query_params = [ @@ -1308,7 +1326,7 @@ def copy_mixed_case_copies(rows) with_client('copy_mixed_case_copies') do |client| ::Tempfile.create(['copy_mixed_case_copies', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.id, row.somevalue])) + file.write(csv_line(client, [row.id, row.somevalue])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`Id`, `SomeValue`)") if file.pos.positive? @@ -1321,7 +1339,7 @@ def copy_mixed_case_copies_unquoted(rows) with_client('copy_mixed_case_copies_unquoted') do |client| ::Tempfile.create(['copy_mixed_case_copies_unquoted', '.csv']) do |file| rows.each do |row| - file.write(csv_line([row.somevalue, row.id])) + file.write(csv_line(client, [row.somevalue, row.id])) end file.flush load_data(client, "LOAD DATA LOCAL INFILE '#{client.escape(file.path)}' INTO TABLE `MixedCaseCopies` CHARACTER SET binary FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' ESCAPED BY '\\\\' LINES TERMINATED BY '\\n' (`SomeValue`, `Id`)") if file.pos.positive? @@ -1515,7 +1533,7 @@ def bound_to(connection) = dup.tap { |tx| tx.instance_variable_set(:@db, connect def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def quote(client, value) @@ -1534,7 +1552,7 @@ def quote(client, value) value.to_s('F') when ::DateTime, ::Time - "'#{value.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" + "'#{mysql_time(client, value)}'" when ::Date "'#{value.strftime('%Y-%m-%d')}'" when ::String @@ -1548,6 +1566,12 @@ def quote(client, value) end end + def mysql_time(client, value) + time = value.is_a?(::DateTime) ? value.to_time : value + time = client.query_options[:database_timezone] == :utc ? time.getutc : time.getlocal + time.strftime('%Y-%m-%d %H:%M:%S.%6N') + end + def bind_params(client, sql, params) output = +'' index = 0 @@ -1603,13 +1627,13 @@ def bind_params(client, sql, params) end # Formats one row for LOAD DATA ... FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\'. - def csv_line(values) + def csv_line(client, values) fields = values.map do |value| case value when nil then '\N' when true then '1' when false then '0' - when ::Time then value.strftime('%Y-%m-%d %H:%M:%S.%6N') + when ::Time, ::DateTime then mysql_time(client, value) else %("#{value.to_s.b.gsub('\\') { '\\\\' }.gsub('"', '""').gsub("\x1A") { '\Z' }}") end end diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb index 423e662..0883ad5 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb @@ -200,7 +200,7 @@ def prepare(client, name, sql) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end end end diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rb b/test/golden/pg/ruby31_gemfile/other_sql.rb index b091a5b..b3df9bd 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rb +++ b/test/golden/pg/ruby31_gemfile/other_sql.rb @@ -239,7 +239,7 @@ def prepare(client, name, sql) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index 887f150..12db3b4 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -1768,7 +1768,7 @@ def prepare(client, name, sql) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. diff --git a/test/golden/pg/ruby32_notypes/my_queries_sql.rb b/test/golden/pg/ruby32_notypes/my_queries_sql.rb index 322dc60..48fc3a7 100644 --- a/test/golden/pg/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/pg/ruby32_notypes/my_queries_sql.rb @@ -199,7 +199,7 @@ def prepare(client, name, sql) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end end end diff --git a/test/golden/pg/ruby32_notypes/other_sql.rb b/test/golden/pg/ruby32_notypes/other_sql.rb index 4139084..bbd9695 100644 --- a/test/golden/pg/ruby32_notypes/other_sql.rb +++ b/test/golden/pg/ruby32_notypes/other_sql.rb @@ -239,7 +239,7 @@ def prepare(client, name, sql) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index 344d94e..d78eabe 100644 --- a/test/golden/pg/ruby32_notypes/query_sql.rb +++ b/test/golden/pg/ruby32_notypes/query_sql.rb @@ -1765,7 +1765,7 @@ def prepare(client, name, sql) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end # bytea parameters are sent escaped: binary Strings (NUL bytes, invalid UTF-8) are not valid text. diff --git a/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb b/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb index e47c44c..49bdca7 100644 --- a/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/my_queries_sql.rb @@ -123,7 +123,7 @@ def execute_statement(stmt, *params) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end end end diff --git a/test/golden/sqlite3/ruby31_gemfile/other_sql.rb b/test/golden/sqlite3/ruby31_gemfile/other_sql.rb index 575dc14..b63c3667 100644 --- a/test/golden/sqlite3/ruby31_gemfile/other_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/other_sql.rb @@ -172,7 +172,7 @@ def execute_statement(stmt, *params) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end end end diff --git a/test/golden/sqlite3/ruby31_gemfile/query_sql.rb b/test/golden/sqlite3/ruby31_gemfile/query_sql.rb index a167a28..61e6600 100644 --- a/test/golden/sqlite3/ruby31_gemfile/query_sql.rb +++ b/test/golden/sqlite3/ruby31_gemfile/query_sql.rb @@ -1529,7 +1529,7 @@ def execute_statement(stmt, *params) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def atomically(client) diff --git a/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb b/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb index e47c44c..49bdca7 100644 --- a/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/my_queries_sql.rb @@ -123,7 +123,7 @@ def execute_statement(stmt, *params) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end end end diff --git a/test/golden/sqlite3/ruby32_notypes/other_sql.rb b/test/golden/sqlite3/ruby32_notypes/other_sql.rb index 79c2691..c31e5fa 100644 --- a/test/golden/sqlite3/ruby32_notypes/other_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/other_sql.rb @@ -173,7 +173,7 @@ def execute_statement(stmt, *params) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end end end diff --git a/test/golden/sqlite3/ruby32_notypes/query_sql.rb b/test/golden/sqlite3/ruby32_notypes/query_sql.rb index 3685cb0..e60c604 100644 --- a/test/golden/sqlite3/ruby32_notypes/query_sql.rb +++ b/test/golden/sqlite3/ruby32_notypes/query_sql.rb @@ -1542,7 +1542,7 @@ def execute_statement(stmt, *params) def timeout_exits_with_throw? !!(defined?(::Timeout::VERSION) && - (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]).negative?) + (::Timeout::VERSION.split('.').map(&:to_i) <=> [0, 4]) == -1) end def atomically(client) diff --git a/test/options_test.rb b/test/options_test.rb index 94918ee..13705ab 100644 --- a/test/options_test.rb +++ b/test/options_test.rb @@ -28,6 +28,14 @@ def test_unknown_override_keys_are_rejected assert_equal 'unknown override keys: colum (did you mean column?); supported: column, db_type, ruby_type', err.message end + def test_module_name_must_not_reopen_core_modules + %w[Kernel Object Kernel::Db BasicObject::X].each do |name| + err = assert_raises(ArgumentError) { opts(driver: 'pg', moduleName: name) } + assert_match(/must not reopen/, err.message) + end + assert_equal 'KernelDb', opts(driver: 'pg', moduleName: 'KernelDb').module_name + end + def test_version_bounds %w[3.1 3.2 3.3 3.4 4.0 3 4].each { |v| RubyVersion.parse(v) } err = assert_raises(ArgumentError) { RubyVersion.parse('4.1') } diff --git a/tests/end2end_mysql2.rb b/tests/end2end_mysql2.rb index 4dcbd1a..358c6d6 100755 --- a/tests/end2end_mysql2.rb +++ b/tests/end2end_mysql2.rb @@ -92,6 +92,31 @@ def setup @query_sql.truncate_exceptions end + def with_time_zone(zone) + saved = ENV.fetch('TZ', nil) + ENV['TZ'] = zone + yield + ensure + saved ? ENV['TZ'] = saved : ENV.delete('TZ') + end + + # Writes each [datetime_value, datetime_2] pair with a bound :exec and with :copyfrom and reads it back. + def assert_times_round_trip(query_sql, pairs) + pairs.each_with_index do |(value, value2), i| + query_sql.insert_mysql_binding_values(**binding_values(i + 1).merge(datetime_value: value, datetime_2: value2)) + end + query_sql.copy_mysql_binding_times(pairs.each_with_index.map do |(value, value2), i| + CopyMysqlBindingTimesArgs.new(id: i + 101, datetime_value: value, datetime_2: value2) + end) + pairs.each_with_index do |(value, value2), i| + [i + 1, i + 101].each do |id| + row = query_sql.get_mysql_binding_values(id: id) + assert_equal value.to_time, row.datetime_value, "datetime_value of row #{id}" + assert_equal value2.to_time, row.datetime_2, "datetime_2 of row #{id}" + end + end + end + def create_author(name, bio = nil) @query_sql.create_author_return_id(name: name, bio: bio) end @@ -567,8 +592,8 @@ def test_client_side_quoting_round_trips_supported_values assert_equal decimal, row.decimal_value assert_equal float, row.float_value assert_equal Date.new(2024, 2, 3), row.date_value - assert_equal time.strftime('%Y-%m-%d %H:%M:%S.%6N'), row.datetime_value.strftime('%Y-%m-%d %H:%M:%S.%6N') - assert_equal date_time.strftime('%Y-%m-%d %H:%M:%S.%6N'), row.datetime_2.strftime('%Y-%m-%d %H:%M:%S.%6N') + assert_equal time, row.datetime_value + assert_equal date_time.to_time, row.datetime_2 assert_equal 1, row.bool_value assert_nil row.nullable_value assert_equal [0xAA, 0xAA].pack('C*'), row.bit_value @@ -583,20 +608,23 @@ def test_client_side_quoting_round_trips_supported_values end end - def test_time_values_match_mysql2_prepared_statement_storage - client = Mysql2::Client.new(mysql2_params) - time = ::Time.new(2024, 2, 3, 4, 5, Rational(6_123_456, 1_000_000), '+05:30') - date_time = DateTime.new(2024, 3, 4, 5, 6, Rational(7_654_321, 1_000_000), '-07:00') - statement = client.prepare('INSERT INTO mysql_binding_values (id, datetime_value, datetime_2) VALUES (?, ?, ?)') - statement.execute(1, time, date_time) - statement.close - @query_sql.insert_mysql_binding_values(**binding_values(2).merge(datetime_value: time, datetime_2: date_time)) - prepared = @query_sql.get_mysql_binding_values(id: 1) - quoted = @query_sql.get_mysql_binding_values(id: 2) - assert_equal prepared.datetime_value, quoted.datetime_value - assert_equal prepared.datetime_2, quoted.datetime_2 - ensure - client&.close + # mysql2 reads DATETIME values back in its database_timezone (the process zone unless set to :utc), so a Time or + # DateTime with another offset must keep its instant, through bound parameters and :copyfrom alike. + def test_time_values_keep_their_instant_whatever_their_offset + with_time_zone('XYZ-3') do # UTC+3, unlike the values below + assert_times_round_trip(@query_sql, [ + [::Time.new(2024, 2, 3, 4, 5, Rational(6_123_456, 1_000_000), '+05:30'), + DateTime.new(2024, 3, 4, 5, 6, Rational(7_654_321, 1_000_000), '-07:00')], + [::Time.now.utc.round(6), ::Time.now.round(6)] + ]) + end + end + + def test_time_values_follow_a_utc_database_timezone + query_sql = QuerySql.connect(mysql2_params.merge('database_timezone' => :utc)) + with_time_zone('XYZ-3') do + assert_times_round_trip(query_sql, [[::Time.now.round(6), DateTime.new(2024, 3, 4, 5, 6, 7, '+05:30')]]) + end end def test_injection_and_unsupported_parameters_are_safe