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
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ sqlcj is a SQL compiler and type-safe Java code generator for PostgreSQL,
inspired by [sqlc](https://github.com/sqlc-dev/sqlc).

You write a PostgreSQL schema snapshot and named SQL queries. sqlcj analyzes
them against the schema and generates readable Java classes with typed
parameters and typed result records, which execute through a small JDBC runtime:
them against the schema and generates one readable Java repository per named
query group, with typed parameters and typed result records, which executes
through a small JDBC runtime:

```text
schema + named SQL -> sqlcj generate -> generated Java -> JDBC
```

```yaml
sql:
- name: Author
schema: sql/schema.sql
queries: sql/queries.sql
```

```sql
-- name: GetAuthor :one
SELECT id, name, bio
Expand All @@ -19,9 +27,14 @@ WHERE id = $1;
```

```java
GetAuthor.GetAuthorResult author = new GetAuthor(executor).getAuthor(1L);
AuthorRepository authors = new AuthorRepository(executor);

AuthorRepository.GetAuthorResult author = authors.getAuthor(1L);
```

Every query of `sql/queries.sql` becomes a method of that one
`AuthorRepository`.

The SQL stays visible and owned by the application. sqlcj is not an ORM, a
migration tool, or a query builder: it does not run migrations, inspect a live
database, or build queries at runtime.
Expand Down Expand Up @@ -75,8 +88,8 @@ operations, including an application-controlled commit and rollback.
empty directory.
- [Queries](docs/queries.md) — query annotations, the supported SQL shapes,
parameter and binding order, the generated API, and what is not supported.
- [Configuration](docs/configuration.md) — the `sqlcj.yaml` format, path
resolution, and generated Java naming.
- [Configuration](docs/configuration.md) — the `sqlcj.yaml` format, query-group
names, path resolution, and generated Java naming.
- [PostgreSQL Support](docs/postgresql.md) — the engine contract, accepted
column types and `CREATE TABLE` constructs, null handling, and connection
ownership.
136 changes: 92 additions & 44 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ blocking JDBC execution; there is no engine or dialect option. See
```yaml
version: "1"
sql:
- schema: schema.sql
- name: Author
schema: schema.sql
queries: queries.sql
java:
package: dev.example.generated
Expand All @@ -41,6 +42,7 @@ java:
| --- | --- | --- |
| `version` | string | Configuration contract version. Must be `"1"`. |
| `sql` | list | Non-empty, ordered list of source entries. |
| `sql[].name` | string | Identity of the query group. Names the generated repository. |
| `sql[].schema` | string | Path to a file containing `CREATE TABLE` statements. |
| `sql[].queries` | string | Path to a file containing named queries. |
| `java` | mapping | Java generation settings. |
Expand All @@ -49,29 +51,48 @@ java:

### `sql` entries

Each entry pairs one schema source with one named-query source. Entries are
loaded in declared order, each entry's queries are analyzed against that entry's
own schema, and query order inside a query file is preserved.
Each entry pairs one group name with one schema source and one named-query
source. Entries are loaded in declared order, each entry's queries are analyzed
against that entry's own schema, and query order inside a query file is
preserved.

Schemas are not shared or merged between entries. A query can only use tables
declared in the schema file of its own entry.

Query names must be unique across all configured query sources, because each
query name becomes a generated Java class name. See
[Generated Java Names](#generated-java-names) for how a query name becomes a
class name and when two query names collide.
One entry generates exactly one repository containing every query of its query
source, in declared query order. Query names are therefore scoped to their
entry: two entries may use the same query name, while two queries of one entry
that generate the same method name are rejected. See
[Generated Java Names](#generated-java-names) for the naming rules and the
collisions that end a run.

### `sql[].name`

`sql[].name` is required and is used unchanged as the prefix of the generated
repository type name, so the entry `name: Author` generates `AuthorRepository`.

It must be a single valid, non-blank Java identifier. A Java keyword, the
literals `true`, `false`, and `null`, the identifier `_`, and a restricted
identifier such as `var` or `record` are rejected:

```text
sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'sql[0].name' value 'record' is not a valid Java identifier for a generated repository name
```

sqlcj does not derive the name from a table or a file name. A query file may
join or write several tables, so the group boundary is declared, not guessed.

### `java.package`

`java.package` must be a dot-separated sequence of valid, non-keyword Java
identifiers, for example `dev.example.generated`. It is used both for the
generated `package` declaration and for the generated file layout.

A query named `GetUser` with `java.package: dev.example.generated` and
An entry named `Author` with `java.package: dev.example.generated` and
`java.out: generated` is written to:

```text
generated/dev/example/generated/GetUser.java
generated/dev/example/generated/AuthorRepository.java
```

### Value rules
Expand All @@ -80,8 +101,8 @@ generated/dev/example/generated/GetUser.java
- Unknown fields are rejected.
- Wrong-typed fields are rejected, including an unquoted numeric `version`.
- A missing or unsupported `version`, a missing section, an empty `sql` list, a
null `sql` entry, a blank value, and an invalid `java.package` are all invalid
configuration.
null `sql` entry, a blank value, an invalid `sql[].name`, and an invalid
`java.package` are all invalid configuration.

## Path Resolution

Expand All @@ -99,77 +120,103 @@ canonical location.
```yaml
version: "1"
sql:
- schema: sql/users/schema.sql
- name: User
schema: sql/users/schema.sql
queries: sql/users/queries.sql
- schema: sql/orders/schema.sql
- name: Order
schema: sql/orders/schema.sql
queries: sql/orders/queries.sql
java:
package: dev.example.generated
out: target/generated-sources/sqlcj
```

With the configuration above and queries `GetUser` and `ListOrders`, sqlcj
generates:
With the configuration above, sqlcj generates one repository per entry:

```text
target/generated-sources/sqlcj/dev/example/generated/GetUser.java
target/generated-sources/sqlcj/dev/example/generated/ListOrders.java
target/generated-sources/sqlcj/dev/example/generated/UserRepository.java
target/generated-sources/sqlcj/dev/example/generated/OrderRepository.java
```

Both repositories may contain a query named `GetById`, because each name is
resolved inside its own repository.

## Generated Java Names

Query names and SQL column names become Java identifiers. SQL identifier
delimiters are removed before a name is analyzed, so the quoted column
`"user id"` has the JDBC label `user id`, while executable SQL keeps the query
exactly as written.
The configured `sql[].name` and the SQL names inside the entry become Java
identifiers. SQL identifier delimiters are removed before a name is analyzed, so
the quoted column `"user id"` has the JDBC label `user id`, while executable SQL
keeps the query exactly as written.

The repository name is the configured `sql[].name` followed by `Repository`,
without normalization, because configuration already requires a valid Java
identifier.

A name that is already a valid, non-reserved Java identifier keeps its spelling:
A query or column name that is already a valid, non-reserved Java identifier
keeps its spelling:

- a query named `GetUser` generates the class `GetUser` and the method `getUser`,
- a query named `getUser` generates the class `getUser` and the method `getUser`,
- a query named `GetUser` generates the result record `GetUserResult` and the
method `getUser`,
- a column named `created_at` generates the record component `created_at`.

Any other name is normalized deterministically:

- each maximal run of characters that cannot appear in a Java identifier becomes
a single `_`, so `Get-User` and `Get*/User` both generate the class `Get_User`,
a single `_`, so `Get-User` and `Get*/User` both generate the result record
`Get_UserResult`,
- a leading `_` is added when the first character cannot start an identifier, so
`1stQuery` generates the class `_1stQuery`,
`1stQuery` generates `_1stQueryResult`,
- a trailing `_` is added to a Java keyword, to `true`, `false`, `null`, and to
`_`, so a column named `class` generates the component `class_`.

Generated names also avoid names that the generated source already uses:

- a class name never repeats an imported or generated type name such as `List`,
`String`, `RowMapper`, or `QueryExecutor`, and never uses a restricted type
identifier such as `record` or `var`; such a name gets a trailing `_`, so a
query named `List` generates the class `List_`,
- the generated method name keeps the lower-initial rule and avoids Java
keywords and inherited `Object` method names, so a query named `Class`
generates the class `Class` with the method `class_`,
- record components avoid inherited `Object` method names, and method parameters
avoid the generator-owned names `executor` and `ROW_MAPPER`.
generates the record `ClassResult` and the method `class_`,
- record components avoid inherited `Object` method names,
- method parameters avoid the generator-owned name `executor` and the row-mapper
field names of the repository, which are the method name followed by
`RowMapper`.

Method parameters and record components are disambiguated inside their own
generated class, in logical parameter order and selected-column order, using the
suffixes `1`, `2`, and so on. Two parameters resolved from the column `id`
become `id1` and `id2`, and the columns `user id` and `user-id` become the
components `user_id1` and `user_id2`.
generated method or record, in logical parameter order and selected-column
order, using the suffixes `1`, `2`, and so on. Two parameters resolved from the
column `id` become `id1` and `id2`, and the columns `user id` and `user-id`
become the components `user_id1` and `user_id2`.

The generated row mapper reads each result column by its one-based position in
the selected-column list, so renaming a component never changes which column it
reads, and identically named columns selected from different query sources stay
distinct.

### Repository method collisions

Two queries of one entry that generate the same method name are rejected instead
of being renamed, because a repository method is a name the application calls.
The diagnostic names both queries:

```text
sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'Get.User' and 'Get-User' generate the same repository method 'get_User'
```

Two queries of one entry whose nested result types differ only by case are
rejected for the same reason, because those class files are one path on a
case-insensitive filesystem:

```text
sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Queries 'GetUser' and 'getuser' generate result types that differ only by case: GetUserResult and getuserResult
```

### Generated path collisions

Two queries whose generated class names resolve to the same file path, or to
paths that differ only by case and are therefore not portable, are rejected
before any file of the run is written, so existing output is not overwritten:
Two entries whose repository files resolve to the same path, or to paths that
differ only by case and are therefore not portable, are rejected before any file
of the run is written, so existing output is not overwritten:

```text
sqlcj: Duplicate generated file for queries 'Get.User' and 'Get-User': generated/Get_User.java
sqlcj: Generated file paths for queries 'GetUser' and 'getuser' differ only by case: generated/GetUser.java and generated/getuser.java
sqlcj: Duplicate generated file for repositories 'User' and 'User': generated/UserRepository.java
sqlcj: Generated file paths for repositories 'User' and 'user' differ only by case: generated/UserRepository.java and generated/userRepository.java
```

## Diagnostics
Expand Down Expand Up @@ -198,6 +245,7 @@ from a previous run. sqlcj does not remove or roll back files it has already
written.

sqlcj also never deletes a generated file that the current run did not produce,
so a renamed or deleted query leaves its previous class behind. Generate into a
so a renamed or removed configuration entry leaves its previous repository
behind. Generate into a
build-owned directory and let the build's clean step remove stale output, as the
[Quickstart](quickstart.md) does.
11 changes: 6 additions & 5 deletions docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ supplies the PostgreSQL driver.
`JdbcQueryExecutor` has two construction paths. Both share the same positional
parameter binding, row mapping, single-row and multi-row result handling,
affected-row counting, and exception translation, and both accept the same
generated query classes without regeneration:
generated repositories without regeneration:

| Construction | Connection ownership |
| --- | --- |
Expand All @@ -32,8 +32,9 @@ generated query classes without regeneration:

The caller-owned connection path is how several generated operations take part
in one application-controlled transaction: the application disables auto-commit,
runs generated reads and writes through one executor, and then calls `commit` or
`rollback` itself. sqlcj provides no transaction callback or template API, no
constructs another repository instance over an executor bound to that
connection, runs generated reads and writes through it, and then calls `commit`
or `rollback` itself. sqlcj provides no transaction callback or template API, no
savepoints, and no isolation configuration. The [Quickstart](quickstart.md)
runs that pattern end to end.

Expand All @@ -52,7 +53,7 @@ connection inherits that connection's confinement to a single thread at a time.

Behavior is verified against PostgreSQL 16. The pipeline is executed end to end
against a `postgres:16-alpine` container: the schema snapshot is run as
PostgreSQL DDL, the generated Java is compiled, and the generated classes are
PostgreSQL DDL, the generated Java is compiled, and the generated repository is
executed through the JDBC runtime. Those tests are skipped when Docker is
unavailable.

Expand Down Expand Up @@ -96,7 +97,7 @@ accepted and map exactly like their unparameterized spellings.

Any spelling that is not listed above is rejected.

The generated class imports `java.time.LocalDate`, `java.time.LocalDateTime`,
The generated repository imports `java.time.LocalDate`, `java.time.LocalDateTime`,
`java.time.OffsetDateTime`, `java.math.BigDecimal`, and `java.util.UUID` as
needed; the remaining types need no import. Each result column is read with
`resultSet.getObject(position, JavaType.class)` at its one-based position in the
Expand Down
Loading
Loading