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] 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,