From 76bbbf6e8ad7056c8ba047b0e2885bbfa0a54f98 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:27:07 +0000 Subject: [PATCH] Options: table/db_type overrides for models too, rename, exactTableNames, global options, unknown-option errors, engine check, schema comments Co-Authored-By: Doron Eli Rachman --- README.md | 19 ++++- docs/Architecture.md | 2 +- examples/authors/postgresql/schema.sql | 4 + examples/pg/models.rb | 2 + examples/pg/models.rbs | 3 + lib/sqlc_gen_ruby/catalog.rb | 29 +++++-- lib/sqlc_gen_ruby/code.rb | 7 ++ lib/sqlc_gen_ruby/generator.rb | 51 ++++++++--- lib/sqlc_gen_ruby/options.rb | 104 ++++++++++++++++++----- lib/sqlc_gen_ruby/plugin_pb.rb | 7 +- lib/sqlc_gen_ruby/row_layout.rb | 4 +- lib/sqlc_gen_ruby/type_resolver.rb | 10 +-- test/features_test.rb | 48 +++++++++++ test/fixtures/mysql2.bin | 5 +- test/fixtures/pg.bin | 15 ++-- test/fixtures/sqlite3.bin | Bin 7267 -> 7280 bytes test/golden/pg/default/models.rb | 2 + test/golden/pg/default/models.rbs | 3 + test/golden/pg/ruby31_gemfile/models.rb | 2 + test/golden/pg/ruby31_gemfile/models.rbs | 3 + test/golden/pg/ruby32_notypes/models.rb | 2 + test/options_test.rb | 15 ++++ 22 files changed, 283 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index c3d9c8d..69c64f1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ codec), and it is shipped as a single self-contained WebAssembly module — the | overrides | a list, see [below](#override-option) | Yes | Overrides the generated (RBS) type of specific columns / parameters. | | moduleName | default: `Codegen` (e.g. `PgCodegen`) | Yes | The Ruby module the generated code lives in, e.g. `MyApp::Db`. | | preparedStatements | default: `true`
values: `false`,`true` | Yes | `pg` only: `false` sends the SQL with every call (`exec_params`) - needed behind pgbouncer in transaction mode. | +| rename | an object, e.g. `{authors: Writer, bio: biography}` | Yes | Renames generated identifiers: a table name (or `schema.table`) gives the model class, a column name gives the row / model attribute. | +| exactTableNames | default: `false`
values: `false`,`true` | Yes | `true` keeps table names as they are for model classes (`authors` => `Authors`) instead of singularizing them. | + +Unknown options are rejected (with a hint for near misses such as `generate_types`). Options can also be set once for +all `sql:` blocks in the top-level `options:` of `sqlc.yaml`, under the plugin's name (`options: {ruby: {moduleName: MyApp::Db}}`). +The `options:` of a codegen entry win over these, and the `overrides` of both apply. A driver used with another +`engine:` (e.g. `pg` with `engine: mysql`) is an error. + +Table, column and enum comments (`COMMENT ON ...` in PostgreSQL, `COMMENT '...'` in MySQL) become doc comments of the +generated models (column comments in the RBS). ### Override option ```yaml @@ -29,11 +39,18 @@ overrides: ruby_type: type: "Symbol" notNull: true - - column: "*:" # every query + - column: "*:" # every query and model ruby_type: type: "Pathname" notNull: false + - column: "." # a table column (".
." too): its model and query columns + ruby_type: + type: "Symbol" + - db_type: "uuid" # every column / parameter of a database type + ruby_type: + type: "String" ``` +The first match wins, in this order: `:`, `
.`, `*:`, `db_type`. ### Configuration ```yaml diff --git a/docs/Architecture.md b/docs/Architecture.md index 381b96b..851d678 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -20,7 +20,7 @@ stdout ◄─ Proto (encode) ◄─ GenerateResponse (query_sql.rb, query_sql.rb |---|---| | `exe/plugin.rb` | entry point. Errors go to stderr with exit code 1 (sqlc shows them); `SQLC_GEN_RUBY_DEBUG=1` adds a backtrace | | `proto.rb`, `plugin_pb.rb` | a ~150 line protobuf wire codec and the subset of sqlc's `codegen.proto` we read. Field numbers must match upstream. `google-protobuf` is a C extension and cannot run inside ruby.wasm | -| `options.rb`, `ruby_version.rb` | parse + validate the `options:` JSON (`driver`, `rubyVersion`, `overrides`, ...) | +| `options.rb`, `ruby_version.rb` | parse + validate the `options:` JSON (`driver`, `rubyVersion`, `overrides`, `rename`, ...; unknown keys are errors) | | `generator.rb` | the orchestrator: one file per query file (`query_sql.rb`), plus `models.rb`, `connection.rb`, `Gemfile`; builds the module / class / `Data` skeleton | | `method_gen.rb` | the code of one query: signature (keyword arguments), SQL constant, command (`:one`, `:many`, `:exec`, `:execrows`, `:execlastid`, `:copyfrom`), `sqlc.slice`, bang finder, and the RBS signature | | `row_layout.rb` | turns result columns into row attributes; `sqlc.embed` collapses a table's columns into one model attribute | diff --git a/examples/authors/postgresql/schema.sql b/examples/authors/postgresql/schema.sql index df993ef..7ce59aa 100644 --- a/examples/authors/postgresql/schema.sql +++ b/examples/authors/postgresql/schema.sql @@ -78,3 +78,7 @@ CREATE TABLE postgres_special_types ( c_dimensions extended.dimensions, c_positive positive_int ); + +COMMENT ON TABLE authors IS 'People who wrote at least one book.'; +COMMENT ON COLUMN authors.bio IS 'Short biography, NULL when unknown.'; +COMMENT ON TYPE extended.bio_type IS 'Kinds of biographies.'; diff --git a/examples/pg/models.rb b/examples/pg/models.rb index 5fc641b..7a6b0d1 100644 --- a/examples/pg/models.rb +++ b/examples/pg/models.rb @@ -3,6 +3,7 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY = 'Autobiography' BIOGRAPHY = 'Biography' @@ -10,6 +11,7 @@ module ExtendedBioType ALL = [AUTOBIOGRAPHY, BIOGRAPHY, MEMOIR].freeze end + # People who wrote at least one book. class Author < Data.define(:id, :name, :bio) end diff --git a/examples/pg/models.rbs b/examples/pg/models.rbs index f3d54b4..223e8c0 100644 --- a/examples/pg/models.rbs +++ b/examples/pg/models.rbs @@ -1,5 +1,6 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY: String BIOGRAPHY: String @@ -7,9 +8,11 @@ module PgCodegen ALL: Array[String] end + # People who wrote at least one book. class Author attr_reader id: Integer attr_reader name: String + # Short biography, NULL when unknown. attr_reader bio: String? def initialize: ( Integer id, String name, String? bio) -> void end diff --git a/lib/sqlc_gen_ruby/catalog.rb b/lib/sqlc_gen_ruby/catalog.rb index b5848f7..8037989 100644 --- a/lib/sqlc_gen_ruby/catalog.rb +++ b/lib/sqlc_gen_ruby/catalog.rb @@ -7,21 +7,26 @@ module SqlcGenRuby class Catalog INTERNAL_SCHEMAS = %w[pg_catalog information_schema pg_temp].freeze - Table = Struct.new(:schema, :name, :columns, :model_name) + Table = Struct.new(:schema, :name, :columns, :model_name, :comment) attr_reader :tables, :enums - def initialize(proto_catalog) + # rename: { db name => Ruby name } (tables -> model classes, columns -> attributes) + # exact_table_names: authors => Authors instead of Author + def initialize(proto_catalog, rename: {}, exact_table_names: false) + @rename = rename + @exact_table_names = exact_table_names @default_schema = proto_catalog&.default_schema.to_s schemas = (proto_catalog&.schemas || []).reject { |s| INTERNAL_SCHEMAS.include?(s.name) } @tables = schemas.flat_map do |schema| schema.tables.map do |t| - Table.new(schema.name, t.rel.name, t.columns, nil) + Table.new(schema.name, t.rel.name, t.columns, nil, t.comment) end end assign_model_names # qualified enum name => values; default-schema enums are reachable both bare and qualified @enums = {} + @enum_comments = {} @composites = schemas.flat_map do |schema| schema.composite_types.flat_map do |c| default_schema?(schema.name) ? [c.name, "#{schema.name}.#{c.name}"] : ["#{schema.name}.#{c.name}"] @@ -30,6 +35,7 @@ def initialize(proto_catalog) schemas.each do |schema| schema.enums.each do |e| @enums["#{schema.name}.#{e.name}"] = e.vals.to_a + @enum_comments[e.name] = @enum_comments["#{schema.name}.#{e.name}"] = e.comment @enums[e.name] = e.vals.to_a if default_schema?(schema.name) end end @@ -45,9 +51,12 @@ def enum_values(type) = @enums[qualified_name(type)] # Enums with their Ruby module names, one entry per distinct enum. def enum_modules @enums.reject { |name, _| name.include?('.') && default_schema?(name.split('.').first) } - .map { |name, vals| [Code.camelize(name), vals] } + .map { |name, vals| [Code.camelize(name), vals, @enum_comments[name]] } end + # Attribute name of a column in rows and models (`rename:` applies first). + def attr_name(column_name) = Code.attr_name(@rename.fetch(column_name, column_name)) + # embed_table: Identifier(schema, name) as sqlc sends it for sqlc.embed def table_for(identifier) @tables.find do |t| @@ -70,10 +79,11 @@ def qualified_name(type) # 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 { |t| t.model_name = model_name(t, Code.singularize(t.name)) } + @tables.each { |t| t.model_name = model_name(t, @exact_table_names ? t.name : 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.each { |t| t.model_name = renamed_model(t) if renamed_model(t) } @tables.group_by(&:model_name).each do |name, same| next if same.size == 1 @@ -82,6 +92,15 @@ def assign_model_names end end + # rename: { "authors": "Writer" } or { "extended.bios": "Bio" } + def renamed_model(table) + name = @rename["#{table.schema}.#{table.name}"] || (default_schema?(table.schema) && @rename[table.name]) + return nil unless name + raise ArgumentError, "rename #{table.name}: #{name.inspect} is not a Ruby class name" unless name.match?(/\A[A-Z]\w*\z/) + + name + end + def model_name(table, base) name = Code.camelize(base) default_schema?(table.schema) ? name : Code.camelize(table.schema) + name diff --git a/lib/sqlc_gen_ruby/code.rb b/lib/sqlc_gen_ruby/code.rb index 4897d7d..da7a373 100644 --- a/lib/sqlc_gen_ruby/code.rb +++ b/lib/sqlc_gen_ruby/code.rb @@ -88,6 +88,13 @@ def constant_part(str) name.match?(/\A[0-9_]/) ? "Q#{name}" : name end + # "Line 1\nLine 2" => "# Line 1\n# Line 2" ('' for no comment) + def comment(text) + return '' if text.to_s.strip.empty? + + text.to_s.strip.lines.map { |l| "# #{l.rstrip}".rstrip }.join("\n") + 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 37a56c3..61f3ec7 100644 --- a/lib/sqlc_gen_ruby/generator.rb +++ b/lib/sqlc_gen_ruby/generator.rb @@ -20,9 +20,10 @@ class Generator CONNECTION_FILE = 'connection' def generate(request) - @options = Options.new(request.plugin_options) + @options = Options.new(request.plugin_options, request.global_options) + check_engine(request.settings&.engine.to_s) @driver = instantiate_driver - @catalog = Catalog.new(request.catalog) + @catalog = Catalog.new(request.catalog, rename: @options.rename, exact_table_names: @options.exact_table_names) @driver.catalog = @catalog @types = TypeResolver.new(@driver, @options) @layout = RowLayout.new(@driver, @catalog, @types) @@ -52,6 +53,15 @@ def name_anonymous_params(queries) end end + ENGINES = { 'pg' => 'postgresql', 'mysql2' => 'mysql', 'sqlite3' => 'sqlite' }.freeze + + def check_engine(engine) + expected = ENGINES.fetch(@options.driver_name) + return if engine.empty? || engine == expected + + raise ArgumentError, "driver #{@options.driver_name} needs engine: #{expected} (this sql block uses #{engine})" + end + def instantiate_driver case @options.driver_name when 'mysql2' then Drivers::Mysql2.new @@ -152,26 +162,37 @@ def class_members(queries, method_gen) # --- data classes (Data.define / Struct.new) --- - def dataclass(name, pairs) + # doc: comment of the class (table comment), attr_docs: { attr => comment } (column comments, RBS only) + def dataclass(name, pairs, doc: nil, attr_docs: {}) dupes = pairs.map(&:first).tally.select { |_, n| n > 1 }.keys raise ArgumentError, "duplicate column names in #{name}: #{dupes.join(', ')}" unless dupes.empty? - @options.ruby_version.immutable_data_supported? ? data_define(name, pairs) : new_struct(name, pairs) + code, rbs = @options.ruby_version.immutable_data_supported? ? data_define(name, pairs, attr_docs) : new_struct(name, pairs, attr_docs) + comment = Code.comment(doc) + comment.empty? ? [code, rbs] : ["#{comment}\n#{code}", "#{comment}\n#{rbs}"] + end + + def attribute_lines(pairs, attr_docs, keyword) + pairs.map do |n, t| + comment = Code.comment(attr_docs[n]) + line = "#{keyword} #{n}: #{t}" + comment.empty? ? line : "#{comment}\n#{line}" + end.join("\n") end - def data_define(name, pairs) + def data_define(name, pairs, attr_docs = {}) attrs = Code.comma_join(pairs.map { |n, _| ":#{n}" }) code = "class #{name} < Data.define(#{attrs})\nend" - readers = Code.indent(pairs.map { |n, t| "attr_reader #{n}: #{t}" }.join("\n")) + readers = Code.indent(attribute_lines(pairs, attr_docs, 'attr_reader')) init_args = Code.indent(Code.comma_join(pairs.map { |n, t| "#{t} #{n}" })) init = Code.indent("def initialize: (#{init_args}) -> void") [code, "class #{name}\n#{readers}\n#{init}\nend"] end - def new_struct(name, pairs) + def new_struct(name, pairs, attr_docs = {}) attrs = Code.comma_join(pairs.map { |n, _| ":#{n}" }) code = "class #{name} < Struct.new(#{attrs})\nend" - accessors = Code.indent(pairs.map { |n, t| "attr_accessor #{n}: #{t}" }.join("\n")) + accessors = Code.indent(attribute_lines(pairs, attr_docs, 'attr_accessor')) init_args = Code.indent(Code.comma_join(pairs.map { |n, t| "#{t} #{n}" })) init = Code.indent("def self.new: (#{init_args}) -> #{name}") [code, "class #{name} < ::Struct\n#{accessors}\n#{init}\nend"] @@ -180,12 +201,14 @@ def new_struct(name, pairs) # --- models.rb: one class per table + one module per enum --- def models_files - members = @catalog.enum_modules.map { |name, vals| enum_module(name, vals) } + members = @catalog.enum_modules.map { |name, vals, comment| enum_module(name, vals, comment) } @catalog.tables.each do |table| next if table.columns.empty? - pairs = table.columns.map { |c| [Code.attr_name(c.name), @types.type(c, fallback: 'untyped')] } - members << dataclass(table.model_name, pairs) + ident = Plugin::Identifier.new(schema: table.schema, name: table.name) + pairs = table.columns.map { |c| [@catalog.attr_name(c.name), @types.type(c, table: ident, fallback: 'untyped')] } + docs = table.columns.to_h { |c| [@catalog.attr_name(c.name), c.comment.to_s] } + members << dataclass(table.model_name, pairs, doc: table.comment, attr_docs: docs) end return [] if members.empty? @@ -213,13 +236,15 @@ def connection_files end # module BioType; AUTOBIOGRAPHY = 'Autobiography'; ...; ALL = [...].freeze; end - def enum_module(name, values) + def enum_module(name, values, comment = nil) consts = unique_constants(values) code = consts.map { |const, value| "#{const} = #{quote(value)}" } code << "ALL = [#{consts.map(&:first).join(', ')}].freeze" rbs = consts.map { |const, _| "#{const}: String" } rbs << 'ALL: Array[String]' - [Code.block("module #{name}", code.join("\n")), Code.block("module #{name}", rbs.join("\n"))] + doc = Code.comment(comment) + doc = doc.empty? ? '' : "#{doc}\n" + ["#{doc}#{Code.block("module #{name}", code.join("\n"))}", "#{doc}#{Code.block("module #{name}", rbs.join("\n"))}"] end # 'Memoir' (single quotes unless the value needs escaping) diff --git a/lib/sqlc_gen_ruby/options.rb b/lib/sqlc_gen_ruby/options.rb index 4f20013..f0fde97 100644 --- a/lib/sqlc_gen_ruby/options.rb +++ b/lib/sqlc_gen_ruby/options.rb @@ -7,17 +7,25 @@ module SqlcGenRuby # Port of PluginOptions/Options.cs + RawOptions.cs: parses the JSON `options:` block of the sqlc config. class Options DRIVERS = %w[mysql2 pg sqlite3].freeze + KNOWN = %w[driver generateGemfile generateTypes rubyVersion overrides moduleName preparedStatements rename + exactTableNames].freeze - # `overrides:` entry - column is ":" or "*:"; type/not_null come from `ruby_type:` - Override = Struct.new(:query, :column, :type, :not_null) + # `overrides:` entry, matched by `column:` (":", "*:", "
.", + # ".
.") or `db_type:`; type/not_null come from `ruby_type:` + Override = Struct.new(:query, :column, :type, :not_null, :table, :schema, :db_type, keyword_init: true) attr_reader :driver_name, :ruby_version, :generate_gemfile, :generate_types, :overrides, :module_name, - :prepared_statements - - def initialize(plugin_options_bytes) - text = plugin_options_bytes.to_s.dup.force_encoding(Encoding::UTF_8) - raw = JSON.parse(text) - raise ArgumentError, 'plugin options must be a JSON object' unless raw.is_a?(Hash) + :prepared_statements, :rename, :exact_table_names + + # global_options_bytes: the plugin's entry of the top-level `options:` of sqlc.yaml; per-codegen options win, + # overrides of both apply (per-codegen first). + def initialize(plugin_options_bytes, global_options_bytes = nil) + raw = parse_object(plugin_options_bytes, 'plugin options') + global = parse_object(global_options_bytes, 'global options') + reject_unknown(global, 'global options') + reject_unknown(raw, 'plugin options') + raw = global.merge(raw).merge('overrides' => raw.fetch('overrides', []) + global.fetch('overrides', []), + 'rename' => global.fetch('rename', {}).merge(raw.fetch('rename', {}))) @driver_name = fetch_string(raw, 'driver', required: true).downcase raise ArgumentError, "unknown driver: #{raw['driver']}" unless DRIVERS.include?(@driver_name) @@ -28,35 +36,93 @@ def initialize(plugin_options_bytes) @overrides = parse_overrides(raw.fetch('overrides', [])) @module_name = raw.key?('moduleName') ? fetch_module_name(raw) : nil # default: Codegen @prepared_statements = fetch_bool(raw, 'preparedStatements', true) + @rename = parse_rename(raw['rename']) + @exact_table_names = fetch_bool(raw, 'exactTableNames', false) end - # The override matching a result column / parameter of a query: "Query:col" wins over "*:col". - def override_for(query_name, column_name) - overrides.find { |o| o.query == query_name && o.column == column_name } || - overrides.find { |o| o.query == '*' && o.column == column_name } + # The override for a result column / parameter of a query (query_name nil for model columns); the first match of + # "Query:col", "table.col", "*:col", db_type wins. table: Identifier of the column's table, if known. + def override_for(query_name, column, table: column.table) + name = column.name + overrides.find { |o| o.query && o.query == query_name && o.column == name } || + overrides.find { |o| o.table && table && o.table == table.name && o.column == name && schema_match?(o, table) } || + overrides.find { |o| o.query == '*' && o.column == name } || + overrides.find { |o| o.db_type && o.db_type == normalize_db_type(column.type&.name) } end private + def schema_match?(override, table) = override.schema.nil? || override.schema == table.schema.to_s + + def normalize_db_type(name) = name.to_s.downcase.delete_prefix('pg_catalog.') + + def parse_object(bytes, label) + text = bytes.to_s.dup.force_encoding(Encoding::UTF_8) + return {} if text.strip.empty? && label != 'plugin options' + + raw = JSON.parse(text) + raise ArgumentError, "#{label} must be a JSON object" unless raw.is_a?(Hash) + + raw + end + + def reject_unknown(raw, label) + unknown = raw.keys - KNOWN + return if unknown.empty? + + hints = unknown.map do |key| + near = KNOWN.find { |k| k.casecmp?(key) || k.casecmp?(key.delete('_')) } + near ? "#{key} (did you mean #{near}?)" : key + end + raise ArgumentError, "unknown #{label}: #{hints.join(', ')}; supported: #{KNOWN.join(', ')}" + end + + # rename: { "authors": "Writer", "bio": "biography" } - table name -> model class, column name -> attribute + def parse_rename(value) + raise ArgumentError, 'option rename must be an object' unless value.is_a?(Hash) + + value.each do |key, name| + raise ArgumentError, "rename #{key} must be a string" unless name.is_a?(String) && !name.empty? + end + value + end + def parse_overrides(list) raise ArgumentError, 'option overrides must be a list' unless list.is_a?(Array) list.map do |entry| raise ArgumentError, 'each override must be an object' unless entry.is_a?(Hash) - query, column = entry['column'].to_s.split(':', 2) - raise ArgumentError, "override column must look like ':' or '*:'" if column.to_s.empty? - + label = entry['column'] || entry['db_type'] type = entry['ruby_type'] - raise ArgumentError, "override #{entry['column']} needs ruby_type.type" unless type.is_a?(Hash) && type['type'].is_a?(String) + raise ArgumentError, "override #{label} needs ruby_type.type" unless type.is_a?(Hash) && type['type'].is_a?(String) not_null = type['notNull'] - unless [nil, true, false].include?(not_null) - raise ArgumentError, "override #{entry['column']}: notNull must be a boolean" + raise ArgumentError, "override #{label}: notNull must be a boolean" unless [nil, true, false].include?(not_null) + + override_target(entry).tap do |o| + o.type = type['type'] + o.not_null = not_null end + end + end + + def override_target(entry) + if entry.key?('db_type') + return Override.new(db_type: normalize_db_type(entry['db_type'])) if entry['db_type'].is_a?(String) - Override.new(query, column, type['type'], not_null) + raise ArgumentError, 'override db_type must be a string' + end + spec = entry['column'].to_s + if spec.include?(':') + query, column = spec.split(':', 2) + return Override.new(query: query, column: column) unless query.empty? || column.empty? + else + *schema, table, column = spec.split('.') + return Override.new(table: table, column: column, schema: schema.first) if column && schema.size <= 1 end + raise ArgumentError, "override column must look like ':', '*:' or '
.', " \ + "got #{spec.inspect}" end def fetch_string(raw, key, default: nil, required: false) diff --git a/lib/sqlc_gen_ruby/plugin_pb.rb b/lib/sqlc_gen_ruby/plugin_pb.rb index e9e2a22..454a63a 100644 --- a/lib/sqlc_gen_ruby/plugin_pb.rb +++ b/lib/sqlc_gen_ruby/plugin_pb.rb @@ -86,8 +86,13 @@ class Catalog < P field :schemas, 4, :message, repeated: true, klass: Schema end + class Settings < P + field :version, 1, :string + field :engine, 2, :string + end + class GenerateRequest < P - # settings(1) is skipped: the generator does not need it. + field :settings, 1, :message, klass: Settings field :catalog, 2, :message, klass: Catalog field :queries, 3, :message, repeated: true, klass: Query field :sqlc_version, 4, :string diff --git a/lib/sqlc_gen_ruby/row_layout.rb b/lib/sqlc_gen_ruby/row_layout.rb index 02bbfc1..9c726c6 100644 --- a/lib/sqlc_gen_ruby/row_layout.rb +++ b/lib/sqlc_gen_ruby/row_layout.rb @@ -32,7 +32,7 @@ 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 { |c| Code.attr_name(c.name) } + taken = query.columns.reject { |c| embed?(c) }.map { |c| @catalog.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| @@ -42,7 +42,7 @@ def build(query) 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(Code.attr_name(column.name), @types.type(column, query_name: query.name), column, nil, false) + Entry.new(@catalog.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 6a4b4aa..c77cc27 100644 --- a/lib/sqlc_gen_ruby/type_resolver.rb +++ b/lib/sqlc_gen_ruby/type_resolver.rb @@ -8,22 +8,22 @@ def initialize(driver, options) @options = options end - # query_name: used to look up `overrides:` entries; nil for model (table) columns. + # query_name: used to look up `overrides:` entries; nil for model (table) columns, which pass their table. # fallback: see Drivers::Base#column_type. - def type(column, query_name: nil, fallback: 'untyped') - override = query_name && @options.override_for(query_name, column.name) + def type(column, query_name: nil, table: column.table, fallback: 'untyped') + override = @options.override_for(query_name, column, table: table) base = override ? override.type : @driver.column_type(column, fallback: fallback) base = array_of(base, column) if column.is_array && !override not_null?(column, override) || base == 'untyped' ? base : "#{base}?" end def nullable?(column, query_name) - !not_null?(column, @options.override_for(query_name, column.name)) && !column.is_sqlc_slice + !not_null?(column, @options.override_for(query_name, column)) && !column.is_sqlc_slice end # A sqlc.slice parameter is passed as an Array of the element type. def slice_type(column, query_name) - override = @options.override_for(query_name, column.name) + override = @options.override_for(query_name, column) "Array[#{override ? override.type : @driver.column_type(column, fallback: 'untyped')}]" end diff --git a/test/features_test.rb b/test/features_test.rb index 0628e9d..63f3d49 100644 --- a/test/features_test.rb +++ b/test/features_test.rb @@ -346,4 +346,52 @@ def test_multi_dimensional_arrays assert_includes generate(load_request('pg')).fetch('query_sql.rbs'), 'attr_reader c_integer_matrix: Array[Array[Integer]]?' end + + def test_table_column_and_db_type_overrides_apply_to_models_and_rows + overrides = [{ column: 'authors.bio', ruby_type: { type: 'Symbol' } }, + { column: '*:c_uuid', ruby_type: { type: 'Pathname', notNull: true } }, + { db_type: 'jsonb', ruby_type: { type: 'Hash[String, untyped]' } }] + files = generate(load_request('pg', overrides: overrides)) + assert_includes files.fetch('models.rbs'), "attr_reader bio: Symbol?\n" + assert_includes files.fetch('models.rbs'), 'attr_reader c_uuid: Pathname' + assert_includes files.fetch('models.rbs'), 'attr_reader c_jsonb: Hash[String, untyped]?' + assert_match(/class GetAuthorRow\n.*\n.*\n\s+attr_reader bio: Symbol\?/, files.fetch('query_sql.rbs')) + assert_includes files.fetch('query_sql.rbs'), 'attr_reader c_uuid: Pathname' + end + + def test_rename_and_exact_table_names + files = generate(load_request('pg', rename: { 'authors' => 'Writer', 'bio' => 'biography' })) + assert_includes files.fetch('models.rb'), 'class Writer < Data.define(:id, :name, :biography)' + assert_includes files.fetch('query_sql.rb'), 'class GetAuthorRow < Data.define(:id, :name, :biography)' + assert_includes files.fetch('query_sql.rb'), "GetAuthorRow.new(row['id'], row['name'], row['bio'])" + assert_includes files.fetch('query_sql.rb'), 'Writer.new(row[0], row[1], row[2])' + assert_includes generate(load_request('pg', exactTableNames: true)).fetch('models.rb'), 'class Authors < Data.define' + assert_raises(RuntimeError) { generate(load_request('pg', rename: { 'authors' => 'writer' })) } + end + + def test_global_options_are_merged_under_plugin_options + request = load_request('pg') + request.global_options = JSON.generate(moduleName: 'Global::Db', generateTypes: false, + overrides: [{ column: '*:bio', ruby_type: { type: 'Symbol' } }]) + files = generate(request) + assert_includes files.fetch('query_sql.rb'), 'module Global::Db' + assert_includes files.keys, 'query_sql.rbs' # generateTypes: true in the plugin options wins + assert_match(/class GetAuthorRow\n.*\n.*\n\s+attr_reader bio: Symbol\?/, files.fetch('query_sql.rbs')) + end + + def test_engine_must_match_the_driver + request = load_request('pg') + request.settings = Plugin::Settings.new(engine: 'mysql') + error = assert_raises(RuntimeError) { generate(request) } + assert_includes error.message, 'driver pg needs engine: postgresql (this sql block uses mysql)' + request.settings.engine = 'postgresql' + generate(request) + end + + def test_table_column_and_enum_comments_become_doc_comments + files = generate(load_request('pg')) + assert_includes files.fetch('models.rb'), " # People who wrote at least one book.\n class Author < Data.define" + assert_includes files.fetch('models.rbs'), " # Short biography, NULL when unknown.\n attr_reader bio: String?" + assert_includes files.fetch('models.rb'), " # Kinds of biographies.\n module ExtendedBioType" + end end diff --git a/test/fixtures/mysql2.bin b/test/fixtures/mysql2.bin index 07b5523..01a024b 100644 --- a/test/fixtures/mysql2.bin +++ b/test/fixtures/mysql2.bin @@ -1,4 +1,7 @@ -”public"‰public€ + + + +2mysql”public"‰public€ authors& id0ÿÿÿÿÿÿÿÿÿR authorsbbigint& name0ÿÿÿÿÿÿÿÿÿR authorsbtext# diff --git a/test/fixtures/pg.bin b/test/fixtures/pg.bin index 1c94b2e..147d1d7 100644 --- a/test/fixtures/pg.bin +++ b/test/fixtures/pg.bin @@ -1,8 +1,11 @@ -ìpublic"Ápublicƒ + + +2 +postgresqlÍpublic"‹publicÍ authors) id0ÿÿÿÿÿÿÿÿÿR authorsb  bigserial& -name0ÿÿÿÿÿÿÿÿÿR authorsbtext# -bio0ÿÿÿÿÿÿÿÿÿR authorsbtext™ +name0ÿÿÿÿÿÿÿÿÿR authorsbtextH +bio*#Short biography, NULL when unknown.0ÿÿÿÿÿÿÿÿÿR authorsbtext#People who wrote at least one book.™ node_postgres_types< c_bit0ÿÿÿÿÿÿÿÿÿRnode_postgres_typesb pg_catalogbitB @@ -70,14 +73,14 @@ c_tsvector0 c_dimensions0ÿÿÿÿÿÿÿÿÿRpostgres_special_typesbextended dimensionsA -c_positive0ÿÿÿÿÿÿÿÿÿRpostgres_special_typesb positive_int"�extendedÔ +c_positive0ÿÿÿÿÿÿÿÿÿRpostgres_special_typesb positive_int"´extendedÔ extendedbiosC author_name0ÿÿÿÿÿÿÿÿÿRextendedbiosb pg_catalogvarchar< name0ÿÿÿÿÿÿÿÿÿRextendedbiosb pg_catalogvarchar= -bio_type0ÿÿÿÿÿÿÿÿÿRextendedbiosbextendedbio_type", -bio_type Autobiography BiographyMemoir* +bio_type0ÿÿÿÿÿÿÿÿÿRextendedbiosbextendedbio_type"C +bio_type Autobiography BiographyMemoirKinds of biographies.* dimensionsÒ 7SELECT id, name, bio FROM authors diff --git a/test/fixtures/sqlite3.bin b/test/fixtures/sqlite3.bin index ccb4e74f09be18d42dd0158c1fb677e3baa90366..f902fb75dd3ad2a215a9cffe8bf01303b303b8fd 100644 GIT binary patch delta 21 ccmaEC@xg+Zi<^tlNQkYtFekGlbwjcY07kF|d;kCd delta 8 Pcmexh@z`P`Z?X&k6GsDq diff --git a/test/golden/pg/default/models.rb b/test/golden/pg/default/models.rb index 5fc641b..7a6b0d1 100644 --- a/test/golden/pg/default/models.rb +++ b/test/golden/pg/default/models.rb @@ -3,6 +3,7 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY = 'Autobiography' BIOGRAPHY = 'Biography' @@ -10,6 +11,7 @@ module ExtendedBioType ALL = [AUTOBIOGRAPHY, BIOGRAPHY, MEMOIR].freeze end + # People who wrote at least one book. class Author < Data.define(:id, :name, :bio) end diff --git a/test/golden/pg/default/models.rbs b/test/golden/pg/default/models.rbs index f3d54b4..223e8c0 100644 --- a/test/golden/pg/default/models.rbs +++ b/test/golden/pg/default/models.rbs @@ -1,5 +1,6 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY: String BIOGRAPHY: String @@ -7,9 +8,11 @@ module PgCodegen ALL: Array[String] end + # People who wrote at least one book. class Author attr_reader id: Integer attr_reader name: String + # Short biography, NULL when unknown. attr_reader bio: String? def initialize: ( Integer id, String name, String? bio) -> void end diff --git a/test/golden/pg/ruby31_gemfile/models.rb b/test/golden/pg/ruby31_gemfile/models.rb index b0c70a5..9b7209f 100644 --- a/test/golden/pg/ruby31_gemfile/models.rb +++ b/test/golden/pg/ruby31_gemfile/models.rb @@ -3,6 +3,7 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY = 'Autobiography' BIOGRAPHY = 'Biography' @@ -10,6 +11,7 @@ module ExtendedBioType ALL = [AUTOBIOGRAPHY, BIOGRAPHY, MEMOIR].freeze end + # People who wrote at least one book. class Author < Struct.new(:id, :name, :bio) end diff --git a/test/golden/pg/ruby31_gemfile/models.rbs b/test/golden/pg/ruby31_gemfile/models.rbs index 6fb4d71..40c44c9 100644 --- a/test/golden/pg/ruby31_gemfile/models.rbs +++ b/test/golden/pg/ruby31_gemfile/models.rbs @@ -1,5 +1,6 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY: String BIOGRAPHY: String @@ -7,9 +8,11 @@ module PgCodegen ALL: Array[String] end + # People who wrote at least one book. class Author < ::Struct attr_accessor id: Integer attr_accessor name: String + # Short biography, NULL when unknown. attr_accessor bio: String? def self.new: ( Integer id, String name, String? bio) -> Author end diff --git a/test/golden/pg/ruby32_notypes/models.rb b/test/golden/pg/ruby32_notypes/models.rb index 5fc641b..7a6b0d1 100644 --- a/test/golden/pg/ruby32_notypes/models.rb +++ b/test/golden/pg/ruby32_notypes/models.rb @@ -3,6 +3,7 @@ # auto-generated by sqlc - do not edit module PgCodegen + # Kinds of biographies. module ExtendedBioType AUTOBIOGRAPHY = 'Autobiography' BIOGRAPHY = 'Biography' @@ -10,6 +11,7 @@ module ExtendedBioType ALL = [AUTOBIOGRAPHY, BIOGRAPHY, MEMOIR].freeze end + # People who wrote at least one book. class Author < Data.define(:id, :name, :bio) end diff --git a/test/options_test.rb b/test/options_test.rb index a83a33e..7b4a864 100644 --- a/test/options_test.rb +++ b/test/options_test.rb @@ -51,4 +51,19 @@ def test_option_types_are_validated assert_raises(ArgumentError) { opts(driver: 'pg', generateTypes: 'yes') } assert_raises(ArgumentError) { opts(driver: 'pg', rubyVersion: 3.3) } end + + def test_unknown_options_are_rejected_with_a_hint + error = assert_raises(ArgumentError) { opts(driver: 'pg', generate_types: false, emitJsonTags: true) } + assert_includes error.message, 'unknown plugin options: generate_types (did you mean generateTypes?), emitJsonTags' + error = assert_raises(ArgumentError) { Options.new(JSON.generate(driver: 'pg'), JSON.generate(typo: 1)) } + assert_includes error.message, 'unknown global options: typo' + end + + def test_override_forms + o = opts(driver: 'pg', overrides: [{ column: 'public.authors.bio', ruby_type: { type: 'A' } }, + { db_type: 'pg_catalog.UUID', ruby_type: { type: 'B' } }]) + assert_equal [%w[authors bio public], 'uuid'], [o.overrides[0].to_h.values_at(:table, :column, :schema), o.overrides[1].db_type] + assert_raises(ArgumentError) { opts(driver: 'pg', overrides: [{ column: 'a.b.c.d', ruby_type: { type: 'A' } }]) } + assert_raises(ArgumentError) { opts(driver: 'pg', rename: { 'a' => 1 }) } + end end