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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ UPDATE_GOLDEN=1 bundle exec ruby -Itest test/extended_golden_test.rb # rewrite
* **The plugin runs inside ruby.wasm**: only Ruby core + `json`; no gems, no C extensions, no filesystem, no network.
Anything `require`d by `lib/` or `exe/` must exist in the WASM (`scripts/wasm/build.sh` stages the stdlib). Gems used
only by tests/tooling go in the `Gemfile` groups, never in `lib/`.
* **Generated code must stay valid for Ruby 3.1–3.3** (`Struct` below 3.2, `Data` from 3.2) and RuboCop/Steep-clean.
* **Generated code must stay valid for Ruby 3.1–3.4** (`Struct` below 3.2, `Data` from 3.2) and RuboCop/Steep-clean.
* Generated code is plain, idiomatic Ruby (keyword args, `Data` rows, implicit returns, 2-space indent); do not mirror
the C# sibling plugin's style. Parameter *values* are never logged.
* Prepared statements are per connection (`ConnectionState`) because the query object is shared by pooled connections.
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ codec), and it is shipped as a single self-contained WebAssembly module — the
| Option | Possible values | Optional | Info |
|-----------------|------------------------------------------------|----------|-----------------------------------------------------------------------------------------------------------|
| driver | values: `mysql2`,`pg`, `sqlite3` | No | Choosing the driver to use - see [here](docs/Examples.md) for more details info. |
| rubyVersion | default: `3.3`<br/>values: `3.1`, `3.2`, `3.3` | Yes | Determines the Ruby version the generated code should support.. |
| rubyVersion | default: `3.3`<br/>values: `3.1` - `3.4` | Yes | Determines the Ruby version the generated code should support.. |
| generateGemfile | default: `false`<br/>values: `false`,`true` | Yes | Assists you with the integration of SQLC and Ruby by generating a `Gemfile` with the needed dependencies. |
| generateTypes | default: `true`<br/>values: `false`,`true` | Yes | Determines whether to generate type signatures in addition to the code. |
| 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. |

Unknown option names (e.g. a typo such as `moduleNmae`) and `preparedStatements` with a driver other than `pg` are errors.

### Override option
```yaml
overrides:
Expand Down Expand Up @@ -96,15 +98,15 @@ queries.list_authors_paged(limit: 10, offset: 0) # :many -> Array of r
queries.get_authors_by_ids(ids: [1, 2, 3]) # sqlc.slice / array parameter
queries.copy_authors([PgCodegen::CopyAuthorsArgs.new(name: 'a', bio: nil)]) # :copyfrom

queries.transaction(isolation: :serializable) do |tx| # COMMIT on return, ROLLBACK on error
queries.transaction(isolation: :serializable) do |tx| # COMMIT on return (also break / return / throw), ROLLBACK on error
id = tx.create_author_return_id(name: 'a', bio: nil)
tx.transaction { |inner| inner.create_book(name: 'b', author_id: id) } # nested -> SAVEPOINT
end
```
* **Keyword arguments:** every query parameter is a keyword argument (missing / unknown ones raise `ArgumentError`). Parameters named with `sqlc.arg` / `sqlc.narg` that are nullable default to `nil`; a plain nullable column (`INSERT ... VALUES ($1, $2)`) has to be passed explicitly. Names are snake_cased, Ruby keywords get a trailing underscore (`class_:`).
* **Keyword arguments:** every query parameter is a keyword argument (missing / unknown ones raise `ArgumentError`). Parameters named with `sqlc.arg` / `sqlc.narg` that are nullable default to `nil`; a plain nullable column (`INSERT ... VALUES ($1, $2)`) has to be passed explicitly. Names are snake_cased, Ruby keywords and the method's own locals (`client`, `sql`, ...) get a trailing underscore (`class_:`); a `sqlc.arg` used twice is one keyword, repeated `?` of the same column become `id:, id_2:, id_3:`.
* **Bang finders:** every `:one` query also gets a `get_author!` that raises `QuerySql::NotFound` instead of returning `nil` (like `find_by` vs `find`).
* **Logging:** `QuerySql.connect(params, logger: Rails.logger)` / `QuerySql.new(pool, logger: logger)` logs `QuerySql#get_author (0.4ms)` at debug level for every query and transaction (SQL parameters are never logged). Without a logger there is no overhead.
* **Rows** are immutable `Data` objects (`Struct` for `rubyVersion` below 3.2), so they work with pattern matching: `case row in { id:, name: }`.
* **Rows** are immutable `Data` objects (a `keyword_init` `Struct` for `rubyVersion` below 3.2; both are built with keywords: `Row.new(id: 1, name: 'a')`), so they work with pattern matching: `case row in { id:, name: }`. Column names that are not valid identifiers are snake_cased (`"Weird Name"` -> `weird_name`), names of the row's own methods get a trailing underscore (`hash` -> `hash_`).
* **Connections:** `QuerySql.connect(params, **pool_options)` builds a [`connection_pool`](https://github.com/mperham/connection_pool); to share a pool you already have (or to inject a fake in tests) use `QuerySql.new(pool)` - anything that responds to `#with` and yields a connection. For SQLite pass a `SQLite3::Database` to `QuerySql.new(db)`.
* **Async / fibers:** the generated code is plain synchronous Ruby and works with the Fiber scheduler (the [`async`](https://github.com/socketry/async) gem): inside `Sync { ... }` pooled queries on `pg` and `mysql2` overlap instead of blocking (see `tests/end2end_pg.rb`). SQLite calls are synchronous C calls and do not overlap.
* **SQL** is kept in frozen heredoc constants (`GET_AUTHOR_SQL`); query comments in the `.sql` file become doc comments on the method; `.rbs` signatures are generated for everything.
Expand Down Expand Up @@ -133,7 +135,7 @@ end

### Other
* **Models:** a `models.rb` / `models.rbs` with one `Data` (`Struct` below Ruby 3.2) class per table, and one module with constants per enum (`ExtendedBioType::MEMOIR`, `ExtendedBioType::ALL`). Enum columns are `String`s.
* **Transactions:** `queries.transaction { |tx| tx.create_author(...) }` runs everything in the block on one connection in a transaction (commit when the block returns, rollback when it raises) and returns the block's value. Nested calls become savepoints; `isolation:` (`pg`, `mysql2`) / `mode:` (`sqlite3`) choose the isolation level / locking mode.
* **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.
* Types follow the Ruby classes the drivers actually return (e.g. `BigDecimal` for `numeric`/`decimal`, `Date`, `Time`, `IPAddr`; `json`/`jsonb` are parsed, so `untyped`).
Expand Down
2 changes: 1 addition & 1 deletion Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ target :gen do
check "examples"
signature "examples"

# stdlib signatures for the types the generated code refers to (BigDecimal, Date, Time, IPAddr, Tempfile; Set is core since RBS 4, CSV has no useful signature)
# stdlib signatures for the types the generated code refers to (BigDecimal, Date, Time, IPAddr, Tempfile; Set is core since RBS 4)
library "bigdecimal", "date", "time", "ipaddr", "tempfile"
end
2 changes: 1 addition & 1 deletion docs/Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ process or as a WASI module. This plugin is pure Ruby and is distributed as a WA
| `exe/plugin.rb` | entry point (stdin → `Generator` → stdout) |
| `lib/sqlc_gen_ruby/proto.rb` | ~150 line protobuf wire codec. `google-protobuf` is a C extension and cannot be loaded inside ruby.wasm, so the plugin brings its own |
| `lib/sqlc_gen_ruby/plugin_pb.rb` | the subset of sqlc's `codegen.proto` the generator needs (field numbers must match upstream) |
| `lib/sqlc_gen_ruby/options.rb`, `ruby_version.rb` | the `options:` JSON, `rubyVersion` parsing / validation (3.1 – 3.3; `Struct` below 3.2, `Data` from 3.2) |
| `lib/sqlc_gen_ruby/options.rb`, `ruby_version.rb` | the `options:` JSON, `rubyVersion` parsing / validation (3.1 – 3.4; `Struct` below 3.2, `Data` from 3.2) |
| `lib/sqlc_gen_ruby/generator.rb` | module / class / dataclass (`Data.define`, `Struct.new`) layout, `.rb` / `.rbs` / `Gemfile` files, `models.rb` (tables + enums) |
| `lib/sqlc_gen_ruby/method_gen.rb` | one generated method per query: `:one`, `:many`, `:exec`, `:execrows`, `:execlastid`, `:copyfrom`, `sqlc.slice` |
| `lib/sqlc_gen_ruby/row_layout.rb` | how result columns become `<Query>Row` attributes (`sqlc.embed` expands a table into one model attribute) |
Expand Down
12 changes: 12 additions & 0 deletions examples/authors/mysql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,15 @@ SELECT * FROM extended_bios WHERE bio_type = ? LIMIT 1;

-- name: TruncateExtendedBios :exec
DELETE FROM extended_bios;

-- name: CountRegressionsByText :one
SELECT count(*) FROM regressions WHERE `order` = sqlc.arg(term) OR note = sqlc.arg(term);

-- name: ListRegressionsByIds :many
SELECT id, `order`, note FROM regressions WHERE id IN (?, ?, ?) ORDER BY id;

-- name: CopyRegressions :copyfrom
INSERT INTO regressions (`order`, note) VALUES (?, ?);

-- name: TruncateRegressions :exec
TRUNCATE TABLE regressions;
6 changes: 6 additions & 0 deletions examples/authors/mysql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ CREATE TABLE extended_bios (
author_type SET('Author', 'Editor', 'Translator'),
PRIMARY KEY (author_name, name)
);

CREATE TABLE regressions (
id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY,
`order` varchar(255) NOT NULL,
note text
);
15 changes: 15 additions & 0 deletions examples/authors/postgresql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,18 @@ SELECT * FROM postgres_extra_types LIMIT 1;

-- name: TruncateExtraTypes :exec
TRUNCATE TABLE postgres_extra_types;

-- name: CreateRegression :one
INSERT INTO regressions (id, "order", payload, doc, bio_types, uids) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *;

-- name: CreateRegressionIfNew :execlastid
INSERT INTO regressions (id, "order") VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING id;

-- name: CopyRegressions :copyfrom
INSERT INTO regressions (id, "order", payload, doc) VALUES ($1, $2, $3, $4);

-- name: ListRegressions :many
SELECT * FROM regressions WHERE (sqlc.narg(order_name)::text IS NULL OR "order" = sqlc.narg(order_name)) ORDER BY id;

-- name: TruncateRegressions :exec
TRUNCATE TABLE regressions;
9 changes: 9 additions & 0 deletions examples/authors/postgresql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,12 @@ CREATE TABLE postgres_extra_types (
c_money MONEY,
c_not_null TEXT NOT NULL DEFAULT 'x'
);

CREATE TABLE regressions (
id bigint PRIMARY KEY,
"order" text NOT NULL,
payload bytea,
doc jsonb,
bio_types extended.bio_type[],
uids uuid[]
);
18 changes: 18 additions & 0 deletions examples/authors/sqlite/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,21 @@ SELECT
sqlc.embed(books)
FROM authors INNER JOIN books ON authors.id = books.author_id
WHERE books.name = ?;

-- name: CreateRegression :exec
INSERT INTO regressions ("order", price) VALUES (?, ?) RETURNING id;

-- name: UpdateRegressionPrices :execrows
UPDATE regressions SET price = ? RETURNING id;

-- name: CopyRegressions :copyfrom
INSERT INTO regressions ("order", price) VALUES (?, ?);

-- name: ListRegressions :many
SELECT id, "order", price FROM regressions ORDER BY id;

-- name: GetMaxRegressionPrice :one
SELECT coalesce(max(price), 0) AS max_price FROM regressions;

-- name: TruncateRegressions :exec
DELETE FROM regressions;
6 changes: 6 additions & 0 deletions examples/authors/sqlite/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ CREATE TABLE books (
description TEXT,
FOREIGN KEY (author_id) REFERENCES authors (id) ON DELETE CASCADE
);

CREATE TABLE regressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
"order" VARCHAR(255) NOT NULL,
price DECIMAL(10,2)
);
3 changes: 3 additions & 0 deletions examples/mysql2/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@ class Book < Data.define(:id, :name, :author_id, :description)

class ExtendedBio < Data.define(:author_name, :name, :bio_type, :author_type)
end

class Regression < Data.define(:id, :order, :note)
end
end
7 changes: 7 additions & 0 deletions examples/mysql2/models.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,11 @@ module Mysql2Codegen
attr_reader author_type: String?
def initialize: ( String? author_name, String? name, String? bio_type, String? author_type) -> void
end

class Regression
attr_reader id: Integer
attr_reader order: String
attr_reader note: String?
def initialize: ( Integer id, String order, String? note) -> void
end
end
Loading
Loading