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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/Coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 3 additions & 0 deletions examples/authors/mysql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
46 changes: 35 additions & 11 deletions examples/mysql2/query_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 (?, ?, ?, ?, ?, ?,
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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?
Expand All @@ -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?
Expand Down Expand Up @@ -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?
Expand All @@ -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?
Expand All @@ -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?
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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?
Expand All @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion examples/mysql2/query_sql.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/sqlc_gen_ruby/drivers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading