Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<Driver>Codegen` (e.g. `PgCodegen`) | Yes | The Ruby module the generated code lives in, e.g. `MyApp::Db`. |
| preparedStatements | default: `true`<br/>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`<br/>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
Expand All @@ -29,11 +39,18 @@ overrides:
ruby_type:
type: "Symbol"
notNull: true
- column: "*:<column-name>" # every query
- column: "*:<column-name>" # every query and model
ruby_type:
type: "Pathname"
notNull: false
- column: "<table>.<column>" # a table column ("<schema>.<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: `<query>:<column>`, `<table>.<column>`, `*:<column>`, `db_type`.

### Configuration
```yaml
Expand Down
2 changes: 1 addition & 1 deletion docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions examples/authors/postgresql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
2 changes: 2 additions & 0 deletions examples/pg/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
# auto-generated by sqlc - do not edit

module PgCodegen
# Kinds of biographies.
module ExtendedBioType
AUTOBIOGRAPHY = 'Autobiography'
BIOGRAPHY = 'Biography'
MEMOIR = 'Memoir'
ALL = [AUTOBIOGRAPHY, BIOGRAPHY, MEMOIR].freeze
end

# People who wrote at least one book.
class Author < Data.define(:id, :name, :bio)
end

Expand Down
3 changes: 3 additions & 0 deletions examples/pg/models.rbs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# auto-generated by sqlc - do not edit
module PgCodegen
# Kinds of biographies.
module ExtendedBioType
AUTOBIOGRAPHY: String
BIOGRAPHY: String
MEMOIR: String
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
Expand Down
29 changes: 24 additions & 5 deletions lib/sqlc_gen_ruby/catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]
Expand All @@ -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
Expand All @@ -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|
Expand All @@ -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

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/sqlc_gen_ruby/code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
51 changes: 38 additions & 13 deletions lib/sqlc_gen_ruby/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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?

Expand Down Expand Up @@ -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)
Expand Down
Loading