diff --git a/README.md b/README.md index 98adb55..5e40ddc 100644 --- a/README.md +++ b/README.md @@ -131,13 +131,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, also through `break` / `return` / `throw`; rollback when it raises or the thread is killed) 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 035c13f..8d76c5d 100644 --- a/examples/authors/postgresql/query.sql +++ b/examples/authors/postgresql/query.sql @@ -102,6 +102,28 @@ 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; + -- name: CreateRegression :one INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; diff --git a/examples/authors/postgresql/schema.sql b/examples/authors/postgresql/schema.sql index 3afb157..be5a234 100644 --- a/examples/authors/postgresql/schema.sql +++ b/examples/authors/postgresql/schema.sql @@ -59,6 +59,12 @@ CREATE TABLE postgres_extra_types ( 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 [][] +); + CREATE TABLE regressions ( id bigint PRIMARY KEY, "order" text NOT NULL, diff --git a/examples/pg/models.rb b/examples/pg/models.rb index 0196340..fd04101 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 Regression < Data.define( :id, :order, diff --git a/examples/pg/models.rbs b/examples/pg/models.rbs index a96af6a..5644e4d 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 Regression attr_reader id: Integer attr_reader order: String diff --git a/examples/pg/query_sql.rb b/examples/pg/query_sql.rb index 43d5da8..605aebc 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 + CREATE_REGRESSION_SQL = <<~'SQL' INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids SQL @@ -650,6 +686,76 @@ 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 create_regression(id:, order:, payload:, doc:, bio_types:, uids:) with_client('create_regression') do |client| query_params = [ diff --git a/examples/pg/query_sql.rbs b/examples/pg/query_sql.rbs index 38dc3aa..fdbbe0c 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 + CREATE_REGRESSION_SQL: String class CreateRegressionRow @@ -319,6 +349,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 create_regression: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow? def create_regression!: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow 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 bfd61d2..57f2ddc 100644 --- a/lib/sqlc_gen_ruby/code.rb +++ b/lib/sqlc_gen_ruby/code.rb @@ -98,6 +98,16 @@ def identifier(name) name.match?(/\A\p{Digit}/) ? "_#{name}" : name 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/generator.rb b/lib/sqlc_gen_ruby/generator.rb index 7044ee7..3d932da 100644 --- a/lib/sqlc_gen_ruby/generator.rb +++ b/lib/sqlc_gen_ruby/generator.rb @@ -31,7 +31,9 @@ def generate(request) check_constants(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| + 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) @@ -87,7 +89,14 @@ 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 def generate_files(queries, class_name, with_models:, with_connection:) @@ -141,7 +150,8 @@ def row_pairs(query) = @layout.entries(query).map { |e| [e.attr, e.type] } def class_members(queries, method_gen) members = @driver.class_methods.dup helpers = @driver.private_methods + method_gen.helper_methods(queries) - check_method_names(queries, (members + helpers).flat_map { |code, _| code.scan(/^def (?:self\.)?([\w?!]+)/) }.flatten) + check_method_names(queries, (members + helpers).flat_map { |code, _| code.scan(/^def (?:self\.)?([\w?!]+)/) }.flatten + + Code::OBJECT_METHODS) members.concat(queries.map { |q| [method_gen.code(q), method_gen.type(q)] }) unless helpers.empty? members << ["private\n\n#{helpers.map(&:first).join("\n\n")}", "private\n\n#{helpers.map(&:last).join("\n\n")}"] diff --git a/lib/sqlc_gen_ruby/method_gen.rb b/lib/sqlc_gen_ruby/method_gen.rb index ff67500..4c5ba4f 100644 --- a/lib/sqlc_gen_ruby/method_gen.rb +++ b/lib/sqlc_gen_ruby/method_gen.rb @@ -27,6 +27,7 @@ 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) copy = query.cmd == ':copyfrom' by_name = {} @@ -207,7 +208,8 @@ def new_row(query) values = entry.table.columns.each_index.map { |i| "row[#{index + i}]" } index += values.size names = entry.table.columns.map { |c| @types.member_name(c.name) } - "#{entry.table.model_name}.new(#{constructor_args(names, values).join(', ')})" + model = "#{entry.table.model_name}.new(#{constructor_args(names, values).join(', ')})" + entry.optional ? "#{missing_row_check(entry.table, values)} ? nil : #{model}" : model else index += 1 array ? "row[#{index - 1}]" : "row['#{entry.column.name}']" @@ -224,5 +226,12 @@ def new_row(query) def constructor_args(names, values) @types.keyword_rows? ? names.zip(values).map { |n, v| "#{n}: #{v}" } : values 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 d1f7002..f2d59dc 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 @@ -31,13 +33,16 @@ def embed?(column) = !column.embed_table.nil? && !column.embed_table.name.empty? def build(query) taken = query.columns.reject { |c| embed?(c) }.map { |c| @types.member_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, @types.member_name(Code.singularize(column.name))), table.model_name, column, table) + name = unique_name(taken, @types.member_name(Code.singularize(column.name))) + Entry.new(name, outer ? "#{table.model_name}?" : table.model_name, column, table, outer) else - Entry.new(@types.member_name(column.name), @types.type(column, query_name: query.name), column, nil) + Entry.new(@types.member_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 0a2a423..e0e6851 100644 --- a/lib/sqlc_gen_ruby/type_resolver.rb +++ b/lib/sqlc_gen_ruby/type_resolver.rb @@ -15,7 +15,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_type(column, query_name, 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 @@ -36,6 +36,11 @@ def member_name(name) = Code.member_name(name, struct: keyword_rows?) 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 driver_type(column, query_name, fallback) @driver.column_type(column, fallback: fallback) rescue ArgumentError => e diff --git a/steep_expectations.yml b/steep_expectations.yml index c496551..9c30482 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -35,150 +35,150 @@ diagnostics: - range: start: - line: 255 + line: 291 character: 10 end: - line: 255 + line: 291 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `ConnectionPool`' code: Ruby::UnknownConstant - range: start: - line: 255 + line: 291 character: 47 end: - line: 255 + line: 291 character: 49 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 255 + line: 291 character: 58 end: - line: 255 + line: 291 character: 69 severity: WARNING message: Empty kwargs doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 270 + line: 306 character: 49 end: - line: 270 + line: 306 character: 51 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 477 + line: 513 character: 18 end: - line: 477 + line: 513 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 700 + line: 806 character: 18 end: - line: 700 + line: 806 character: 20 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 767 + line: 873 character: 17 end: - line: 767 + line: 873 character: 19 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 770 + line: 876 character: 36 end: - line: 770 + line: 876 character: 38 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 776 + line: 882 character: 16 end: - line: 776 + line: 882 character: 18 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 777 + line: 883 character: 15 end: - line: 777 + line: 883 character: 17 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 778 + line: 884 character: 79 end: - line: 778 + line: 884 character: 81 severity: WARNING message: Empty array doesn't have type annotation code: Ruby::UnannotatedEmptyCollection - range: start: - line: 779 + line: 885 character: 25 end: - line: 779 + line: 885 character: 27 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 780 + line: 886 character: 67 end: - line: 780 + line: 886 character: 69 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 788 + line: 894 character: 15 end: - line: 788 + line: 894 character: 17 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' code: Ruby::UnknownConstant - range: start: - line: 793 + line: 899 character: 27 end: - line: 793 + line: 899 character: 29 severity: WARNING message: 'Cannot find the declaration of constant: `PG`' diff --git a/test/features_test.rb b/test/features_test.rb index 7ca4ad1..2a0a0b6 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -243,6 +243,60 @@ def test_logger_support 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}: method" + 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_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 + # ---- regressions (examples/authors/*: the `regressions` table) ----------------------------------------------- def test_reused_and_repeated_parameters_get_one_keyword_each diff --git a/test/fixtures/pg.bin b/test/fixtures/pg.bin index 5846fce..8699e21 100644 Binary files a/test/fixtures/pg.bin and b/test/fixtures/pg.bin differ diff --git a/test/fixtures/sqlite3.bin b/test/fixtures/sqlite3.bin index f27e086..c1c9ceb 100644 Binary files a/test/fixtures/sqlite3.bin and b/test/fixtures/sqlite3.bin differ diff --git a/test/golden/pg/default/models.rb b/test/golden/pg/default/models.rb index 0196340..fd04101 100644 --- a/test/golden/pg/default/models.rb +++ b/test/golden/pg/default/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 Regression < Data.define( :id, :order, diff --git a/test/golden/pg/default/models.rbs b/test/golden/pg/default/models.rbs index a96af6a..5644e4d 100644 --- a/test/golden/pg/default/models.rbs +++ b/test/golden/pg/default/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 Regression attr_reader id: Integer attr_reader order: String diff --git a/test/golden/pg/default/query_sql.rb b/test/golden/pg/default/query_sql.rb index 92bf313..d8b613a 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 + CREATE_REGRESSION_SQL = <<~'SQL' INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids SQL @@ -680,6 +716,76 @@ 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 create_regression(id:, order:, payload:, doc:, bio_types:, uids:) with_client('create_regression') do |client| query_params = [ diff --git a/test/golden/pg/default/query_sql.rbs b/test/golden/pg/default/query_sql.rbs index f89aa56..727cc8e 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 + CREATE_REGRESSION_SQL: String class CreateRegressionRow @@ -354,6 +384,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 create_regression: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow? def create_regression!: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow diff --git a/test/golden/pg/ruby31_gemfile/models.rb b/test/golden/pg/ruby31_gemfile/models.rb index 0e1bd5e..8adf9aa 100644 --- a/test/golden/pg/ruby31_gemfile/models.rb +++ b/test/golden/pg/ruby31_gemfile/models.rb @@ -54,6 +54,8 @@ module ExtendedBioType keyword_init: true ) + PostgresArrayType = Struct.new(:c_uuid_array, :c_bio_type_array, :c_integer_matrix, keyword_init: true) + Regression = Struct.new( :id, :order, diff --git a/test/golden/pg/ruby31_gemfile/models.rbs b/test/golden/pg/ruby31_gemfile/models.rbs index 620fec3..5fc07e4 100644 --- a/test/golden/pg/ruby31_gemfile/models.rbs +++ b/test/golden/pg/ruby31_gemfile/models.rbs @@ -116,6 +116,14 @@ module PgCodegen ) -> PostgresExtraType end + class PostgresArrayType < ::Struct[untyped] + attr_accessor c_uuid_array: Array[String]? + attr_accessor c_bio_type_array: Array[String]? + attr_accessor c_integer_matrix: Array[Array[Integer]]? + def initialize: ( c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> void + def self.new: ( c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> PostgresArrayType + end + class Regression < ::Struct[untyped] attr_accessor id: Integer attr_accessor order: String diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rb b/test/golden/pg/ruby31_gemfile/query_sql.rb index fbe0df9..28a90e9 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rb +++ b/test/golden/pg/ruby31_gemfile/query_sql.rb @@ -196,6 +196,39 @@ module PgCodegen 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 + + GetArrayTypesRow = Struct.new(:c_uuid_array, :c_bio_type_array, :c_integer_matrix, keyword_init: true) + + 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 + + ListAuthorsWithOptionalBookRow = Struct.new(:author, :book, keyword_init: true) + + LIST_AUTHORS_BETWEEN_IDS_SQL = <<~'SQL' + SELECT id, name, bio FROM authors + WHERE id > $1 AND id < $2 + ORDER BY id + SQL + + ListAuthorsBetweenIdsRow = Struct.new(:id, :name, :bio, keyword_init: true) + CREATE_REGRESSION_SQL = <<~'SQL' INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids SQL @@ -693,6 +726,80 @@ 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( + c_uuid_array: row['c_uuid_array'], + c_bio_type_array: row['c_bio_type_array'], + c_integer_matrix: 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( + author: row[0].nil? && row[1].nil? ? nil : Author.new(id: row[0], name: row[1], bio: row[2]), + book: row[3].nil? && row[4].nil? && row[5].nil? ? nil : Book.new(id: row[3], name: row[4], author_id: row[5], description: 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(id: row['id'], name: row['name'], bio: row['bio']) + end + end + end + def create_regression(id:, order:, payload:, doc:, bio_types:, uids:) with_client('create_regression') do |client| query_params = [ diff --git a/test/golden/pg/ruby31_gemfile/query_sql.rbs b/test/golden/pg/ruby31_gemfile/query_sql.rbs index 5bd6b4f..fe4828e 100644 --- a/test/golden/pg/ruby31_gemfile/query_sql.rbs +++ b/test/golden/pg/ruby31_gemfile/query_sql.rbs @@ -242,6 +242,39 @@ module PgCodegen TRUNCATE_EXTRA_TYPES_SQL: String + INSERT_ARRAY_TYPES_SQL: String + + GET_ARRAY_TYPES_SQL: String + + class GetArrayTypesRow < ::Struct[untyped] + attr_accessor c_uuid_array: Array[String]? + attr_accessor c_bio_type_array: Array[String]? + attr_accessor c_integer_matrix: Array[Array[Integer]]? + def initialize: ( c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> void + def self.new: ( c_uuid_array: Array[String]?, c_bio_type_array: Array[String]?, c_integer_matrix: Array[Array[Integer]]?) -> GetArrayTypesRow + end + + TRUNCATE_ARRAY_TYPES_SQL: String + + LIST_AUTHORS_WITH_OPTIONAL_BOOK_SQL: String + + class ListAuthorsWithOptionalBookRow < ::Struct[untyped] + attr_accessor author: Author? + attr_accessor book: Book? + def initialize: ( author: Author?, book: Book?) -> void + def self.new: ( author: Author?, book: Book?) -> ListAuthorsWithOptionalBookRow + end + + LIST_AUTHORS_BETWEEN_IDS_SQL: String + + class ListAuthorsBetweenIdsRow < ::Struct[untyped] + attr_accessor id: Integer + attr_accessor name: String + attr_accessor bio: String? + def initialize: ( id: Integer, name: String, bio: String?) -> void + def self.new: ( id: Integer, name: String, bio: String?) -> ListAuthorsBetweenIdsRow + end + CREATE_REGRESSION_SQL: String class CreateRegressionRow < ::Struct[untyped] @@ -425,6 +458,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 create_regression: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow? def create_regression!: (id: Integer, order: String, payload: String?, doc: untyped, bio_types: Array[String]?, uids: Array[String]?) -> CreateRegressionRow diff --git a/test/golden/pg/ruby32_notypes/models.rb b/test/golden/pg/ruby32_notypes/models.rb index 0196340..fd04101 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 Regression < Data.define( :id, :order, diff --git a/test/golden/pg/ruby32_notypes/query_sql.rb b/test/golden/pg/ruby32_notypes/query_sql.rb index 92bf313..d8b613a 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 + CREATE_REGRESSION_SQL = <<~'SQL' INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, "order", payload, doc, bio_types, uids SQL @@ -680,6 +716,76 @@ 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 create_regression(id:, order:, payload:, doc:, bio_types:, uids:) with_client('create_regression') do |client| query_params = [ diff --git a/tests/end2end_pg.rb b/tests/end2end_pg.rb index ab756b1..9796bb1 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 @query_sql.truncate_regressions end @@ -168,6 +169,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 @@ -260,6 +276,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,