From aed0be9f93e26fa3451664166f1c8ce0262c4bf3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:17:22 +0000 Subject: [PATCH 1/3] Fix wrong generated output: repeated params, reserved names, model/file/attribute names, outer-join embeds, nested and undecoded PG arrays Co-Authored-By: Doron Eli Rachman --- README.md | 5 +- examples/authors/postgresql/query.sql | 22 ++++ examples/authors/postgresql/schema.sql | 6 + examples/pg/models.rb | 3 + examples/pg/models.rbs | 7 ++ examples/pg/query_sql.rb | 111 +++++++++++++++++- examples/pg/query_sql.rbs | 42 +++++++ lib/sqlc_gen_ruby/catalog.rb | 18 ++- lib/sqlc_gen_ruby/code.rb | 25 ++++ lib/sqlc_gen_ruby/drivers/pg.rb | 8 +- lib/sqlc_gen_ruby/generator.rb | 33 +++++- lib/sqlc_gen_ruby/method_gen.rb | 29 ++++- lib/sqlc_gen_ruby/row_layout.rb | 13 +- lib/sqlc_gen_ruby/type_resolver.rb | 7 +- steep_expectations.yml | 62 +++++++--- test/features_test.rb | 65 ++++++++++ test/fixtures/pg.bin | Bin 11146 -> 13022 bytes test/golden/pg/default/models.rb | 3 + test/golden/pg/default/models.rbs | 7 ++ test/golden/pg/default/my_queries_sql.rb | 8 +- test/golden/pg/default/other_sql.rb | 8 +- test/golden/pg/default/query_sql.rb | 111 +++++++++++++++++- test/golden/pg/default/query_sql.rbs | 42 +++++++ test/golden/pg/ruby31_gemfile/models.rb | 3 + test/golden/pg/ruby31_gemfile/models.rbs | 7 ++ .../pg/ruby31_gemfile/my_queries_sql.rb | 8 +- test/golden/pg/ruby31_gemfile/other_sql.rb | 8 +- test/golden/pg/ruby31_gemfile/query_sql.rb | 111 +++++++++++++++++- test/golden/pg/ruby31_gemfile/query_sql.rbs | 42 +++++++ test/golden/pg/ruby32_notypes/models.rb | 3 + .../pg/ruby32_notypes/my_queries_sql.rb | 8 +- test/golden/pg/ruby32_notypes/other_sql.rb | 8 +- test/golden/pg/ruby32_notypes/query_sql.rb | 111 +++++++++++++++++- tests/end2end_pg.rb | 27 +++++ 34 files changed, 926 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index b2c2f3a..a1b49ac 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,14 @@ end | `sqlc.arg` | ✅ | ✅ | ✅ | names the attribute of the `Args` class (anonymous parameters such as `ANY($1::BIGINT[])` are called `param1`, `param2`, ...) | | `sqlc.narg` | ✅ | ✅ | ✅ | nullable parameter, typed `T?` | | `sqlc.slice` | 🚫 | ✅ | ✅ | the `IN (...)` list is expanded at runtime (an empty list matches nothing). PostgreSQL: pass an array to `= ANY($1::T[])` instead | -| `sqlc.embed` | ✅ | ✅ | ✅ | the row gets an attribute holding the table's model (`author`, `author2` for the same table twice) | +| `sqlc.embed` | ✅ | ✅ | ✅ | the row gets an attribute holding the table's model (`author`, `author2` for the same table twice). In a query with a `LEFT` / `RIGHT` / `FULL JOIN` the embedded models are optional (`Book?`) and `nil` when the join found no row | ### Other * **Models:** a `models.rb` / `models.rbs` with one `Data` (`Struct` below Ruby 3.2) class per table, and one module with constants per enum (`ExtendedBioType::MEMOIR`, `ExtendedBioType::ALL`). Enum columns are `String`s. * **Transactions:** `queries.transaction { |tx| tx.create_author(...) }` runs everything in the block on one connection in a transaction (commit when the block returns, rollback when it raises) and returns the block's value. Nested calls become savepoints; `isolation:` (`pg`, `mysql2`) / `mode:` (`sqlite3`) choose the isolation level / locking mode. * **Nullability:** nullable columns / parameters are typed `T?` in the `.rbs`. -* **Arrays (PostgreSQL):** `Array[T]` for columns and parameters. +* **Arrays (PostgreSQL):** `Array[T]` for columns and parameters (`Array[Array[T]]` for `T[][]`); arrays of types without a decoder (`uuid[]`, enum arrays, ...) are `Array[String]`. +* **Names:** a parameter repeated in a query gets a suffix (`id:`, `id_2:`); result columns named like a keyword or a method every row has (`class`, `hash`, `end`, ...) get a trailing `_` (`class_`); 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 rejected. * Types follow the Ruby classes the drivers actually return (e.g. `BigDecimal` for `numeric`/`decimal`, `Date`, `Time`, `IPAddr`; `json`/`jsonb` are parsed, so `untyped`). Not applicable to the Ruby plugin (they exist in the C# plugin because of its runtime): `withAsyncSuffix` / `withCancellationToken` (Ruby has no async/await: use the Fiber scheduler, see above), `useDapper`, target framework / csproj / namespace options (`moduleName` is the equivalent of the namespace). diff --git a/examples/authors/postgresql/query.sql b/examples/authors/postgresql/query.sql index b6d24be..a5071e4 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -101,3 +101,25 @@ SELECT * FROM postgres_extra_types LIMIT 1; -- name: TruncateExtraTypes :exec TRUNCATE TABLE postgres_extra_types; + +-- name: InsertArrayTypes :exec +INSERT INTO postgres_array_types (c_uuid_array, c_bio_type_array, c_integer_matrix) VALUES ($1, $2, $3); + +-- name: GetArrayTypes :one +SELECT * FROM postgres_array_types LIMIT 1; + +-- name: TruncateArrayTypes :exec +TRUNCATE TABLE postgres_array_types; + +-- name: ListAuthorsWithOptionalBook :many +SELECT + sqlc.embed(authors), + sqlc.embed(books) +FROM authors +LEFT JOIN books ON authors.id = books.author_id +ORDER BY authors.name; + +-- name: ListAuthorsBetweenIds :many +SELECT * FROM authors +WHERE id > $1 AND id < $2 +ORDER BY id; diff --git a/examples/authors/postgresql/schema.sql b/examples/authors/postgresql/schema.sql index 6540944..12af3e5 100644 --- a/examples/authors/postgresql/schema.sql +++ b/examples/authors/postgresql/schema.sql @@ -58,3 +58,9 @@ CREATE TABLE postgres_extra_types ( c_money MONEY, c_not_null TEXT NOT NULL DEFAULT 'x' ); + +CREATE TABLE postgres_array_types ( + c_uuid_array UUID [], + c_bio_type_array extended.bio_type [], + c_integer_matrix INTEGER [][] +); diff --git a/examples/pg/models.rb b/examples/pg/models.rb index 6d0eee8..0c2322c 100644 --- a/examples/pg/models.rb +++ b/examples/pg/models.rb @@ -50,6 +50,9 @@ class PostgresExtraType < Data.define( ) end + class PostgresArrayType < Data.define(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + class ExtendedBio < Data.define(:author_name, :name, :bio_type) end end diff --git a/examples/pg/models.rbs b/examples/pg/models.rbs index b97a45b..c4daf44 100644 --- a/examples/pg/models.rbs +++ b/examples/pg/models.rbs @@ -84,6 +84,13 @@ module PgCodegen ) -> void end + class PostgresArrayType + attr_reader c_uuid_array: Array[String]? + attr_reader c_bio_type_array: Array[String]? + attr_reader c_integer_matrix: Array[Array[Integer]]? + def initialize: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> void + end + class ExtendedBio attr_reader author_name: String attr_reader name: String diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index 9849d78..9b65e48 100644 --- a/examples/pg/query_sql.rb +++ b/examples/pg/query_sql.rb @@ -203,6 +203,42 @@ class GetExtraTypesRow < Data.define( TRUNCATE TABLE postgres_extra_types SQL + INSERT_ARRAY_TYPES_SQL = <<~'SQL' + INSERT INTO postgres_array_types (c_uuid_array, c_bio_type_array, c_integer_matrix) VALUES ($1, $2, $3) + SQL + + GET_ARRAY_TYPES_SQL = <<~'SQL' + SELECT c_uuid_array, c_bio_type_array, c_integer_matrix FROM postgres_array_types LIMIT 1 + SQL + + class GetArrayTypesRow < Data.define(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + + TRUNCATE_ARRAY_TYPES_SQL = <<~'SQL' + TRUNCATE TABLE postgres_array_types + SQL + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL = <<~'SQL' + SELECT + authors.id, authors.name, authors.bio, + books.id, books.name, books.author_id, books.description + FROM authors + LEFT JOIN books ON authors.id = books.author_id + ORDER BY authors.name + SQL + + class ListAuthorsWithOptionalBookRow < Data.define(:author, :book) + end + + LIST_AUTHORS_BETWEEN_IDS_SQL = <<~'SQL' + SELECT id, name, bio FROM authors + WHERE id > $1 AND id < $2 + ORDER BY id + SQL + + class ListAuthorsBetweenIdsRow < Data.define(:id, :name, :bio) + end + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -596,6 +632,73 @@ def truncate_extra_types end end + def insert_array_types(c_uuid_array:, c_bio_type_array:, c_integer_matrix:) + with_client('insert_array_types') do |client| + query_params = [c_uuid_array, c_bio_type_array, c_integer_matrix] + unless client.prepared_statements.include?('query_sql_insert_array_types') + client.prepare('query_sql_insert_array_types', INSERT_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_insert_array_types') + end + client.exec_prepared('query_sql_insert_array_types', query_params) + nil + end + end + + def get_array_types + with_client('get_array_types') do |client| + unless client.prepared_statements.include?('query_sql_get_array_types') + client.prepare('query_sql_get_array_types', GET_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_get_array_types') + end + result = client.exec_prepared('query_sql_get_array_types') + row = result.first + row && GetArrayTypesRow.new(row['c_uuid_array'], row['c_bio_type_array'], row['c_integer_matrix']) + end + end + + # Like #get_array_types, but raises NotFound when there is no row. + def get_array_types!(...) + get_array_types(...) || raise(NotFound, 'GetArrayTypes returned no row') + end + + def truncate_array_types + with_client('truncate_array_types') do |client| + unless client.prepared_statements.include?('query_sql_truncate_array_types') + client.prepare('query_sql_truncate_array_types', TRUNCATE_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_truncate_array_types') + end + client.exec_prepared('query_sql_truncate_array_types') + nil + end + end + + def list_authors_with_optional_book + with_client('list_authors_with_optional_book') do |client| + unless client.prepared_statements.include?('query_sql_list_authors_with_optional_book') + client.prepare('query_sql_list_authors_with_optional_book', LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL) + client.prepared_statements.add('query_sql_list_authors_with_optional_book') + end + result = client.exec_prepared('query_sql_list_authors_with_optional_book').values + result.map do |row| + ListAuthorsWithOptionalBookRow.new(row[0].nil? && row[1].nil? ? nil : Author.new(row[0], row[1], row[2]), row[3].nil? && row[4].nil? && row[5].nil? ? nil : Book.new(row[3], row[4], row[5], row[6])) + end + end + end + + def list_authors_between_ids(id:, id_2:) + with_client('list_authors_between_ids') do |client| + query_params = [id, id_2] + unless client.prepared_statements.include?('query_sql_list_authors_between_ids') + client.prepare('query_sql_list_authors_between_ids', LIST_AUTHORS_BETWEEN_IDS_SQL) + client.prepared_statements.add('query_sql_list_authors_between_ids') + end + result = client.exec_prepared('query_sql_list_authors_between_ids', query_params) + result.map do |row| + ListAuthorsBetweenIdsRow.new(row['id'], row['name'], row['bio']) + end + end + end + private def instrument(name) @@ -622,7 +725,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/examples/pg/query_sql.rbs b/examples/pg/query_sql.rbs index 1742e90..5e69d11 100644 --- a/examples/pg/query_sql.rbs +++ b/examples/pg/query_sql.rbs @@ -198,6 +198,36 @@ module PgCodegen TRUNCATE_EXTRA_TYPES_SQL: String + INSERT_ARRAY_TYPES_SQL: String + + GET_ARRAY_TYPES_SQL: String + + class GetArrayTypesRow + attr_reader c_uuid_array: Array[String]? + attr_reader c_bio_type_array: Array[String]? + attr_reader c_integer_matrix: Array[Array[Integer]]? + def initialize: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> void + end + + TRUNCATE_ARRAY_TYPES_SQL: String + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL: String + + class ListAuthorsWithOptionalBookRow + attr_reader author: Author? + attr_reader book: Book? + def initialize: ( Author? author, Book? book) -> void + end + + LIST_AUTHORS_BETWEEN_IDS_SQL: String + + class ListAuthorsBetweenIdsRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + class QuerySql class NotFound < StandardError end @@ -264,6 +294,18 @@ module PgCodegen def truncate_extra_types: -> void + def insert_array_types: (c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> void + + def get_array_types: -> GetArrayTypesRow? + + def get_array_types!: -> GetArrayTypesRow + + def truncate_array_types: -> void + + def list_authors_with_optional_book: -> Array[ListAuthorsWithOptionalBookRow] + + def list_authors_between_ids: (id: Integer, id_2: Integer) -> Array[ListAuthorsBetweenIdsRow] + private def instrument: [T] (String name) { () -> T } -> T diff --git a/lib/sqlc_gen_ruby/catalog.rb b/lib/sqlc_gen_ruby/catalog.rb index de1c6e3..fd9b68b 100644 --- a/lib/sqlc_gen_ruby/catalog.rb +++ b/lib/sqlc_gen_ruby/catalog.rb @@ -60,11 +60,23 @@ def qualified_name(type) "#{type.schema}.#{name}" end + # authors => Author; when two tables would share a model (author and authors) the plural one keeps its plural name. def assign_model_names - @tables.each do |t| - base = Code.camelize(Code.singularize(t.name)) - t.model_name = default_schema?(t.schema) ? base : Code.camelize(t.schema) + base + @tables.each { |t| t.model_name = model_name(t, Code.singularize(t.name)) } + @tables.group_by(&:model_name).each_value do |same| + same.each { |t| t.model_name = model_name(t, t.name) } if same.size > 1 end + @tables.group_by(&:model_name).each do |name, same| + next if same.size == 1 + + tables = same.map { |t| "#{t.schema}.#{t.name}" }.join(', ') + raise ArgumentError, "tables #{tables} would all generate the model class #{name}" + end + end + + def model_name(table, base) + name = Code.camelize(base) + default_schema?(table.schema) ? name : Code.camelize(table.schema) + name end end end diff --git a/lib/sqlc_gen_ruby/code.rb b/lib/sqlc_gen_ruby/code.rb index 15a1b01..4897d7d 100644 --- a/lib/sqlc_gen_ruby/code.rb +++ b/lib/sqlc_gen_ruby/code.rb @@ -63,6 +63,31 @@ def param_name(name) RUBY_KEYWORDS.include?(snake) ? "#{snake}_" : snake end + # Methods every row (Data / Struct) or Object already has; a column with one of these names would replace it. + RESERVED_ATTRS = %w[class clone display dup eql? equal? extend freeze frozen? hash inspect instance_of? is_a? itself + kind_of? method methods nil? object_id public_send respond_to? send singleton_class tap then to_s + members to_h with deconstruct deconstruct_keys to_a values each size length select filter dig + each_pair].freeze + + # sqlc column name -> a valid attribute (method) name: invalid characters become `_`, a leading digit or an upper + # case letter gets a `_` / is lowered, keywords and reserved methods get a trailing `_` (class -> class_). + def attr_name(name) + attr = name.to_s.gsub(/[^A-Za-z0-9_]+/, '_') + attr = "_#{attr}" if attr.empty? || attr.match?(/\A[0-9]/) + attr = attr[0].downcase + attr[1..] if attr.match?(/\A[A-Z]/) + RUBY_KEYWORDS.include?(attr) || RESERVED_ATTRS.include?(attr) ? "#{attr}_" : attr + end + + # 'user-queries' => User_queries is invalid; runs of characters that are not valid in a constant start a new word: + # 'user-queries' => UserQueries, 'my_queries' => My_queries (unchanged), '2024' => Q2024 + def constant_part(str) + words = str.split(/[^A-Za-z0-9_]+/).reject(&:empty?) + raise ArgumentError, "#{str.inspect} has no characters usable in a Ruby constant" if words.empty? + + name = words.map { |w| upcase_first(w) }.join + name.match?(/\A[0-9_]/) ? "Q#{name}" : name + end + # GetAuthor => GET_AUTHOR_SQL def sql_constant(query_name) = "#{snake_case(query_name).upcase}_SQL" diff --git a/lib/sqlc_gen_ruby/drivers/pg.rb b/lib/sqlc_gen_ruby/drivers/pg.rb index 954cad9..0f8269f 100644 --- a/lib/sqlc_gen_ruby/drivers/pg.rb +++ b/lib/sqlc_gen_ruby/drivers/pg.rb @@ -145,7 +145,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/lib/sqlc_gen_ruby/generator.rb b/lib/sqlc_gen_ruby/generator.rb index 19039c8..d0b9b08 100644 --- a/lib/sqlc_gen_ruby/generator.rb +++ b/lib/sqlc_gen_ruby/generator.rb @@ -30,7 +30,10 @@ def generate(request) name_anonymous_params(request.queries) models = models_files connection = connection_files - files = request.queries.group_by { |q| class_name_for(q.filename) }.flat_map do |class_name, queries| + reject_reserved_query_names(request.queries) + by_class = request.queries.group_by { |q| class_name_for(q.filename) } + reject_colliding_files(by_class.keys) + files = by_class.flat_map do |class_name, queries| generate_files(queries, class_name, with_models: !models.empty?, with_connection: !connection.empty?) end files.concat(models).concat(connection) @@ -65,7 +68,29 @@ def class_name_for(filename) ext = File.extname(filename) raise ArgumentError, "query file #{filename} has no extension" if ext.empty? - Code.upcase_first(File.basename(filename, ext)) + Code.upcase_first(ext[1..]) + Code.constant_part(File.basename(filename, ext)) + Code.constant_part(ext[1..]) + end + + # user-queries.sql and user_queries.sql would both write user_queries_sql.rb + def reject_colliding_files(class_names) + class_names.group_by { |c| Code.snake_case(c) }.each do |base, names| + raise ArgumentError, "query files #{names.join(' and ')} would both generate #{base}.rb" if names.size > 1 + end + end + + # A query named Transaction or Initialize would silently replace the generated method of the same name. + def reject_reserved_query_names(queries) + generated = (@driver.class_methods + @driver.private_methods).flat_map do |code, _| + code.scan(/^\s*def (?:self\.)?([a-z_][A-Za-z0-9_]*[?!]?)/).flatten + end + reserved = generated + %w[initialize slice_placeholders] + Code::RESERVED_ATTRS + queries.each do |query| + func = Code.snake_case(query.name) + next unless reserved.include?(func) + + raise ArgumentError, "query #{query.name} would generate ##{func}, which the generated class already defines; " \ + 'rename the query' + end end def generate_files(queries, class_name, with_models:, with_connection:) @@ -102,7 +127,7 @@ def query_members(query, method_gen) end members << dataclass("#{Code.upcase_first(query.name)}Row", row_pairs(query)) unless query.columns.empty? if query.cmd == ':copyfrom' - pairs = method_gen.params(query).map { |p| [p.name, p.type] } + pairs = method_gen.params(query).map { |p| [Code.attr_name(p.name), p.type] } members << dataclass("#{Code.upcase_first(query.name)}Args", pairs) end members @@ -155,7 +180,7 @@ def models_files @catalog.tables.each do |table| next if table.columns.empty? - pairs = table.columns.map { |c| [c.name, @types.type(c, fallback: 'untyped')] } + pairs = table.columns.map { |c| [Code.attr_name(c.name), @types.type(c, fallback: 'untyped')] } members << dataclass(table.model_name, pairs) end return [] if members.empty? diff --git a/lib/sqlc_gen_ruby/method_gen.rb b/lib/sqlc_gen_ruby/method_gen.rb index f4691fd..8f084c9 100644 --- a/lib/sqlc_gen_ruby/method_gen.rb +++ b/lib/sqlc_gen_ruby/method_gen.rb @@ -23,12 +23,14 @@ def initialize(driver, layout, types, class_name) @statement_prefix = Code.snake_case(class_name) end + # `created_at > $1 AND created_at < $2` -> created_at:, created_at_2: def params(query) - query.params.map do |p| + names = unique_param_names(query.params.map { |p| Code.param_name(p.column.name) }) + query.params.zip(names).map do |p, name| column = p.column slice = column.is_sqlc_slice type = slice ? @types.slice_type(column, query.name) : @types.type(column, query_name: query.name) - Param.new(Code.param_name(column.name), column, slice, column.is_named_param && @types.nullable?(column, query.name), type) + Param.new(name, column, slice, column.is_named_param && @types.nullable?(column, query.name), type) end end @@ -77,6 +79,17 @@ def helper_methods(queries) private + def unique_param_names(bases) + used = [] + bases.map do |base| + name = base + suffix = 1 + name = "#{base}_#{suffix += 1}" while used.include?(name) || (name != base && bases.include?(name)) + used << name + name + end + end + def slice?(query) = query.params.any? { |p| p.column.is_sqlc_slice } def return_type(query) @@ -101,7 +114,7 @@ def copy_statements(query, params) table = query.insert_into_table name = table.schema.to_s.empty? ? table.name : "#{table.schema}.#{table.name}" - @driver.copy_from_lines('rows', name, params.map { |p| p.column.name }, params.map(&:name), + @driver.copy_from_lines('rows', name, params.map { |p| p.column.name }, params.map { |p| Code.attr_name(p.name) }, Code.snake_case(query.name)) end @@ -167,7 +180,8 @@ def new_row(query) if entry.table values = entry.table.columns.each_index.map { |i| "row[#{index + i}]" } index += values.size - "#{entry.table.model_name}.new(#{Code.comma_join(values)})" + model = "#{entry.table.model_name}.new(#{Code.comma_join(values)})" + entry.optional ? "#{missing_row_check(entry.table, values)} ? nil : #{model}" : model else access = array ? "row[#{index}]" : "row['#{entry.column.name}']" index += 1 @@ -176,5 +190,12 @@ def new_row(query) end "#{query.name}Row.new(#{Code.comma_join(args)})" end + + # The outer side of a join is missing when its NOT NULL columns (all columns if it has none) are NULL. + def missing_row_check(table, values) + required = table.columns.each_index.select { |i| table.columns[i].not_null } + required = table.columns.each_index.to_a if required.empty? + required.map { |i| "#{values[i]}.nil?" }.join(' && ') + end end end diff --git a/lib/sqlc_gen_ruby/row_layout.rb b/lib/sqlc_gen_ruby/row_layout.rb index 0dcb8cf..02bbfc1 100644 --- a/lib/sqlc_gen_ruby/row_layout.rb +++ b/lib/sqlc_gen_ruby/row_layout.rb @@ -7,7 +7,9 @@ module SqlcGenRuby # Plain columns map 1:1; a `sqlc.embed(table)` column expands (in the SQL) to all columns of that table and becomes # one attribute holding the table's model, so such rows are read by position. class RowLayout - Entry = Struct.new(:attr, :type, :column, :table) + # optional: an embedded table on the outer side of a join, nil when the join found no row + Entry = Struct.new(:attr, :type, :column, :table, :optional) + OUTER_JOIN = /\b(?:LEFT|RIGHT|FULL)\s+(?:OUTER\s+)?JOIN\b/i def initialize(driver, catalog, types) @driver = driver @@ -30,14 +32,17 @@ def array_rows?(query) def embed?(column) = !column.embed_table.nil? && !column.embed_table.name.empty? def build(query) - taken = query.columns.reject { |c| embed?(c) }.map(&:name) + taken = query.columns.reject { |c| embed?(c) }.map { |c| Code.attr_name(c.name) } + # sqlc does not say which side of a join an embedded table is on: in an outer join any of them may be missing + outer = query.text.match?(OUTER_JOIN) query.columns.map do |column| if embed?(column) table = @catalog.table_for(column.embed_table) or raise ArgumentError, "#{query.name}: sqlc.embed of unknown table #{column.embed_table.name}" - Entry.new(unique_name(taken, Code.singularize(column.name)), table.model_name, column, table) + name = unique_name(taken, Code.attr_name(Code.singularize(column.name))) + Entry.new(name, outer ? "#{table.model_name}?" : table.model_name, column, table, outer) else - Entry.new(column.name, @types.type(column, query_name: query.name), column, nil) + Entry.new(Code.attr_name(column.name), @types.type(column, query_name: query.name), column, nil, false) end end.tap { |list| reject_duplicate_columns(query, list) } end diff --git a/lib/sqlc_gen_ruby/type_resolver.rb b/lib/sqlc_gen_ruby/type_resolver.rb index 9669777..d30ab03 100644 --- a/lib/sqlc_gen_ruby/type_resolver.rb +++ b/lib/sqlc_gen_ruby/type_resolver.rb @@ -13,7 +13,7 @@ def initialize(driver, options) def type(column, query_name: nil, fallback: :raise) override = query_name && @options.override_for(query_name, column.name) base = override ? override.type : @driver.column_type(column, fallback: fallback) - base = "Array[#{base}]" if column.is_array && !override + base = array_of(base, column) if column.is_array && !override not_null?(column, override) || base == 'untyped' ? base : "#{base}?" end @@ -29,6 +29,11 @@ def slice_type(column, query_name) private + # int[][] -> Array[Array[Integer]] (sqlc sends array_dims; 0 from older versions means 1) + def array_of(base, column) + [column.array_dims.to_i, 1].max.times.reduce(base) { |type, _| "Array[#{type}]" } + end + def not_null?(column, override) override&.not_null.nil? ? column.not_null : override.not_null end diff --git a/steep_expectations.yml b/steep_expectations.yml index a71323d..15550b8 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -45,80 +45,110 @@ diagnostics: - range: start: - line: 213 + line: 249 character: 10 end: - line: 213 + line: 249 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 213 + line: 249 character: 47 end: - line: 213 + line: 249 character: 49 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 213 + line: 249 character: 58 end: - line: 213 + line: 249 character: 69 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 227 + line: 263 character: 49 end: - line: 227 + line: 263 character: 51 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 427 + line: 463 character: 18 end: - line: 427 + line: 463 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 624 + line: 727 character: 17 end: - line: 624 + line: 727 character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 625 + line: 729 + character: 17 + end: + line: 729 + character: 19 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 731 + character: 27 + end: + line: 731 + character: 29 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 731 + character: 91 + end: + line: 731 + character: 93 + severity: WARNING + message: 'Cannot find the declaration of constant: `PG`' + code: Ruby::UnknownConstant + - range: + start: + line: 733 character: 34 end: - line: 625 + line: 733 character: 36 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 627 + line: 736 character: 36 end: - line: 627 + line: 736 character: 38 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' diff --git a/test/features_test.rb b/test/features_test.rb index b01a95c..69901a2 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -239,4 +239,69 @@ def test_logger_support end assert_includes generate(load_request('pg')).fetch('query_sql.rb'), 'def self.connect(pg_params, logger: nil, **pool_options)' end + + def test_query_names_cannot_replace_generated_methods + %w[Transaction Initialize Connect WithClient Hash].each do |name| + request = load_request('pg') + query(request, 'DeleteAuthor').name = name + error = assert_raises(RuntimeError) { generate(request) } + assert_includes error.message, "query #{name} would generate" + end + end + + def test_query_file_names_become_valid_constants + request = load_request('sqlite3') + request.queries.each { |q| q.filename = 'user-queries.sql' } + files = generate(request) + assert_includes files.fetch('user_queries_sql.rb'), 'class UserQueriesSql' + assert_equal 0, ruby_syntax_check(files.fetch('user_queries_sql.rb')) + end + + def test_query_files_writing_the_same_file_are_rejected + request = load_request('sqlite3') + request.queries.first.filename = 'user-queries.sql' + request.queries.last.filename = 'user_queries.sql' + assert_raises(RuntimeError) { generate(request) } + end + + def test_column_names_that_are_not_plain_attributes_are_renamed + request = load_request('pg') + columns = query(request, 'GetAuthor').columns + columns[0].name = 'class' + columns[1].name = 'hash' + columns[2].name = 'Full Name' + files = generate(request) + assert_includes files.fetch('query_sql.rb'), 'class GetAuthorRow < Data.define(:class_, :hash_, :full_Name)' + assert_includes files.fetch('query_sql.rb'), "GetAuthorRow.new(row['class'], row['hash'], row['Full Name'])" + assert_includes files.fetch('query_sql.rbs'), 'attr_reader class_: Integer' + end + + def test_tables_with_the_same_singular_get_distinct_models + request = load_request('pg') + tables = request.catalog.schemas.find { |s| s.name == 'public' }.tables + author = tables.find { |t| t.rel.name == 'authors' } + tables << Plugin::Table.new(rel: Plugin::Identifier.new(name: 'author'), columns: author.columns) + models = generate(request).fetch('models.rb') + assert_includes models, 'class Authors < Data.define(:id, :name, :bio)' + assert_includes models, 'class Author < Data.define(:id, :name, :bio)' + assert_equal 0, ruby_syntax_check(models) + end + + def test_repeated_parameter_names_get_a_suffix + files = generate(load_request('pg')) + assert_includes files.fetch('query_sql.rb'), 'def list_authors_between_ids(id:, id_2:)' + assert_includes files.fetch('query_sql.rbs'), 'def list_authors_between_ids: (id: Integer, id_2: Integer) -> Array[ListAuthorsBetweenIdsRow]' + end + + def test_outer_join_embeds_are_optional + files = generate(load_request('pg')) + assert_includes files.fetch('query_sql.rbs'), "attr_reader author: Author?\n attr_reader book: Book?" + assert_includes files.fetch('query_sql.rb'), 'row[3].nil? && row[4].nil? && row[5].nil? ? nil : Book.new(' + assert_includes files.fetch('query_sql.rbs'), "class ListAllAuthorsBooksRow\n attr_reader author: Author\n" + end + + def test_multi_dimensional_arrays + assert_includes generate(load_request('pg')).fetch('query_sql.rbs'), + 'attr_reader c_integer_matrix: Array[Array[Integer]]?' + end end diff --git a/test/fixtures/pg.bin b/test/fixtures/pg.bin index 75b705d8be0ce34b52416a125a8d16f39fba001f..7e17cdbcfe298d47423592f74100e67655dd9c01 100644 GIT binary patch delta 995 zcmZ{i!E4h{9LGtUv{}%)J+q?OCePZ{Ql;1p2P-;kT{p_QRcgkzQrE(L0(5-lKNARNUT@yF${Pj zOlnG9H_tU*<1*bS0v>fRG8(8M@qU*l+wx?(fl(7#%8`mKy#vYT=ymQ~O;jdM9$jI> zJtp+wZ*=h%Cw&bIO|qYyen^dn>HdBFtM9_$A;U^=2g%)xrzXpWjVxU7tjeBKiOZu7 z(lWa|1LO`X{fXM}+T3apEU~$ha jfN5${5#i?u8RdpeV(=~bF|o*Ha&Gj void end + class PostgresArrayType + attr_reader c_uuid_array: Array[String]? + attr_reader c_bio_type_array: Array[String]? + attr_reader c_integer_matrix: Array[Array[Integer]]? + def initialize: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> void + end + class ExtendedBio attr_reader author_name: String attr_reader name: String diff --git a/test/golden/pg/default/my_queries_sql.rb b/test/golden/pg/default/my_queries_sql.rb index 813f87e..324309a 100644 --- a/test/golden/pg/default/my_queries_sql.rb +++ b/test/golden/pg/default/my_queries_sql.rb @@ -89,7 +89,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/default/other_sql.rb b/test/golden/pg/default/other_sql.rb index 91b8418..bee486f 100644 --- a/test/golden/pg/default/other_sql.rb +++ b/test/golden/pg/default/other_sql.rb @@ -137,7 +137,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/default/query_sql.rb b/test/golden/pg/default/query_sql.rb index 3221290..c368589 100644 --- a/test/golden/pg/default/query_sql.rb +++ b/test/golden/pg/default/query_sql.rb @@ -203,6 +203,42 @@ class GetExtraTypesRow < Data.define( TRUNCATE TABLE postgres_extra_types SQL + INSERT_ARRAY_TYPES_SQL = <<~'SQL' + INSERT INTO postgres_array_types (c_uuid_array, c_bio_type_array, c_integer_matrix) VALUES ($1, $2, $3) + SQL + + GET_ARRAY_TYPES_SQL = <<~'SQL' + SELECT c_uuid_array, c_bio_type_array, c_integer_matrix FROM postgres_array_types LIMIT 1 + SQL + + class GetArrayTypesRow < Data.define(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + + TRUNCATE_ARRAY_TYPES_SQL = <<~'SQL' + TRUNCATE TABLE postgres_array_types + SQL + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL = <<~'SQL' + SELECT + authors.id, authors.name, authors.bio, + books.id, books.name, books.author_id, books.description + FROM authors + LEFT JOIN books ON authors.id = books.author_id + ORDER BY authors.name + SQL + + class ListAuthorsWithOptionalBookRow < Data.define(:author, :book) + end + + LIST_AUTHORS_BETWEEN_IDS_SQL = <<~'SQL' + SELECT id, name, bio FROM authors + WHERE id > $1 AND id < $2 + ORDER BY id + SQL + + class ListAuthorsBetweenIdsRow < Data.define(:id, :name, :bio) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -626,6 +662,73 @@ def truncate_extra_types end end + def insert_array_types(c_uuid_array:, c_bio_type_array:, c_integer_matrix:) + with_client('insert_array_types') do |client| + query_params = [c_uuid_array, c_bio_type_array, c_integer_matrix] + unless client.prepared_statements.include?('query_sql_insert_array_types') + client.prepare('query_sql_insert_array_types', INSERT_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_insert_array_types') + end + client.exec_prepared('query_sql_insert_array_types', query_params) + nil + end + end + + def get_array_types + with_client('get_array_types') do |client| + unless client.prepared_statements.include?('query_sql_get_array_types') + client.prepare('query_sql_get_array_types', GET_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_get_array_types') + end + result = client.exec_prepared('query_sql_get_array_types') + row = result.first + row && GetArrayTypesRow.new(row['c_uuid_array'], row['c_bio_type_array'], row['c_integer_matrix']) + end + end + + # Like #get_array_types, but raises NotFound when there is no row. + def get_array_types!(...) + get_array_types(...) || raise(NotFound, 'GetArrayTypes returned no row') + end + + def truncate_array_types + with_client('truncate_array_types') do |client| + unless client.prepared_statements.include?('query_sql_truncate_array_types') + client.prepare('query_sql_truncate_array_types', TRUNCATE_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_truncate_array_types') + end + client.exec_prepared('query_sql_truncate_array_types') + nil + end + end + + def list_authors_with_optional_book + with_client('list_authors_with_optional_book') do |client| + unless client.prepared_statements.include?('query_sql_list_authors_with_optional_book') + client.prepare('query_sql_list_authors_with_optional_book', LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL) + client.prepared_statements.add('query_sql_list_authors_with_optional_book') + end + result = client.exec_prepared('query_sql_list_authors_with_optional_book').values + result.map do |row| + ListAuthorsWithOptionalBookRow.new(row[0].nil? && row[1].nil? ? nil : Author.new(row[0], row[1], row[2]), row[3].nil? && row[4].nil? && row[5].nil? ? nil : Book.new(row[3], row[4], row[5], row[6])) + end + end + end + + def list_authors_between_ids(id:, id_2:) + with_client('list_authors_between_ids') do |client| + query_params = [id, id_2] + unless client.prepared_statements.include?('query_sql_list_authors_between_ids') + client.prepare('query_sql_list_authors_between_ids', LIST_AUTHORS_BETWEEN_IDS_SQL) + client.prepared_statements.add('query_sql_list_authors_between_ids') + end + result = client.exec_prepared('query_sql_list_authors_between_ids', query_params) + result.map do |row| + ListAuthorsBetweenIdsRow.new(row['id'], row['name'], row['bio']) + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -709,7 +812,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/default/query_sql.rbs b/test/golden/pg/default/query_sql.rbs index ca94251..782cd96 100644 --- a/test/golden/pg/default/query_sql.rbs +++ b/test/golden/pg/default/query_sql.rbs @@ -198,6 +198,36 @@ module PgCodegen TRUNCATE_EXTRA_TYPES_SQL: String + INSERT_ARRAY_TYPES_SQL: String + + GET_ARRAY_TYPES_SQL: String + + class GetArrayTypesRow + attr_reader c_uuid_array: Array[String]? + attr_reader c_bio_type_array: Array[String]? + attr_reader c_integer_matrix: Array[Array[Integer]]? + def initialize: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> void + end + + TRUNCATE_ARRAY_TYPES_SQL: String + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL: String + + class ListAuthorsWithOptionalBookRow + attr_reader author: Author? + attr_reader book: Book? + def initialize: ( Author? author, Book? book) -> void + end + + LIST_AUTHORS_BETWEEN_IDS_SQL: String + + class ListAuthorsBetweenIdsRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + MANY_PARAMS_SQL: String class ManyParamsRow @@ -299,6 +329,18 @@ module PgCodegen def truncate_extra_types: -> void + def insert_array_types: (c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> void + + def get_array_types: -> GetArrayTypesRow? + + def get_array_types!: -> GetArrayTypesRow + + def truncate_array_types: -> void + + def list_authors_with_optional_book: -> Array[ListAuthorsWithOptionalBookRow] + + def list_authors_between_ids: (id: Integer, id_2: Integer) -> Array[ListAuthorsBetweenIdsRow] + def many_params: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow? def many_params!: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow diff --git a/test/golden/pg/ruby31_gemfile/models.rb b/test/golden/pg/ruby31_gemfile/models.rb index e3d3438..1902584 100644 --- a/test/golden/pg/ruby31_gemfile/models.rb +++ b/test/golden/pg/ruby31_gemfile/models.rb @@ -50,6 +50,9 @@ class PostgresExtraType < Struct.new( ) end + class PostgresArrayType < Struct.new(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + class ExtendedBio < Struct.new(:author_name, :name, :bio_type) end end diff --git a/test/golden/pg/ruby31_gemfile/models.rbs b/test/golden/pg/ruby31_gemfile/models.rbs index 9d9bf3c..accf699 100644 --- a/test/golden/pg/ruby31_gemfile/models.rbs +++ b/test/golden/pg/ruby31_gemfile/models.rbs @@ -84,6 +84,13 @@ module PgCodegen ) -> PostgresExtraType end + class PostgresArrayType < ::Struct + attr_accessor c_uuid_array: Array[String]? + attr_accessor c_bio_type_array: Array[String]? + attr_accessor c_integer_matrix: Array[Array[Integer]]? + def self.new: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> PostgresArrayType + end + class ExtendedBio < ::Struct attr_accessor author_name: String attr_accessor name: String diff --git a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb index 1982f7c..eb3bc93 100644 --- a/test/golden/pg/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/pg/ruby31_gemfile/my_queries_sql.rb @@ -90,7 +90,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/ruby31_gemfile/other_sql.rb b/test/golden/pg/ruby31_gemfile/other_sql.rb index fc9b664..089ca31 100644 --- a/test/golden/pg/ruby31_gemfile/other_sql.rb +++ b/test/golden/pg/ruby31_gemfile/other_sql.rb @@ -138,7 +138,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index 1f47341..d538957 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -204,6 +204,42 @@ class GetExtraTypesRow < Struct.new( TRUNCATE TABLE postgres_extra_types SQL + INSERT_ARRAY_TYPES_SQL = <<~'SQL' + INSERT INTO postgres_array_types (c_uuid_array, c_bio_type_array, c_integer_matrix) VALUES ($1, $2, $3) + SQL + + GET_ARRAY_TYPES_SQL = <<~'SQL' + SELECT c_uuid_array, c_bio_type_array, c_integer_matrix FROM postgres_array_types LIMIT 1 + SQL + + class GetArrayTypesRow < Struct.new(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + + TRUNCATE_ARRAY_TYPES_SQL = <<~'SQL' + TRUNCATE TABLE postgres_array_types + SQL + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL = <<~'SQL' + SELECT + authors.id, authors.name, authors.bio, + books.id, books.name, books.author_id, books.description + FROM authors + LEFT JOIN books ON authors.id = books.author_id + ORDER BY authors.name + SQL + + class ListAuthorsWithOptionalBookRow < Struct.new(:author, :book) + end + + LIST_AUTHORS_BETWEEN_IDS_SQL = <<~'SQL' + SELECT id, name, bio FROM authors + WHERE id > $1 AND id < $2 + ORDER BY id + SQL + + class ListAuthorsBetweenIdsRow < Struct.new(:id, :name, :bio) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -627,6 +663,73 @@ def truncate_extra_types end end + def insert_array_types(c_uuid_array:, c_bio_type_array:, c_integer_matrix:) + with_client('insert_array_types') do |client| + query_params = [c_uuid_array, c_bio_type_array, c_integer_matrix] + unless client.prepared_statements.include?('query_sql_insert_array_types') + client.prepare('query_sql_insert_array_types', INSERT_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_insert_array_types') + end + client.exec_prepared('query_sql_insert_array_types', query_params) + nil + end + end + + def get_array_types + with_client('get_array_types') do |client| + unless client.prepared_statements.include?('query_sql_get_array_types') + client.prepare('query_sql_get_array_types', GET_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_get_array_types') + end + result = client.exec_prepared('query_sql_get_array_types') + row = result.first + row && GetArrayTypesRow.new(row['c_uuid_array'], row['c_bio_type_array'], row['c_integer_matrix']) + end + end + + # Like #get_array_types, but raises NotFound when there is no row. + def get_array_types!(...) + get_array_types(...) || raise(NotFound, 'GetArrayTypes returned no row') + end + + def truncate_array_types + with_client('truncate_array_types') do |client| + unless client.prepared_statements.include?('query_sql_truncate_array_types') + client.prepare('query_sql_truncate_array_types', TRUNCATE_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_truncate_array_types') + end + client.exec_prepared('query_sql_truncate_array_types') + nil + end + end + + def list_authors_with_optional_book + with_client('list_authors_with_optional_book') do |client| + unless client.prepared_statements.include?('query_sql_list_authors_with_optional_book') + client.prepare('query_sql_list_authors_with_optional_book', LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL) + client.prepared_statements.add('query_sql_list_authors_with_optional_book') + end + result = client.exec_prepared('query_sql_list_authors_with_optional_book').values + result.map do |row| + ListAuthorsWithOptionalBookRow.new(row[0].nil? && row[1].nil? ? nil : Author.new(row[0], row[1], row[2]), row[3].nil? && row[4].nil? && row[5].nil? ? nil : Book.new(row[3], row[4], row[5], row[6])) + end + end + end + + def list_authors_between_ids(id:, id_2:) + with_client('list_authors_between_ids') do |client| + query_params = [id, id_2] + unless client.prepared_statements.include?('query_sql_list_authors_between_ids') + client.prepare('query_sql_list_authors_between_ids', LIST_AUTHORS_BETWEEN_IDS_SQL) + client.prepared_statements.add('query_sql_list_authors_between_ids') + end + result = client.exec_prepared('query_sql_list_authors_between_ids', query_params) + result.map do |row| + ListAuthorsBetweenIdsRow.new(row['id'], row['name'], row['bio']) + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -710,7 +813,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rbs b/test/golden/pg/ruby31_gemfile/query_sql.rbs index 58f9f9d..0065501 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/query_sql.rbs @@ -198,6 +198,36 @@ module PgCodegen TRUNCATE_EXTRA_TYPES_SQL: String + INSERT_ARRAY_TYPES_SQL: String + + GET_ARRAY_TYPES_SQL: String + + class GetArrayTypesRow < ::Struct + attr_accessor c_uuid_array: Array[String]? + attr_accessor c_bio_type_array: Array[String]? + attr_accessor c_integer_matrix: Array[Array[Integer]]? + def self.new: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> GetArrayTypesRow + end + + TRUNCATE_ARRAY_TYPES_SQL: String + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL: String + + class ListAuthorsWithOptionalBookRow < ::Struct + attr_accessor author: Author? + attr_accessor book: Book? + def self.new: ( Author? author, Book? book) -> ListAuthorsWithOptionalBookRow + end + + LIST_AUTHORS_BETWEEN_IDS_SQL: String + + class ListAuthorsBetweenIdsRow < ::Struct + attr_accessor id: Integer + attr_accessor name: String + attr_accessor bio: String? + def self.new: ( Integer id, String name, String? bio) -> ListAuthorsBetweenIdsRow + end + MANY_PARAMS_SQL: String class ManyParamsRow < ::Struct @@ -299,6 +329,18 @@ module PgCodegen def truncate_extra_types: -> void + def insert_array_types: (c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> void + + def get_array_types: -> GetArrayTypesRow? + + def get_array_types!: -> GetArrayTypesRow + + def truncate_array_types: -> void + + def list_authors_with_optional_book: -> Array[ListAuthorsWithOptionalBookRow] + + def list_authors_between_ids: (id: Integer, id_2: Integer) -> Array[ListAuthorsBetweenIdsRow] + def many_params: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow? def many_params!: (c0: bool, c1: Integer, c2: String, c3: Float, c4: String) -> ManyParamsRow diff --git a/test/golden/pg/ruby32_notypes/models.rb b/test/golden/pg/ruby32_notypes/models.rb index 6d0eee8..0c2322c 100644 --- a/test/golden/pg/ruby32_notypes/models.rb +++ b/test/golden/pg/ruby32_notypes/models.rb @@ -50,6 +50,9 @@ class PostgresExtraType < Data.define( ) end + class PostgresArrayType < Data.define(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + class ExtendedBio < Data.define(:author_name, :name, :bio_type) end end diff --git a/test/golden/pg/ruby32_notypes/my_queries_sql.rb b/test/golden/pg/ruby32_notypes/my_queries_sql.rb index 813f87e..324309a 100644 --- a/test/golden/pg/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/pg/ruby32_notypes/my_queries_sql.rb @@ -89,7 +89,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/ruby32_notypes/other_sql.rb b/test/golden/pg/ruby32_notypes/other_sql.rb index 91b8418..bee486f 100644 --- a/test/golden/pg/ruby32_notypes/other_sql.rb +++ b/test/golden/pg/ruby32_notypes/other_sql.rb @@ -137,7 +137,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index 3221290..c368589 100644 --- a/test/golden/pg/ruby32_notypes/query_sql.rb +++ b/test/golden/pg/ruby32_notypes/query_sql.rb @@ -203,6 +203,42 @@ class GetExtraTypesRow < Data.define( TRUNCATE TABLE postgres_extra_types SQL + INSERT_ARRAY_TYPES_SQL = <<~'SQL' + INSERT INTO postgres_array_types (c_uuid_array, c_bio_type_array, c_integer_matrix) VALUES ($1, $2, $3) + SQL + + GET_ARRAY_TYPES_SQL = <<~'SQL' + SELECT c_uuid_array, c_bio_type_array, c_integer_matrix FROM postgres_array_types LIMIT 1 + SQL + + class GetArrayTypesRow < Data.define(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) + end + + TRUNCATE_ARRAY_TYPES_SQL = <<~'SQL' + TRUNCATE TABLE postgres_array_types + SQL + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL = <<~'SQL' + SELECT + authors.id, authors.name, authors.bio, + books.id, books.name, books.author_id, books.description + FROM authors + LEFT JOIN books ON authors.id = books.author_id + ORDER BY authors.name + SQL + + class ListAuthorsWithOptionalBookRow < Data.define(:author, :book) + end + + LIST_AUTHORS_BETWEEN_IDS_SQL = <<~'SQL' + SELECT id, name, bio FROM authors + WHERE id > $1 AND id < $2 + ORDER BY id + SQL + + class ListAuthorsBetweenIdsRow < Data.define(:id, :name, :bio) + end + MANY_PARAMS_SQL = <<~'SQL' SELECT 1 -- ? ? WHERE a = ? @@ -626,6 +662,73 @@ def truncate_extra_types end end + def insert_array_types(c_uuid_array:, c_bio_type_array:, c_integer_matrix:) + with_client('insert_array_types') do |client| + query_params = [c_uuid_array, c_bio_type_array, c_integer_matrix] + unless client.prepared_statements.include?('query_sql_insert_array_types') + client.prepare('query_sql_insert_array_types', INSERT_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_insert_array_types') + end + client.exec_prepared('query_sql_insert_array_types', query_params) + nil + end + end + + def get_array_types + with_client('get_array_types') do |client| + unless client.prepared_statements.include?('query_sql_get_array_types') + client.prepare('query_sql_get_array_types', GET_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_get_array_types') + end + result = client.exec_prepared('query_sql_get_array_types') + row = result.first + row && GetArrayTypesRow.new(row['c_uuid_array'], row['c_bio_type_array'], row['c_integer_matrix']) + end + end + + # Like #get_array_types, but raises NotFound when there is no row. + def get_array_types!(...) + get_array_types(...) || raise(NotFound, 'GetArrayTypes returned no row') + end + + def truncate_array_types + with_client('truncate_array_types') do |client| + unless client.prepared_statements.include?('query_sql_truncate_array_types') + client.prepare('query_sql_truncate_array_types', TRUNCATE_ARRAY_TYPES_SQL) + client.prepared_statements.add('query_sql_truncate_array_types') + end + client.exec_prepared('query_sql_truncate_array_types') + nil + end + end + + def list_authors_with_optional_book + with_client('list_authors_with_optional_book') do |client| + unless client.prepared_statements.include?('query_sql_list_authors_with_optional_book') + client.prepare('query_sql_list_authors_with_optional_book', LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL) + client.prepared_statements.add('query_sql_list_authors_with_optional_book') + end + result = client.exec_prepared('query_sql_list_authors_with_optional_book').values + result.map do |row| + ListAuthorsWithOptionalBookRow.new(row[0].nil? && row[1].nil? ? nil : Author.new(row[0], row[1], row[2]), row[3].nil? && row[4].nil? && row[5].nil? ? nil : Book.new(row[3], row[4], row[5], row[6])) + end + end + end + + def list_authors_between_ids(id:, id_2:) + with_client('list_authors_between_ids') do |client| + query_params = [id, id_2] + unless client.prepared_statements.include?('query_sql_list_authors_between_ids') + client.prepare('query_sql_list_authors_between_ids', LIST_AUTHORS_BETWEEN_IDS_SQL) + client.prepared_statements.add('query_sql_list_authors_between_ids') + end + result = client.exec_prepared('query_sql_list_authors_between_ids', query_params) + result.map do |row| + ListAuthorsBetweenIdsRow.new(row['id'], row['name'], row['bio']) + end + end + end + def many_params(c0:, c1:, c2:, c3:, c4:) with_client('many_params') do |client| query_params = [ @@ -709,7 +812,13 @@ def with_client(name) # Connections of an injected pool are set up on first use: typed results and Ruby arrays as parameters. def configure(client) type_map = PG::BasicTypeMapForResults.new(client) - type_map.default_type_map = PG::TypeMapAllStrings.new # uuid, enums, money, ... come back as Strings + # uuid, enums, money, ... come back as Strings, arrays of them (uuid[], enum[], ...) as Arrays of Strings + fallback = PG::TypeMapByOid.new + client.exec("SELECT oid FROM pg_type WHERE typcategory = 'A'").each do |row| + fallback.add_coder(PG::TextDecoder::Array.new(oid: row['oid'].to_i, elements_type: PG::TextDecoder::String.new)) + end + fallback.default_type_map = PG::TypeMapAllStrings.new + type_map.default_type_map = fallback client.type_map_for_results = type_map client.type_map_for_queries = PG::BasicTypeMapForQueries.new(client) client.extend(ConnectionState) diff --git a/tests/end2end_pg.rb b/tests/end2end_pg.rb index 574c789..2724f51 100755 --- a/tests/end2end_pg.rb +++ b/tests/end2end_pg.rb @@ -31,6 +31,7 @@ def setup @query_sql.truncate_authors # cascades to books @query_sql.truncate_extended_bios @query_sql.truncate_extra_types + @query_sql.truncate_array_types end def create_author(name, bio = nil) @@ -167,6 +168,21 @@ def test_same_table_embedded_twice assert_equal [low, high], [rows.first.author.id, rows.first.author2.id] end + def test_embed_on_the_outer_side_of_a_join_is_nil_without_a_match + with_book = create_author('a') + create_author('b') + @query_sql.create_book(name: 'book', author_id: with_book) + rows = @query_sql.list_authors_with_optional_book + assert_equal 'book', rows.first.book.name + assert_equal 'b', rows.last.author.name + assert_nil rows.last.book + end + + def test_repeated_column_parameters_get_distinct_names + ids = %w[a b c].map { |name| create_author(name) } + assert_equal [ids[1]], @query_sql.list_authors_between_ids(id: ids[0], id_2: ids[2]).map(&:id) + end + # ---- enums ----------------------------------------------------------------------------------------------------- def test_enum_column @@ -259,6 +275,17 @@ def test_extra_types_round_trip assert_equal 'nn', row.c_not_null end + def test_arrays_of_types_without_a_decoder_are_arrays + uuids = [SecureRandom.uuid, SecureRandom.uuid] + @query_sql.insert_array_types(c_uuid_array: uuids, c_bio_type_array: %w[Memoir Biography], + c_integer_matrix: [[1, 2], [3, 4]]) + row = @query_sql.get_array_types + assert_equal uuids, row.c_uuid_array + assert_equal %w[Memoir Biography], row.c_bio_type_array + assert_equal [[1, 2], [3, 4]], row.c_integer_matrix + assert_empty RbsTypes.mismatches(row, File.expand_path('../examples/pg/query_sql.rbs', __dir__), 'GetArrayTypesRow') + end + def test_extra_types_nulls @query_sql.insert_extra_types( c_uuid: nil, c_jsonb: nil, c_text_array: nil, c_integer_array: nil, c_timestamptz: nil, c_inet: nil, From 1c453d823b460ab951e85d1a3c74d2db57a5ea2a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:23:04 +0000 Subject: [PATCH 2/3] Support :execresult, :batch* annotations, more PostgreSQL types, composite types, SQLite type affinity; unknown types fall back to untyped Co-Authored-By: Doron Eli Rachman --- README.md | 4 + examples/authors/mysql/query.sql | 12 ++ examples/authors/postgresql/query.sql | 22 +++ examples/authors/postgresql/schema.sql | 14 ++ examples/authors/sqlite/query.sql | 21 +++ examples/authors/sqlite/schema.sql | 8 + examples/mysql2/query_sql.rb | 95 ++++++++++ examples/mysql2/query_sql.rbs | 52 ++++++ examples/pg/models.rb | 11 ++ examples/pg/models.rbs | 19 ++ examples/pg/query_sql.rb | 171 ++++++++++++++++++ examples/pg/query_sql.rbs | 85 +++++++++ examples/sqlite3/models.rb | 9 + examples/sqlite3/models.rbs | 15 ++ examples/sqlite3/query_sql.rb | 171 ++++++++++++++++++ examples/sqlite3/query_sql.rbs | 81 +++++++++ lib/sqlc_gen_ruby/catalog.rb | 8 + lib/sqlc_gen_ruby/drivers/base.rb | 10 +- lib/sqlc_gen_ruby/drivers/mysql2.rb | 2 + lib/sqlc_gen_ruby/drivers/pg.rb | 13 +- lib/sqlc_gen_ruby/drivers/sqlite3.rb | 15 ++ lib/sqlc_gen_ruby/generator.rb | 6 +- lib/sqlc_gen_ruby/method_gen.rb | 41 ++++- lib/sqlc_gen_ruby/plugin_pb.rb | 7 +- lib/sqlc_gen_ruby/type_resolver.rb | 4 +- steep_expectations.yml | 60 +++--- test/features_test.rb | 46 ++++- test/fixtures/mysql2.bin | 20 +- test/fixtures/pg.bin | Bin 13022 -> 16092 bytes test/fixtures/sqlite3.bin | Bin 4771 -> 7267 bytes test/golden/mysql2/default/query_sql.rb | 95 ++++++++++ test/golden/mysql2/default/query_sql.rbs | 52 ++++++ .../golden/mysql2/ruby31_gemfile/query_sql.rb | 95 ++++++++++ .../mysql2/ruby31_gemfile/query_sql.rbs | 52 ++++++ .../golden/mysql2/ruby32_notypes/query_sql.rb | 95 ++++++++++ test/golden/pg/default/models.rb | 11 ++ test/golden/pg/default/models.rbs | 19 ++ test/golden/pg/default/query_sql.rb | 171 ++++++++++++++++++ test/golden/pg/default/query_sql.rbs | 85 +++++++++ test/golden/pg/ruby31_gemfile/models.rb | 11 ++ test/golden/pg/ruby31_gemfile/models.rbs | 19 ++ test/golden/pg/ruby31_gemfile/query_sql.rb | 171 ++++++++++++++++++ test/golden/pg/ruby31_gemfile/query_sql.rbs | 85 +++++++++ test/golden/pg/ruby32_notypes/models.rb | 11 ++ test/golden/pg/ruby32_notypes/query_sql.rb | 171 ++++++++++++++++++ test/golden/sqlite3/default/models.rb | 9 + test/golden/sqlite3/default/models.rbs | 15 ++ test/golden/sqlite3/default/query_sql.rb | 171 ++++++++++++++++++ test/golden/sqlite3/default/query_sql.rbs | 81 +++++++++ test/golden/sqlite3/ruby31_gemfile/models.rb | 9 + test/golden/sqlite3/ruby31_gemfile/models.rbs | 15 ++ .../sqlite3/ruby31_gemfile/query_sql.rb | 171 ++++++++++++++++++ .../sqlite3/ruby31_gemfile/query_sql.rbs | 81 +++++++++ test/golden/sqlite3/ruby32_notypes/models.rb | 9 + .../sqlite3/ruby32_notypes/query_sql.rb | 171 ++++++++++++++++++ tests/end2end_mysql2.rb | 17 ++ tests/end2end_pg.rb | 27 +++ tests/end2end_sqlite3.rb | 24 +++ 58 files changed, 2921 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a1b49ac..c3d9c8d 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ end | `:exec` | ✅ | ✅ | ✅ | | | `:execrows` | ✅ | ✅ | ✅ | returns the number of affected rows | | `:execlastid` | ✅ | ✅ | ✅ | PostgreSQL / SQLite need a `RETURNING ` clause (the value of that column is returned); MySQL returns `client.last_id` | +| `:execresult` | ✅ | ✅ | ✅ | returns `ExecResult` (`rows_affected`, `last_insert_id`; `last_insert_id` is `nil` on PostgreSQL, use `RETURNING`) | +| `:batchexec` / `:batchone` / `:batchmany` | ✅ | ✅ | ✅ | take an `Array` of `Args` and run the prepared statement once per element on one connection; return `nil` / `Array[Row?]` / `Array[Array[Row]]` | | `:copyfrom` | ✅ | ✅ | ✅ | takes an `Array` of `Args` (a generated `Data` class). PostgreSQL: `COPY ... FROM STDIN`. MySQL: `LOAD DATA LOCAL INFILE` from a temporary CSV - needs `local_infile: true` in the `Mysql2::Client` params and `local_infile=1` on the server. SQLite: batched multi-row `INSERT` | ### Macros @@ -137,6 +139,8 @@ end * **Nullability:** nullable columns / parameters are typed `T?` in the `.rbs`. * **Arrays (PostgreSQL):** `Array[T]` for columns and parameters (`Array[Array[T]]` for `T[][]`); arrays of types without a decoder (`uuid[]`, enum arrays, ...) are `Array[String]`. * **Names:** a parameter repeated in a query gets a suffix (`id:`, `id_2:`); result columns named like a keyword or a method every row has (`class`, `hash`, `end`, ...) get a trailing `_` (`class_`); 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 rejected. +* **Other PostgreSQL types:** geometric, range / multirange, full-text, `hstore`, `ltree`, `timetz`, `reg*` and composite types are `String`s (the text the pg gem returns); domains and types the plugin does not know are `untyped` instead of failing the generation. +* **SQLite declared types** (`VARCHAR(255)`, `DECIMAL(10,5)`, `UNSIGNED BIG INT`, ...) follow SQLite's [type affinity](https://www.sqlite.org/datatype3.html#determination_of_column_affinity). * Types follow the Ruby classes the drivers actually return (e.g. `BigDecimal` for `numeric`/`decimal`, `Date`, `Time`, `IPAddr`; `json`/`jsonb` are parsed, so `untyped`). Not applicable to the Ruby plugin (they exist in the C# plugin because of its runtime): `withAsyncSuffix` / `withCancellationToken` (Ruby has no async/await: use the Fiber scheduler, see above), `useDapper`, target framework / csproj / namespace options (`moduleName` is the equivalent of the namespace). diff --git a/examples/authors/mysql/query.sql b/examples/authors/mysql/query.sql index 7a60060..020fbc5 100644 --- a/examples/authors/mysql/query.sql +++ b/examples/authors/mysql/query.sql @@ -88,3 +88,15 @@ SELECT * FROM extended_bios WHERE bio_type = ? LIMIT 1; -- name: TruncateExtendedBios :exec DELETE FROM extended_bios; + +-- name: CreateAuthorResult :execresult +INSERT INTO authors (name, bio) VALUES (?, ?); + +-- name: CreateAuthorsBatch :batchexec +INSERT INTO authors (name, bio) VALUES (?, ?); + +-- name: GetAuthorBatch :batchone +SELECT * FROM authors WHERE id = ?; + +-- name: ListAuthorsByNameBatch :batchmany +SELECT * FROM authors WHERE name = ? ORDER BY id; diff --git a/examples/authors/postgresql/query.sql b/examples/authors/postgresql/query.sql index a5071e4..e02409e 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -123,3 +123,25 @@ ORDER BY authors.name; SELECT * FROM authors WHERE id > $1 AND id < $2 ORDER BY id; + +-- name: InsertSpecialTypes :exec +INSERT INTO postgres_special_types (c_timetz, c_tsvector, c_point, c_int4range, c_oid, c_dimensions, c_positive) +VALUES ($1, $2, $3, $4, $5, $6, $7); + +-- name: GetSpecialTypes :one +SELECT * FROM postgres_special_types LIMIT 1; + +-- name: TruncateSpecialTypes :exec +TRUNCATE TABLE postgres_special_types; + +-- name: CreateAuthorResult :execresult +INSERT INTO authors (name, bio) VALUES ($1, $2); + +-- name: CreateAuthorsBatch :batchexec +INSERT INTO authors (name, bio) VALUES ($1, $2); + +-- name: GetAuthorBatch :batchone +SELECT * FROM authors WHERE id = $1; + +-- name: ListAuthorsByNameBatch :batchmany +SELECT * FROM authors WHERE name = $1 ORDER BY id; diff --git a/examples/authors/postgresql/schema.sql b/examples/authors/postgresql/schema.sql index 12af3e5..df993ef 100644 --- a/examples/authors/postgresql/schema.sql +++ b/examples/authors/postgresql/schema.sql @@ -64,3 +64,17 @@ CREATE TABLE postgres_array_types ( c_bio_type_array extended.bio_type [], c_integer_matrix INTEGER [][] ); + +CREATE TYPE extended.dimensions AS (width INTEGER, height INTEGER); + +CREATE DOMAIN positive_int AS INTEGER CHECK (VALUE > 0); + +CREATE TABLE postgres_special_types ( + c_timetz TIMETZ, + c_tsvector TSVECTOR, + c_point POINT, + c_int4range INT4RANGE, + c_oid OID, + c_dimensions extended.dimensions, + c_positive positive_int +); diff --git a/examples/authors/sqlite/query.sql b/examples/authors/sqlite/query.sql index 0c77ede..e1c3774 100644 --- a/examples/authors/sqlite/query.sql +++ b/examples/authors/sqlite/query.sql @@ -73,3 +73,24 @@ SELECT sqlc.embed(books) FROM authors INNER JOIN books ON authors.id = books.author_id WHERE books.name = ?; + +-- name: CreateAuthorResult :execresult +INSERT INTO authors (name, bio) VALUES (?, ?); + +-- name: CreateAuthorsBatch :batchexec +INSERT INTO authors (name, bio) VALUES (?, ?); + +-- name: GetAuthorBatch :batchone +SELECT * FROM authors WHERE id = ?; + +-- name: ListAuthorsByNameBatch :batchmany +SELECT * FROM authors WHERE name = ? ORDER BY id; + +-- name: InsertDeclaredTypes :exec +INSERT INTO declared_types (c_varchar, c_decimal, c_unsigned_big_int, c_native, c_double) VALUES (?, ?, ?, ?, ?); + +-- name: GetDeclaredTypes :one +SELECT * FROM declared_types LIMIT 1; + +-- name: TruncateDeclaredTypes :exec +DELETE FROM declared_types; diff --git a/examples/authors/sqlite/schema.sql b/examples/authors/sqlite/schema.sql index 549d6d4..c80c38b 100644 --- a/examples/authors/sqlite/schema.sql +++ b/examples/authors/sqlite/schema.sql @@ -12,3 +12,11 @@ CREATE TABLE books ( description TEXT, FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE CASCADE ); + +CREATE TABLE declared_types ( + c_varchar VARCHAR(255), + c_decimal DECIMAL(10, 5), + c_unsigned_big_int UNSIGNED BIG INT, + c_native NATIVE CHARACTER(70), + c_double DOUBLE PRECISION +); diff --git a/examples/mysql2/query_sql.rb b/examples/mysql2/query_sql.rb index 2382175..f63704f 100644 --- a/examples/mysql2/query_sql.rb +++ b/examples/mysql2/query_sql.rb @@ -188,6 +188,37 @@ class GetFirstExtendedBioByTypeRow < Data.define(:author_name, :name, :bio_type, DELETE FROM extended_bios SQL + CREATE_AUTHOR_RESULT_SQL = <<~'SQL' + INSERT INTO authors (name, bio) VALUES (?, ?) + SQL + + CREATE_AUTHORS_BATCH_SQL = <<~'SQL' + INSERT INTO authors (name, bio) VALUES (?, ?) + SQL + + class CreateAuthorsBatchArgs < Data.define(:name, :bio) + end + + GET_AUTHOR_BATCH_SQL = <<~'SQL' + SELECT id, name, bio FROM authors WHERE id = ? + SQL + + class GetAuthorBatchRow < Data.define(:id, :name, :bio) + end + + class GetAuthorBatchArgs < Data.define(:id) + end + + LIST_AUTHORS_BY_NAME_BATCH_SQL = <<~'SQL' + SELECT id, name, bio FROM authors WHERE name = ? ORDER BY id + SQL + + class ListAuthorsByNameBatchRow < Data.define(:id, :name, :bio) + end + + class ListAuthorsByNameBatchArgs < Data.define(:name) + end + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -231,6 +262,9 @@ def transaction(isolation: nil) end end + class ExecResult < Data.define(:rows_affected, :last_insert_id) + end + # Returns the author with the given id, or nil when there is none. def get_author(id:) with_client('get_author') do |client| @@ -578,6 +612,67 @@ def truncate_extended_bios end end + def create_author_result(name:, bio:) + with_client('create_author_result') do |client| + query_params = [name, bio] + stmt = client.prepare(CREATE_AUTHOR_RESULT_SQL) + begin + stmt.execute(*query_params) + ExecResult.new(stmt.affected_rows, client.last_id) + ensure + stmt.close + end + end + end + + def create_authors_batch(rows) + with_client('create_authors_batch') do |client| + stmt = client.prepare(CREATE_AUTHORS_BATCH_SQL) + begin + rows.each do |args| + query_params = [args.name, args.bio] + stmt.execute(*query_params) + end + nil + ensure + stmt.close + end + end + end + + def get_author_batch(rows) + with_client('get_author_batch') do |client| + stmt = client.prepare(GET_AUTHOR_BATCH_SQL) + begin + rows.map do |args| + query_params = [args.id] + result = stmt.execute(*query_params) + row = result.first + row && GetAuthorBatchRow.new(row['id'], row['name'], row['bio']) + end + ensure + stmt.close + end + end + end + + def list_authors_by_name_batch(rows) + with_client('list_authors_by_name_batch') do |client| + stmt = client.prepare(LIST_AUTHORS_BY_NAME_BATCH_SQL) + begin + rows.map do |args| + query_params = [args.name] + result = stmt.execute(*query_params) + result.map do |row| + ListAuthorsByNameBatchRow.new(row['id'], row['name'], row['bio']) + end + end + ensure + stmt.close + end + end + end + private def instrument(name) diff --git a/examples/mysql2/query_sql.rbs b/examples/mysql2/query_sql.rbs index 7b2a71b..799c102 100644 --- a/examples/mysql2/query_sql.rbs +++ b/examples/mysql2/query_sql.rbs @@ -195,6 +195,44 @@ module Mysql2Codegen TRUNCATE_EXTENDED_BIOS_SQL: String + CREATE_AUTHOR_RESULT_SQL: String + + CREATE_AUTHORS_BATCH_SQL: String + + class CreateAuthorsBatchArgs + attr_reader name: String + attr_reader bio: String? + def initialize: ( String name, String? bio) -> void + end + + GET_AUTHOR_BATCH_SQL: String + + class GetAuthorBatchRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + + class GetAuthorBatchArgs + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + LIST_AUTHORS_BY_NAME_BATCH_SQL: String + + class ListAuthorsByNameBatchRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + + class ListAuthorsByNameBatchArgs + attr_reader name: String + def initialize: ( String name) -> void + end + class QuerySql class NotFound < StandardError end @@ -205,6 +243,12 @@ module Mysql2Codegen def transaction: [T] (?isolation: (:read_uncommitted | :read_committed | :repeatable_read | :serializable)?) { (self) -> T } -> T + class ExecResult + attr_reader rows_affected: Integer + attr_reader last_insert_id: Integer? + def initialize: ( Integer rows_affected, Integer? last_insert_id) -> void + end + def get_author: (id: Integer) -> GetAuthorRow? def get_author!: (id: Integer) -> GetAuthorRow @@ -253,6 +297,14 @@ module Mysql2Codegen def truncate_extended_bios: -> void + def create_author_result: (name: String, bio: String?) -> ExecResult + + def create_authors_batch: (Array[CreateAuthorsBatchArgs] rows) -> void + + def get_author_batch: (Array[GetAuthorBatchArgs] rows) -> Array[GetAuthorBatchRow?] + + def list_authors_by_name_batch: (Array[ListAuthorsByNameBatchArgs] rows) -> Array[Array[ListAuthorsByNameBatchRow]] + private def instrument: [T] (String name) { () -> T } -> T diff --git a/examples/pg/models.rb b/examples/pg/models.rb index 0c2322c..5fc641b 100644 --- a/examples/pg/models.rb +++ b/examples/pg/models.rb @@ -53,6 +53,17 @@ class PostgresExtraType < Data.define( class PostgresArrayType < Data.define(:c_uuid_array, :c_bio_type_array, :c_integer_matrix) end + class PostgresSpecialType < Data.define( + :c_timetz, + :c_tsvector, + :c_point, + :c_int4range, + :c_oid, + :c_dimensions, + :c_positive + ) + end + class ExtendedBio < Data.define(:author_name, :name, :bio_type) end end diff --git a/examples/pg/models.rbs b/examples/pg/models.rbs index c4daf44..f3d54b4 100644 --- a/examples/pg/models.rbs +++ b/examples/pg/models.rbs @@ -91,6 +91,25 @@ module PgCodegen def initialize: ( Array[String]? c_uuid_array, Array[String]? c_bio_type_array, Array[Array[Integer]]? c_integer_matrix) -> void end + class PostgresSpecialType + attr_reader c_timetz: String? + attr_reader c_tsvector: String? + attr_reader c_point: String? + attr_reader c_int4range: String? + attr_reader c_oid: Integer? + attr_reader c_dimensions: String? + attr_reader c_positive: untyped + def initialize: ( + String? c_timetz, + String? c_tsvector, + String? c_point, + String? c_int4range, + Integer? c_oid, + String? c_dimensions, + untyped c_positive + ) -> void + end + class ExtendedBio attr_reader author_name: String attr_reader name: String diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index 9b65e48..c9d5373 100644 --- a/examples/pg/query_sql.rb +++ b/examples/pg/query_sql.rb @@ -239,6 +239,61 @@ class ListAuthorsWithOptionalBookRow < Data.define(:author, :book) class ListAuthorsBetweenIdsRow < Data.define(:id, :name, :bio) end + INSERT_SPECIAL_TYPES_SQL = <<~'SQL' + INSERT INTO postgres_special_types (c_timetz, c_tsvector, c_point, c_int4range, c_oid, c_dimensions, c_positive) + VALUES ($1, $2, $3, $4, $5, $6, $7) + SQL + + GET_SPECIAL_TYPES_SQL = <<~'SQL' + SELECT c_timetz, c_tsvector, c_point, c_int4range, c_oid, c_dimensions, c_positive FROM postgres_special_types LIMIT 1 + SQL + + class GetSpecialTypesRow < Data.define( + :c_timetz, + :c_tsvector, + :c_point, + :c_int4range, + :c_oid, + :c_dimensions, + :c_positive + ) + end + + TRUNCATE_SPECIAL_TYPES_SQL = <<~'SQL' + TRUNCATE TABLE postgres_special_types + SQL + + CREATE_AUTHOR_RESULT_SQL = <<~'SQL' + INSERT INTO authors (name, bio) VALUES ($1, $2) + SQL + + CREATE_AUTHORS_BATCH_SQL = <<~'SQL' + INSERT INTO authors (name, bio) VALUES ($1, $2) + SQL + + class CreateAuthorsBatchArgs < Data.define(:name, :bio) + end + + GET_AUTHOR_BATCH_SQL = <<~'SQL' + SELECT id, name, bio FROM authors WHERE id = $1 + SQL + + class GetAuthorBatchRow < Data.define(:id, :name, :bio) + end + + class GetAuthorBatchArgs < Data.define(:id) + end + + LIST_AUTHORS_BY_NAME_BATCH_SQL = <<~'SQL' + SELECT id, name, bio FROM authors WHERE name = $1 ORDER BY id + SQL + + class ListAuthorsByNameBatchRow < Data.define(:id, :name, :bio) + end + + class ListAuthorsByNameBatchArgs < Data.define(:name) + end + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -278,6 +333,9 @@ def transaction(isolation: nil) end end + class ExecResult < Data.define(:rows_affected, :last_insert_id) + end + # Returns the author with the given id, or nil when there is none. def get_author(id:) with_client('get_author') do |client| @@ -699,6 +757,119 @@ def list_authors_between_ids(id:, id_2:) end end + def insert_special_types(c_timetz:, c_tsvector:, c_point:, c_int4range:, c_oid:, c_dimensions:, c_positive:) + with_client('insert_special_types') do |client| + query_params = [ + c_timetz, + c_tsvector, + c_point, + c_int4range, + c_oid, + c_dimensions, + c_positive + ] + unless client.prepared_statements.include?('query_sql_insert_special_types') + client.prepare('query_sql_insert_special_types', INSERT_SPECIAL_TYPES_SQL) + client.prepared_statements.add('query_sql_insert_special_types') + end + client.exec_prepared('query_sql_insert_special_types', query_params) + nil + end + end + + def get_special_types + with_client('get_special_types') do |client| + unless client.prepared_statements.include?('query_sql_get_special_types') + client.prepare('query_sql_get_special_types', GET_SPECIAL_TYPES_SQL) + client.prepared_statements.add('query_sql_get_special_types') + end + result = client.exec_prepared('query_sql_get_special_types') + row = result.first + row && GetSpecialTypesRow.new( + row['c_timetz'], + row['c_tsvector'], + row['c_point'], + row['c_int4range'], + row['c_oid'], + row['c_dimensions'], + row['c_positive'] + ) + end + end + + # Like #get_special_types, but raises NotFound when there is no row. + def get_special_types!(...) + get_special_types(...) || raise(NotFound, 'GetSpecialTypes returned no row') + end + + def truncate_special_types + with_client('truncate_special_types') do |client| + unless client.prepared_statements.include?('query_sql_truncate_special_types') + client.prepare('query_sql_truncate_special_types', TRUNCATE_SPECIAL_TYPES_SQL) + client.prepared_statements.add('query_sql_truncate_special_types') + end + client.exec_prepared('query_sql_truncate_special_types') + nil + end + end + + def create_author_result(name:, bio:) + with_client('create_author_result') do |client| + query_params = [name, bio] + unless client.prepared_statements.include?('query_sql_create_author_result') + client.prepare('query_sql_create_author_result', CREATE_AUTHOR_RESULT_SQL) + client.prepared_statements.add('query_sql_create_author_result') + end + result = client.exec_prepared('query_sql_create_author_result', query_params) + ExecResult.new(result.cmd_tuples, nil) + end + end + + def create_authors_batch(rows) + with_client('create_authors_batch') do |client| + unless client.prepared_statements.include?('query_sql_create_authors_batch') + client.prepare('query_sql_create_authors_batch', CREATE_AUTHORS_BATCH_SQL) + client.prepared_statements.add('query_sql_create_authors_batch') + end + rows.each do |args| + query_params = [args.name, args.bio] + client.exec_prepared('query_sql_create_authors_batch', query_params) + end + nil + end + end + + def get_author_batch(rows) + with_client('get_author_batch') do |client| + unless client.prepared_statements.include?('query_sql_get_author_batch') + client.prepare('query_sql_get_author_batch', GET_AUTHOR_BATCH_SQL) + client.prepared_statements.add('query_sql_get_author_batch') + end + rows.map do |args| + query_params = [args.id] + result = client.exec_prepared('query_sql_get_author_batch', query_params) + row = result.first + row && GetAuthorBatchRow.new(row['id'], row['name'], row['bio']) + end + end + end + + def list_authors_by_name_batch(rows) + with_client('list_authors_by_name_batch') do |client| + unless client.prepared_statements.include?('query_sql_list_authors_by_name_batch') + client.prepare('query_sql_list_authors_by_name_batch', LIST_AUTHORS_BY_NAME_BATCH_SQL) + client.prepared_statements.add('query_sql_list_authors_by_name_batch') + end + rows.map do |args| + query_params = [args.name] + result = client.exec_prepared('query_sql_list_authors_by_name_batch', query_params) + result.map do |row| + ListAuthorsByNameBatchRow.new(row['id'], row['name'], row['bio']) + end + end + end + end + private def instrument(name) diff --git a/examples/pg/query_sql.rbs b/examples/pg/query_sql.rbs index 5e69d11..aba0f83 100644 --- a/examples/pg/query_sql.rbs +++ b/examples/pg/query_sql.rbs @@ -228,6 +228,69 @@ module PgCodegen def initialize: ( Integer id, String name, String? bio) -> void end + INSERT_SPECIAL_TYPES_SQL: String + + GET_SPECIAL_TYPES_SQL: String + + class GetSpecialTypesRow + attr_reader c_timetz: String? + attr_reader c_tsvector: String? + attr_reader c_point: String? + attr_reader c_int4range: String? + attr_reader c_oid: Integer? + attr_reader c_dimensions: String? + attr_reader c_positive: untyped + def initialize: ( + String? c_timetz, + String? c_tsvector, + String? c_point, + String? c_int4range, + Integer? c_oid, + String? c_dimensions, + untyped c_positive + ) -> void + end + + TRUNCATE_SPECIAL_TYPES_SQL: String + + CREATE_AUTHOR_RESULT_SQL: String + + CREATE_AUTHORS_BATCH_SQL: String + + class CreateAuthorsBatchArgs + attr_reader name: String + attr_reader bio: String? + def initialize: ( String name, String? bio) -> void + end + + GET_AUTHOR_BATCH_SQL: String + + class GetAuthorBatchRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + + class GetAuthorBatchArgs + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + LIST_AUTHORS_BY_NAME_BATCH_SQL: String + + class ListAuthorsByNameBatchRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + + class ListAuthorsByNameBatchArgs + attr_reader name: String + def initialize: ( String name) -> void + end + class QuerySql class NotFound < StandardError end @@ -238,6 +301,12 @@ module PgCodegen def transaction: [T] (?isolation: (:read_uncommitted | :read_committed | :repeatable_read | :serializable)?) { (self) -> T } -> T + class ExecResult + attr_reader rows_affected: Integer + attr_reader last_insert_id: Integer? + def initialize: ( Integer rows_affected, Integer? last_insert_id) -> void + end + def get_author: (id: Integer) -> GetAuthorRow? def get_author!: (id: Integer) -> GetAuthorRow @@ -306,6 +375,22 @@ module PgCodegen def list_authors_between_ids: (id: Integer, id_2: Integer) -> Array[ListAuthorsBetweenIdsRow] + def insert_special_types: (c_timetz: String?, c_tsvector: String?, c_point: String?, c_int4range: String?, c_oid: Integer?, c_dimensions: String?, c_positive: untyped) -> void + + def get_special_types: -> GetSpecialTypesRow? + + def get_special_types!: -> GetSpecialTypesRow + + def truncate_special_types: -> void + + def create_author_result: (name: String, bio: String?) -> ExecResult + + def create_authors_batch: (Array[CreateAuthorsBatchArgs] rows) -> void + + def get_author_batch: (Array[GetAuthorBatchArgs] rows) -> Array[GetAuthorBatchRow?] + + def list_authors_by_name_batch: (Array[ListAuthorsByNameBatchArgs] rows) -> Array[Array[ListAuthorsByNameBatchRow]] + private def instrument: [T] (String name) { () -> T } -> T diff --git a/examples/sqlite3/models.rb b/examples/sqlite3/models.rb index bf6966f..eaf72d5 100644 --- a/examples/sqlite3/models.rb +++ b/examples/sqlite3/models.rb @@ -8,4 +8,13 @@ class Author < Data.define(:id, :name, :bio) class Book < Data.define(:id, :name, :author_id, :description) end + + class DeclaredType < Data.define( + :c_varchar, + :c_decimal, + :c_unsigned_big_int, + :c_native, + :c_double + ) + end end diff --git a/examples/sqlite3/models.rbs b/examples/sqlite3/models.rbs index cd9946a..bbd64a7 100644 --- a/examples/sqlite3/models.rbs +++ b/examples/sqlite3/models.rbs @@ -14,4 +14,19 @@ module Sqlite3Codegen attr_reader description: String? def initialize: ( Integer id, String name, Integer author_id, String? description) -> void end + + class DeclaredType + attr_reader c_varchar: String? + attr_reader c_decimal: Float? + attr_reader c_unsigned_big_int: Integer? + attr_reader c_native: String? + attr_reader c_double: Float? + def initialize: ( + String? c_varchar, + Float? c_decimal, + Integer? c_unsigned_big_int, + String? c_native, + Float? c_double + ) -> void + end end diff --git a/examples/sqlite3/query_sql.rb b/examples/sqlite3/query_sql.rb index 109053c..e92457f 100644 --- a/examples/sqlite3/query_sql.rb +++ b/examples/sqlite3/query_sql.rb @@ -127,6 +127,58 @@ class GetDuplicateAuthorsRow < Data.define(:author, :author2) class GetAuthorsByBookNameRow < Data.define(:id, :name, :bio, :book) end + CREATE_AUTHOR_RESULT_SQL = <<~'SQL' + INSERT INTO authors (name, bio) VALUES (?, ?) + SQL + + CREATE_AUTHORS_BATCH_SQL = <<~'SQL' + INSERT INTO authors (name, bio) VALUES (?, ?) + SQL + + class CreateAuthorsBatchArgs < Data.define(:name, :bio) + end + + GET_AUTHOR_BATCH_SQL = <<~'SQL' + SELECT id, name, bio FROM authors WHERE id = ? + SQL + + class GetAuthorBatchRow < Data.define(:id, :name, :bio) + end + + class GetAuthorBatchArgs < Data.define(:id) + end + + LIST_AUTHORS_BY_NAME_BATCH_SQL = <<~'SQL' + SELECT id, name, bio FROM authors WHERE name = ? ORDER BY id + SQL + + class ListAuthorsByNameBatchRow < Data.define(:id, :name, :bio) + end + + class ListAuthorsByNameBatchArgs < Data.define(:name) + end + + INSERT_DECLARED_TYPES_SQL = <<~'SQL' + INSERT INTO declared_types (c_varchar, c_decimal, c_unsigned_big_int, c_native, c_double) VALUES (?, ?, ?, ?, ?) + SQL + + GET_DECLARED_TYPES_SQL = <<~'SQL' + SELECT c_varchar, c_decimal, c_unsigned_big_int, c_native, c_double FROM declared_types LIMIT 1 + SQL + + class GetDeclaredTypesRow < Data.define( + :c_varchar, + :c_decimal, + :c_unsigned_big_int, + :c_native, + :c_double + ) + end + + TRUNCATE_DECLARED_TYPES_SQL = <<~'SQL' + DELETE FROM declared_types + SQL + class QuerySql # Raised by the bang variants (get_author!) when the query returns no row. class NotFound < StandardError; end @@ -156,6 +208,9 @@ def transaction(mode: nil) end end + class ExecResult < Data.define(:rows_affected, :last_insert_id) + end + # Returns the author with the given id, or nil when there is none. def get_author(id:) with_client('get_author') do |client| @@ -387,6 +442,122 @@ def get_authors_by_book_name(name:) end end + def create_author_result(name:, bio:) + with_client('create_author_result') do |client| + query_params = [name, bio] + stmt = client.prepare(CREATE_AUTHOR_RESULT_SQL) + begin + stmt.execute(*query_params) + ExecResult.new(client.changes, client.last_insert_row_id) + ensure + stmt.close + end + end + end + + def create_authors_batch(rows) + with_client('create_authors_batch') do |client| + stmt = client.prepare(CREATE_AUTHORS_BATCH_SQL) + begin + rows.each do |args| + query_params = [args.name, args.bio] + stmt.execute(*query_params) + end + nil + ensure + stmt.close + end + end + end + + def get_author_batch(rows) + with_client('get_author_batch') do |client| + stmt = client.prepare(GET_AUTHOR_BATCH_SQL) + begin + rows.map do |args| + query_params = [args.id] + result = stmt.execute(*query_params) + row = result.first + row && GetAuthorBatchRow.new(row[0], row[1], row[2]) + end + ensure + stmt.close + end + end + end + + def list_authors_by_name_batch(rows) + with_client('list_authors_by_name_batch') do |client| + stmt = client.prepare(LIST_AUTHORS_BY_NAME_BATCH_SQL) + begin + rows.map do |args| + query_params = [args.name] + result = stmt.execute(*query_params) + result.map do |row| + ListAuthorsByNameBatchRow.new(row[0], row[1], row[2]) + end + end + ensure + stmt.close + end + end + end + + def insert_declared_types(c_varchar:, c_decimal:, c_unsigned_big_int:, c_native:, c_double:) + with_client('insert_declared_types') do |client| + query_params = [ + c_varchar, + c_decimal, + c_unsigned_big_int, + c_native, + c_double + ] + stmt = client.prepare(INSERT_DECLARED_TYPES_SQL) + begin + stmt.execute(*query_params) + nil + ensure + stmt.close + end + end + end + + def get_declared_types + with_client('get_declared_types') do |client| + stmt = client.prepare(GET_DECLARED_TYPES_SQL) + begin + result = stmt.execute + row = result.first + row && GetDeclaredTypesRow.new( + row[0], + row[1], + row[2], + row[3], + row[4] + ) + ensure + stmt.close + end + end + end + + # Like #get_declared_types, but raises NotFound when there is no row. + def get_declared_types!(...) + get_declared_types(...) || raise(NotFound, 'GetDeclaredTypes returned no row') + end + + def truncate_declared_types + with_client('truncate_declared_types') do |client| + stmt = client.prepare(TRUNCATE_DECLARED_TYPES_SQL) + begin + stmt.execute + nil + ensure + stmt.close + end + end + end + private def instrument(name) diff --git a/examples/sqlite3/query_sql.rbs b/examples/sqlite3/query_sql.rbs index 6e7f0a0..7f2f35d 100644 --- a/examples/sqlite3/query_sql.rbs +++ b/examples/sqlite3/query_sql.rbs @@ -108,6 +108,65 @@ module Sqlite3Codegen def initialize: ( Integer id, String name, String? bio, Book book) -> void end + CREATE_AUTHOR_RESULT_SQL: String + + CREATE_AUTHORS_BATCH_SQL: String + + class CreateAuthorsBatchArgs + attr_reader name: String + attr_reader bio: String? + def initialize: ( String name, String? bio) -> void + end + + GET_AUTHOR_BATCH_SQL: String + + class GetAuthorBatchRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + + class GetAuthorBatchArgs + attr_reader id: Integer + def initialize: ( Integer id) -> void + end + + LIST_AUTHORS_BY_NAME_BATCH_SQL: String + + class ListAuthorsByNameBatchRow + attr_reader id: Integer + attr_reader name: String + attr_reader bio: String? + def initialize: ( Integer id, String name, String? bio) -> void + end + + class ListAuthorsByNameBatchArgs + attr_reader name: String + def initialize: ( String name) -> void + end + + INSERT_DECLARED_TYPES_SQL: String + + GET_DECLARED_TYPES_SQL: String + + class GetDeclaredTypesRow + attr_reader c_varchar: String? + attr_reader c_decimal: Float? + attr_reader c_unsigned_big_int: Integer? + attr_reader c_native: String? + attr_reader c_double: Float? + def initialize: ( + String? c_varchar, + Float? c_decimal, + Integer? c_unsigned_big_int, + String? c_native, + Float? c_double + ) -> void + end + + TRUNCATE_DECLARED_TYPES_SQL: String + class QuerySql class NotFound < StandardError end @@ -116,6 +175,12 @@ module Sqlite3Codegen def transaction: [T] (?mode: (:deferred | :immediate | :exclusive)?) { (self) -> T } -> T + class ExecResult + attr_reader rows_affected: Integer + attr_reader last_insert_id: Integer? + def initialize: ( Integer rows_affected, Integer? last_insert_id) -> void + end + def get_author: (id: Integer) -> GetAuthorRow? def get_author!: (id: Integer) -> GetAuthorRow @@ -150,6 +215,22 @@ module Sqlite3Codegen def get_authors_by_book_name: (name: String) -> Array[GetAuthorsByBookNameRow] + def create_author_result: (name: String, bio: String?) -> ExecResult + + def create_authors_batch: (Array[CreateAuthorsBatchArgs] rows) -> void + + def get_author_batch: (Array[GetAuthorBatchArgs] rows) -> Array[GetAuthorBatchRow?] + + def list_authors_by_name_batch: (Array[ListAuthorsByNameBatchArgs] rows) -> Array[Array[ListAuthorsByNameBatchRow]] + + def insert_declared_types: (c_varchar: String?, c_decimal: Float?, c_unsigned_big_int: Integer?, c_native: String?, c_double: Float?) -> void + + def get_declared_types: -> GetDeclaredTypesRow? + + def get_declared_types!: -> GetDeclaredTypesRow + + def truncate_declared_types: -> void + private def instrument: [T] (String name) { () -> T } -> T diff --git a/lib/sqlc_gen_ruby/catalog.rb b/lib/sqlc_gen_ruby/catalog.rb index fd9b68b..b5848f7 100644 --- a/lib/sqlc_gen_ruby/catalog.rb +++ b/lib/sqlc_gen_ruby/catalog.rb @@ -22,6 +22,11 @@ def initialize(proto_catalog) assign_model_names # qualified enum name => values; default-schema enums are reachable both bare and qualified @enums = {} + @composites = schemas.flat_map do |schema| + schema.composite_types.flat_map do |c| + default_schema?(schema.name) ? [c.name, "#{schema.name}.#{c.name}"] : ["#{schema.name}.#{c.name}"] + end + end schemas.each do |schema| schema.enums.each do |e| @enums["#{schema.name}.#{e.name}"] = e.vals.to_a @@ -32,6 +37,9 @@ def initialize(proto_catalog) def enum?(type) = @enums.key?(qualified_name(type)) + # CREATE TYPE ... AS (...): the pg gem returns the record text, e.g. "(1,abc)" + def composite?(type) = @composites.include?(qualified_name(type)) + def enum_values(type) = @enums[qualified_name(type)] # Enums with their Ruby module names, one entry per distinct enum. diff --git a/lib/sqlc_gen_ruby/drivers/base.rb b/lib/sqlc_gen_ruby/drivers/base.rb index 0302250..225525a 100644 --- a/lib/sqlc_gen_ruby/drivers/base.rb +++ b/lib/sqlc_gen_ruby/drivers/base.rb @@ -67,13 +67,16 @@ def exec_last_id_lines(_exec, _query) = raise(NotImplementedError) # table: SQL name; columns: SQL column names; attrs: matching reader names of the rows def copy_from_lines(_rows_var, _table, _columns, _attrs, _label) = raise(NotImplementedError) + # :execresult -> ExecResult.new(rows_affected, last_insert_id) + def exec_result_lines(_exec) = raise(NotImplementedError) + # Private helper methods the class needs for these queries: [[code, rbs], ...] def helper_methods(_queries) = [] # Ruby/RBS type of a column's DB type (no nullability / array wrapper). Enums are Strings. # With fallback: nil unknown types return it instead of raising (used for models, which cover every table). def column_type(column, fallback: :raise) - return 'String' if catalog&.enum?(column.type) + return 'String' if catalog&.enum?(column.type) || catalog&.composite?(column.type) db_type = normalize_type(column.type.name) column_mappings.each do |mapping| @@ -84,6 +87,8 @@ def column_type(column, fallback: :raise) end return mapping.ruby_type if matched end + affinity = affinity_type(db_type) + return affinity if affinity return fallback unless fallback == :raise raise ArgumentError, "Unsupported column type: #{column.type.name}" @@ -139,6 +144,9 @@ class NotFound < StandardError; end # from sqlc - char(5), int4, pg_catalog.int8, BLOB, ... def normalize_type(type_name) = type_name.downcase.delete_prefix('pg_catalog.') + # Type of a declared type no mapping matched (SQLite's type affinity), nil for none. + def affinity_type(_db_type) = nil + def driver_gems = raise(NotImplementedError) def column_mappings = raise(NotImplementedError) diff --git a/lib/sqlc_gen_ruby/drivers/mysql2.rb b/lib/sqlc_gen_ruby/drivers/mysql2.rb index eaac2a4..e7e375d 100644 --- a/lib/sqlc_gen_ruby/drivers/mysql2.rb +++ b/lib/sqlc_gen_ruby/drivers/mysql2.rb @@ -45,6 +45,8 @@ def exec_rows_lines(exec) = [exec, 'stmt.affected_rows'] def exec_last_id_lines(exec, _query) = [exec, 'client.last_id'] + def exec_result_lines(exec) = [exec, 'ExecResult.new(stmt.affected_rows, client.last_id)'] + # LOAD DATA LOCAL INFILE from a temporary CSV (the client needs `local_infile: true`, the server # `local_infile=1`). def copy_from_lines(rows_var, table, columns, attrs, label) diff --git a/lib/sqlc_gen_ruby/drivers/pg.rb b/lib/sqlc_gen_ruby/drivers/pg.rb index 0f8269f..1c2bcd9 100644 --- a/lib/sqlc_gen_ruby/drivers/pg.rb +++ b/lib/sqlc_gen_ruby/drivers/pg.rb @@ -60,6 +60,9 @@ def execute_stmt(stmt_name, const, has_params, array_rows: false) def exec_rows_lines(exec) = ["result = #{exec}", 'result.cmd_tuples'] + # PostgreSQL has no last insert id (use RETURNING). + def exec_result_lines(exec) = ["result = #{exec}", 'ExecResult.new(result.cmd_tuples, nil)'] + # Relies on a user-written `RETURNING `. def exec_last_id_lines(exec, query) if query.columns.empty? @@ -169,14 +172,18 @@ def column_mappings mapping('bool', %w[bool boolean]), mapping('String', %w[char bpchar varchar text time uuid money macaddr macaddr8 interval xml name citext bit varbit bytea tinytext mediumtext longtext tinyblob blob mediumblob longblob - binary varbinary]), - mapping('Integer', %w[int2 int4 int8 serial bigserial integer smallint bigint int smallserial]), + binary varbinary timetz tsvector tsquery hstore ltree lquery ltxtquery + point line lseg box path polygon circle int4range int8range numrange tsrange + tstzrange daterange int4multirange int8multirange nummultirange tsmultirange + tstzmultirange datemultirange regclass regproc regtype pg_lsn int2vector oidvector + tid xid cid]), + mapping('Integer', %w[int2 int4 int8 serial bigserial integer smallint bigint int smallserial oid]), mapping('Float', %w[float4 float8 real double]), mapping('BigDecimal', %w[numeric decimal]), mapping('Date', %w[date]), mapping('Time', %w[timestamp timestamptz datetime]), mapping('IPAddr', %w[inet cidr]), - mapping('untyped', %w[json jsonb]) + mapping('untyped', %w[json jsonb any anyelement anyarray record void unknown]) ] end end diff --git a/lib/sqlc_gen_ruby/drivers/sqlite3.rb b/lib/sqlc_gen_ruby/drivers/sqlite3.rb index 078b8ce..a16dc7c 100644 --- a/lib/sqlc_gen_ruby/drivers/sqlite3.rb +++ b/lib/sqlc_gen_ruby/drivers/sqlite3.rb @@ -42,6 +42,8 @@ def supports_slice? = true def exec_rows_lines(exec) = [exec, 'client.changes'] + def exec_result_lines(exec) = [exec, 'ExecResult.new(client.changes, client.last_insert_row_id)'] + # Relies on a user-written `RETURNING `. def exec_last_id_lines(exec, query) if query.columns.empty? @@ -93,6 +95,19 @@ def transaction(mode: nil) private + # VARCHAR(255) -> varchar, UNSIGNED BIG INT -> unsignedbigint + def normalize_type(type_name) = super.sub(/\(.*\z/m, '').delete(" \t\n") + + # https://www.sqlite.org/datatype3.html#determination_of_column_affinity + def affinity_type(db_type) + case db_type + when /int/ then 'Integer' + when /char|clob|text/, /blob/, '' then 'String' + when /real|floa|doub/ then 'Float' + else 'Float' # NUMERIC affinity, see decimal / numeric above + end + end + def driver_gems = %w[sqlite3] def column_mappings diff --git a/lib/sqlc_gen_ruby/generator.rb b/lib/sqlc_gen_ruby/generator.rb index d0b9b08..37a56c3 100644 --- a/lib/sqlc_gen_ruby/generator.rb +++ b/lib/sqlc_gen_ruby/generator.rb @@ -126,7 +126,7 @@ def query_members(query, method_gen) members << [@driver.query_text_constant(query), "#{Code.sql_constant(query.name)}: String"] end members << dataclass("#{Code.upcase_first(query.name)}Row", row_pairs(query)) unless query.columns.empty? - if query.cmd == ':copyfrom' + if MethodGen::ROWS_COMMANDS.include?(query.cmd) pairs = method_gen.params(query).map { |p| [Code.attr_name(p.name), p.type] } members << dataclass("#{Code.upcase_first(query.name)}Args", pairs) end @@ -138,6 +138,10 @@ def row_pairs(query) = @layout.entries(query).map { |e| [e.attr, e.type] } # constructor / connect / transaction, one method per query, then the private helpers def class_members(queries, method_gen) members = @driver.class_methods.dup + if queries.any? { |q| q.cmd == ':execresult' } + # last_insert_id is nil on PostgreSQL (use RETURNING) + members << dataclass('ExecResult', [%w[rows_affected Integer], ['last_insert_id', 'Integer?']]) + end members.concat(queries.map { |q| [method_gen.code(q), method_gen.type(q)] }) helpers = @driver.private_methods + method_gen.helper_methods(queries) unless helpers.empty? diff --git a/lib/sqlc_gen_ruby/method_gen.rb b/lib/sqlc_gen_ruby/method_gen.rb index 8f084c9..f1c43f8 100644 --- a/lib/sqlc_gen_ruby/method_gen.rb +++ b/lib/sqlc_gen_ruby/method_gen.rb @@ -12,6 +12,11 @@ class MethodGen 'def slice_placeholders: (Array[untyped] values) -> String' ].freeze + # :batch* run the query once per element of an Array of Args, on one connection + BATCH_COMMANDS = { ':batchexec' => ':exec', ':batchone' => ':one', ':batchmany' => ':many' }.freeze + # commands whose method takes `rows` (an Array of Args) instead of keyword arguments + ROWS_COMMANDS = [':copyfrom', *BATCH_COMMANDS.keys].freeze + # optional: nullable and named by the user (sqlc.arg / sqlc.narg) -> keyword argument defaulting to nil Param = Struct.new(:name, :column, :slice, :optional, :type) @@ -40,6 +45,9 @@ def code(query) if query.cmd == ':copyfrom' statements = copy_statements(query, params) signature = '(rows)' + elsif BATCH_COMMANDS.key?(query.cmd) + statements = batch_statements(query, func, params) + signature = '(rows)' else statements = query_statements(query, func, params) signature = params.empty? ? '' : "(#{params.map { |p| p.optional ? "#{p.name}: nil" : "#{p.name}:" }.join(', ')})" @@ -58,7 +66,7 @@ def bang_method(query, func) def type(query) func = Code.snake_case(query.name) - if query.cmd == ':copyfrom' + if ROWS_COMMANDS.include?(query.cmd) signature = "(Array[#{query.name}Args] rows)" else list = params(query).map { |p| "#{p.optional ? '?' : ''}#{p.name}: #{p.type}" } @@ -98,6 +106,9 @@ def return_type(query) when ':many' then "Array[#{query.name}Row]" when ':execrows' then 'Integer' when ':execlastid' then last_id_type(query) + when ':execresult' then 'ExecResult' + when ':batchone' then "Array[#{query.name}Row?]" + when ':batchmany' then "Array[Array[#{query.name}Row]]" else 'void' end end @@ -142,6 +153,29 @@ def query_statements(query, func, params) statements end + # Prepared once, executed for every element of rows. + def batch_statements(query, func, params) + raise ArgumentError, "#{query.name}: sqlc.slice cannot be used in #{query.cmd}" if slice?(query) + + const = Code.sql_constant(query.name) + stmt_name = "#{@statement_prefix}_#{func}" + statements = [@driver.prepare_stmt(stmt_name, const)].compact + cmd = BATCH_COMMANDS.fetch(query.cmd) + body = [] + body << "query_params = [#{Code.comma_join(params.map { |p| "args.#{Code.attr_name(p.name)}" })}]" unless params.empty? + body.concat(command_statements(query, stmt_name, const, !params.empty?, cmd: cmd)) + body.pop if cmd == ':exec' # the trailing nil + block_arg = params.empty? ? '_args' : 'args' + loop = Code.block("rows.#{cmd == ':exec' ? 'each' : 'map'} do |#{block_arg}|", body.join("\n")) + commands = cmd == ':exec' ? [loop, 'nil'] : [loop] + if @driver.closes_statements? + statements << Code.block('begin', commands.join("\n")).sub(/\nend\z/, "\nensure\n stmt.close\nend") + else + statements.concat(commands) + end + statements + end + def query_params(params) list = params.map { |p| p.slice ? "*#{p.name}" : p.name } "query_params = [#{Code.comma_join(list)}]" @@ -155,9 +189,9 @@ def slice_sql(const, params) end # The value of the last expression is the return value of the method (no explicit `return`). - def command_statements(query, stmt_name, const, has_params) + def command_statements(query, stmt_name, const, has_params, cmd: query.cmd) exec = @driver.execute_stmt(stmt_name, const, has_params, array_rows: @layout.array_rows?(query)) - case query.cmd + case cmd when ':one' ["result = #{exec}", 'row = result.first', "row && #{new_row(query)}"] when ':many' @@ -165,6 +199,7 @@ def command_statements(query, stmt_name, const, has_params) when ':exec' then [exec, 'nil'] when ':execrows' then @driver.exec_rows_lines(exec) when ':execlastid' then @driver.exec_last_id_lines(exec, query) + when ':execresult' then @driver.exec_result_lines(exec) else raise ArgumentError, "unsupported query command: #{query.cmd}" end end diff --git a/lib/sqlc_gen_ruby/plugin_pb.rb b/lib/sqlc_gen_ruby/plugin_pb.rb index 1745d1a..e9e2a22 100644 --- a/lib/sqlc_gen_ruby/plugin_pb.rb +++ b/lib/sqlc_gen_ruby/plugin_pb.rb @@ -66,12 +66,17 @@ class Table < P field :comment, 3, :string end + class CompositeType < P + field :name, 1, :string + field :comment, 2, :string + end + class Schema < P - # composite_types(5) is skipped field :comment, 1, :string field :name, 2, :string field :tables, 3, :message, repeated: true, klass: Table field :enums, 4, :message, repeated: true, klass: Enum + field :composite_types, 5, :message, repeated: true, klass: CompositeType end class Catalog < P diff --git a/lib/sqlc_gen_ruby/type_resolver.rb b/lib/sqlc_gen_ruby/type_resolver.rb index d30ab03..6a4b4aa 100644 --- a/lib/sqlc_gen_ruby/type_resolver.rb +++ b/lib/sqlc_gen_ruby/type_resolver.rb @@ -10,7 +10,7 @@ def initialize(driver, options) # query_name: used to look up `overrides:` entries; nil for model (table) columns. # fallback: see Drivers::Base#column_type. - def type(column, query_name: nil, fallback: :raise) + def type(column, query_name: nil, fallback: 'untyped') override = query_name && @options.override_for(query_name, column.name) base = override ? override.type : @driver.column_type(column, fallback: fallback) base = array_of(base, column) if column.is_array && !override @@ -24,7 +24,7 @@ def nullable?(column, query_name) # A sqlc.slice parameter is passed as an Array of the element type. def slice_type(column, query_name) override = @options.override_for(query_name, column.name) - "Array[#{override ? override.type : @driver.column_type(column)}]" + "Array[#{override ? override.type : @driver.column_type(column, fallback: 'untyped')}]" end private diff --git a/steep_expectations.yml b/steep_expectations.yml index 15550b8..370eea9 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -3,40 +3,40 @@ diagnostics: - range: start: - line: 198 + line: 229 character: 10 end: - line: 198 + line: 229 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 198 + line: 229 character: 47 end: - line: 198 + line: 229 character: 53 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 198 + line: 229 character: 66 end: - line: 198 + line: 229 character: 81 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 615 + line: 710 character: 6 end: - line: 615 + line: 710 character: 9 severity: WARNING message: 'Cannot find the declaration of constant: `CSV`' @@ -45,110 +45,110 @@ diagnostics: - range: start: - line: 249 + line: 304 character: 10 end: - line: 249 + line: 304 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 249 + line: 304 character: 47 end: - line: 249 + line: 304 character: 49 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 249 + line: 304 character: 58 end: - line: 249 + line: 304 character: 69 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 263 + line: 318 character: 49 end: - line: 263 + line: 318 character: 51 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 463 + line: 521 character: 18 end: - line: 463 + line: 521 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 727 + line: 898 character: 17 end: - line: 727 + line: 898 character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 729 + line: 900 character: 17 end: - line: 729 + line: 900 character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 731 + line: 902 character: 27 end: - line: 731 + line: 902 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 731 + line: 902 character: 91 end: - line: 731 + line: 902 character: 93 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 733 + line: 904 character: 34 end: - line: 733 + line: 904 character: 36 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 736 + line: 907 character: 36 end: - line: 736 + line: 907 character: 38 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' diff --git a/test/features_test.rb b/test/features_test.rb index 69901a2..0628e9d 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -55,10 +55,52 @@ def test_models_fall_back_to_untyped_for_unknown_types assert_includes generate(request).fetch('models.rbs'), 'attr_reader id: untyped' end - def test_unknown_types_in_queries_fail + def test_unknown_types_in_queries_fall_back_to_untyped request = load_request('pg') query(request, 'GetAuthor').columns.first.type.name = 'geometry' - assert_raises(RuntimeError) { generate(request) } + query(request, 'GetAuthor').params.first.column.type.name = 'geometry' + rbs = generate(request).fetch('query_sql.rbs') + assert_includes rbs, "class GetAuthorRow\n attr_reader id: untyped\n" + assert_includes rbs, 'def get_author: (id: untyped) -> GetAuthorRow?' + end + + def test_more_postgres_types_composites_and_domains + rbs = generate(load_request('pg')).fetch('query_sql.rbs') + %w[c_timetz c_tsvector c_point c_int4range c_dimensions].each { |c| assert_includes rbs, "attr_reader #{c}: String?" } + assert_includes rbs, 'attr_reader c_oid: Integer?' + assert_includes rbs, 'attr_reader c_positive: untyped' # sqlc does not send the base type of a domain + end + + def test_sqlite_declared_types_follow_type_affinity + rbs = generate(load_request('sqlite3')).fetch('query_sql.rbs') + assert_match(/attr_reader c_varchar: String\?\n\s+attr_reader c_decimal: Float\?\n\s+attr_reader c_unsigned_big_int: Integer\?/, rbs) + request = load_request('sqlite3') + query(request, 'GetAuthor').columns.first.type.name = 'MEDIUMINT(4)' + query(request, 'GetAuthor').columns[1].type.name = 'STRING' + assert_match(/class GetAuthorRow\n\s+attr_reader id: Integer\n\s+attr_reader name: Float\n/, generate(request).fetch('query_sql.rbs')) + end + + def test_execresult_returns_rows_affected_and_last_insert_id + { 'pg' => 'ExecResult.new(result.cmd_tuples, nil)', 'mysql2' => 'ExecResult.new(stmt.affected_rows, client.last_id)', + 'sqlite3' => 'ExecResult.new(client.changes, client.last_insert_row_id)' }.each do |driver, line| + files = generate(load_request(driver)) + assert_includes files.fetch('query_sql.rb'), line + assert_includes files.fetch('query_sql.rb'), 'class ExecResult < Data.define(:rows_affected, :last_insert_id)' + assert_includes files.fetch('query_sql.rbs'), 'def create_author_result: (name: String, bio: String?) -> ExecResult' + end + end + + def test_batch_commands_take_an_array_of_args + %w[pg mysql2 sqlite3].each do |driver| + files = generate(load_request(driver)) + rbs = files.fetch('query_sql.rbs') + assert_includes rbs, 'def create_authors_batch: (Array[CreateAuthorsBatchArgs] rows) -> void' + assert_includes rbs, 'def get_author_batch: (Array[GetAuthorBatchArgs] rows) -> Array[GetAuthorBatchRow?]' + assert_includes rbs, 'def list_authors_by_name_batch: (Array[ListAuthorsByNameBatchArgs] rows) -> ' \ + 'Array[Array[ListAuthorsByNameBatchRow]]' + assert_includes files.fetch('query_sql.rb'), 'class CreateAuthorsBatchArgs < Data.define(:name, :bio)' + assert_includes files.fetch('query_sql.rb'), "rows.map do |args|\n" + end end def test_embedded_tables_become_model_attributes diff --git a/test/fixtures/mysql2.bin b/test/fixtures/mysql2.bin index eef840c..07b5523 100644 --- a/test/fixtures/mysql2.bin +++ b/test/fixtures/mysql2.bin @@ -232,4 +232,22 @@ XINSERT INTO extended_bios (author_name, name, bio_type, author_type) VALUES (?, bio_type0 R extended_biosbextended_bios_bio_typezbio_type"J author_type0R extended_biosbextended_bios_author_typez author_type*EA bio_type0 R extended_biosbextended_bios_bio_typezbio_type: query.sqlC -DELETE FROM extended_biosTruncateExtendedBios:exec: query.sql"v1.27.0*T{"driver":"mysql2","generateGemfile":false,"generateTypes":true,"rubyVersion":"3.3"} \ No newline at end of file +DELETE FROM extended_biosTruncateExtendedBios:exec: query.sqlÖ +-INSERT INTO authors (name, bio) VALUES (?, ?)CreateAuthorResult :execresult*84 +name0ÿÿÿÿÿÿÿÿÿRpublicauthorsbtextzname*40 +bio0ÿÿÿÿÿÿÿÿÿRpublicauthorsbtextzbio: query.sqlB authorsÕ +-INSERT INTO authors (name, bio) VALUES (?, ?)CreateAuthorsBatch +:batchexec*84 +name0ÿÿÿÿÿÿÿÿÿRpublicauthorsbtextzname*40 +bio0ÿÿÿÿÿÿÿÿÿRpublicauthorsbtextzbio: query.sqlB authorsŠ +.SELECT id, name, bio FROM authors WHERE id = ?GetAuthorBatch :batchone"* +id0ÿÿÿÿÿÿÿÿÿR authorsbbigintzid", +name0ÿÿÿÿÿÿÿÿÿR authorsbtextzname"( +bio0ÿÿÿÿÿÿÿÿÿR authorsbtextzbio*.* +id0ÿÿÿÿÿÿÿÿÿR authorsbbigintzid: query.sql£ +