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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,14 @@ end
| `sqlc.arg` | ✅ | ✅ | ✅ | names the attribute of the `<Query>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).
Expand Down
22 changes: 22 additions & 0 deletions examples/authors/postgresql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 *;

Expand Down
6 changes: 6 additions & 0 deletions examples/authors/postgresql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions examples/pg/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions examples/pg/models.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions examples/pg/query_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
42 changes: 42 additions & 0 deletions examples/pg/query_sql.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions lib/sqlc_gen_ruby/catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions lib/sqlc_gen_ruby/code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
16 changes: 13 additions & 3 deletions lib/sqlc_gen_ruby/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:)
Expand Down Expand Up @@ -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")}"]
Expand Down
11 changes: 10 additions & 1 deletion lib/sqlc_gen_ruby/method_gen.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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}']"
Expand All @@ -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
Loading
Loading