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 @@ -29,11 +29,12 @@ WHERE id = $1;
```java
AuthorRepository authors = new AuthorRepository(executor);

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

Every query of `sql/queries.sql` becomes a method of that one
`AuthorRepository`.
`AuthorRepository`, and the `authors` row it returns is the top-level
`AuthorsRow` record generated once for the configured package.

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
Expand Down
39 changes: 31 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ 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.

The entries share one generated package, so they also share its row records: a
table whose complete row both entries return generates one `<TableName>Row`
file that both repositories return.

## Generated Java Names

The configured `sql[].name` and the SQL names inside the entry — query names,
Expand Down Expand Up @@ -265,7 +269,9 @@ The rules are applied as follows:
`Result`, so `get_author` generates `GetAuthorResult`,
- a row record is the upper camel form of the table name followed by `Row`, so
the table `authors` generates `AuthorsRow`. Row names are normalized, never
singularized,
singularized. A row record is a top-level type of `java.package`, generated
once per table into its own file and shared by every repository of the package
that returns that row,
- a method is the lower camel form of the query name, so `get_author` generates
`getAuthor`,
- a row-mapper field is the method name followed by `RowMapper`, and the mapper
Expand Down Expand Up @@ -326,13 +332,29 @@ case-insensitive filesystem:
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
```

Two tables of one entry whose row records are equal ignoring case are rejected
on the same grounds, naming both tables and both row types:
Two tables whose row records are equal ignoring case are rejected on the same
grounds, naming both tables and both row types. Because a row record belongs to
the package, the two tables may come from one entry or from two:

```text
sqlcj: Invalid query group 'User' in /home/dev/project/sql/queries.sql: Tables 'user_data' and 'userdata' generate row types that are equal ignoring case: UserDataRow and UserdataRow
```

### Row record collisions

Two entries of one package that return the complete row of one table generate
one row record, so they must define that table alike: the analyzed row columns
must have the same names, types, and nullability, in the same order. Otherwise
the run ends, naming the entry that defined the table first:

```text
sqlcj: Invalid query group 'Library' in /home/dev/project/sql/library.sql: Table 'authors' differs from its definition in query group 'Author', which generates the same row type AuthorsRow
```

A cross-entry failure is reported against the later entry in configuration
order and, like every generation failure, ends the run before any file is
written.

### Generated path collisions

Two entries whose repository files resolve to the same path, or to paths that
Expand Down Expand Up @@ -385,11 +407,12 @@ output directory or generated file it could not write and exit status `1`. The
files the run had already written stay in place.

A successful run records what it generated in `sqlcj-manifest.txt` inside
`java.out`. The manifest is a UTF-8 text file listing every generated file as a
path relative to `java.out`, with `/` between its name elements, sorted, one per
line. The next successful run deletes the files the previous manifest listed
that it did not generate itself, so a renamed or removed configuration entry
leaves no stale repository behind.
`java.out`. The manifest is a UTF-8 text file listing every generated file,
repositories and row records alike, as a path relative to `java.out`, with `/`
between its name elements, sorted, one per line. The next successful run deletes
the files the previous manifest listed that it did not generate itself, so a
renamed or removed configuration entry leaves no stale repository behind, and a
row record no entry returns any more is deleted too.

Cleanup is deliberately narrow:

Expand Down
72 changes: 51 additions & 21 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,11 @@ types differ are rejected.
Each configuration entry generates one final repository class in the configured
`java.package`, written to the package directory under `java.out` and named
`<sql[].name>Repository`. Every named query of that entry becomes one method of
that repository; sqlcj never generates a class per query.
that repository; sqlcj never generates a class per query. Beside the
repositories, the package holds one `<TableName>Row` record file for each table
whose complete row an entry returns.

- Every generated repository file begins with the fixed notice
- Every generated file begins with the fixed notice
`// Code generated by sqlcj. DO NOT EDIT.`, followed by one blank line and the
`package` declaration. The notice holds no version, timestamp, or filesystem
path.
Expand All @@ -330,10 +332,11 @@ that repository; sqlcj never generates a class per query.
of the query name, as in `get_author` and `GetAuthor` to `getAuthor`.
- A `:one`, `:optional`, or `:many` query that returns one complete table row — a
single-source `SELECT *` or `SELECT <source>.*`, or a write whose `RETURNING`
clause is exactly `*` — returns the repository's nested
`<TableName>Row` record. That record and its private `RowMapper` field are
generated once per table, after the constructor, in the order the queries
first use them, and every query returning that row shares them.
clause is exactly `*` — returns the `<TableName>Row` record. That record is a
top-level `public record` in `java.package`, generated once per table into its
own file and shared by every repository of the package that returns that row.
Each repository keeps its own private `RowMapper` field for the row, generated
after the constructor in the order its queries first use it.
- Every other `:one`, `:optional`, or `:many` query generates a nested `public record` named
`<QueryName>Result` in upper camel case, whose components follow the
selected-column order and are named after each column's projection alias or
Expand All @@ -345,14 +348,39 @@ that repository; sqlcj never generates a class per query.
- Every generated method passes the repository class name and the query name to
the executor, as the first two arguments of its call, so a runtime failure
names the query the application called.
- Generated source imports only `dev.sqlcj.runtime.QueryExecutor`,
- Generated repository source imports only `dev.sqlcj.runtime.QueryExecutor`,
`dev.sqlcj.runtime.RowMapper`, `java.util.List`, `java.util.Optional` when the
entry declares an `:optional` query, and the JDK types of the mapped columns,
so the runtime artifact is the only sqlcj dependency a consumer needs.
so the runtime artifact is the only sqlcj dependency a consumer needs. A row
record imports the JDK types of its components alone and depends on no sqlcj
type.

The `Author` entry of the [Quickstart](quickstart.md), which declares
`CreateAuthor`, `GetAuthor`, `FindAuthor`, `ListAuthors`, `UpdateAuthorBio`, and
`DeleteAuthor`, generates one `AuthorRepository`:
`DeleteAuthor`, generates the row record of the `authors` table:

```java
// Code generated by sqlcj. DO NOT EDIT.

package com.example.app.db;

import java.time.LocalDateTime;

/**
* Generated by sqlcj.
*
* Table: authors
*/
public record AuthorsRow(
Long id,
String name,
String bio,
LocalDateTime createdAt
) {
}
```

and one `AuthorRepository` that uses it:

```java
// Code generated by sqlcj. DO NOT EDIT.
Expand All @@ -378,14 +406,6 @@ public final class AuthorRepository {
this.executor = executor;
}

public record AuthorsRow(
Long id,
String name,
String bio,
LocalDateTime createdAt
) {
}

private static final RowMapper<AuthorsRow> authorsRowMapper =
resultSet -> new AuthorsRow(
resultSet.getObject(1, Long.class),
Expand Down Expand Up @@ -439,23 +459,26 @@ public final class AuthorRepository {
`CreateAuthor`, `GetAuthor`, `FindAuthor`, and `ListAuthors` each return one
complete `authors` row, so all four use the one `AuthorsRow` record and the one
`authorsRowMapper` field. `getAuthor` returns `AuthorsRow`, `findAuthor` returns
`Optional<AuthorsRow>`, and `listAuthors` returns `List<AuthorsRow>`.
`Optional<AuthorsRow>`, and `listAuthors` returns `List<AuthorsRow>`. A second
entry of the same package that returns the full `authors` row returns that same
`AuthorsRow` class.

The application constructs the repository once per execution context:

```java
AuthorRepository authors = new AuthorRepository(new JdbcQueryExecutor(dataSource));

AuthorRepository.AuthorsRow author = authors.getAuthor(1L);
AuthorsRow author = authors.getAuthor(1L);

Optional<AuthorRepository.AuthorsRow> found = authors.findAuthor(2L);
Optional<AuthorsRow> found = authors.findAuthor(2L);
```

A query name, projection alias, column name, and parameter name becomes a
conventional Java name by one deterministic camel-case rule set, and names that
would collide inside one generated repository are disambiguated in their SQL
order. Those rules, the rejection of two queries of one entry that generate the
same method, and the rejection of two entries whose repository files would
same method, the rejection of two entries that define one row record
differently, and the rejection of two entries whose repository files would
collide, are documented in
[Generated Java Names](configuration.md#generated-java-names).

Expand Down Expand Up @@ -714,6 +737,13 @@ Generated Java:
`JavaCodeGeneratorTest.shouldDisambiguateRowMapperFromQueryRowMapper`, and
`JavaCodeGeneratorTest.shouldRejectRowTypesThatAreEqualIgnoringCase` cover the
shared row records, their mapper fields, and their collision.
- `SqlcjCompilerIntegrationTest.shouldGenerateOneSharedRowRecordForTwoEntries`,
`SqlcjCompilerIntegrationTest.shouldDeleteTheStaleRowRecordOfARemovedFullRowQuery`,
`SqlcjCompilerIntegrationTest.shouldReportTwoEntriesThatDefineOneRowDifferently`,
and
`SqlcjCompilerIntegrationTest.shouldReportRowRecordsOfTwoEntriesThatAreEqualIgnoringCase`
cover the one top-level row record two entries share, its manifest entry and
stale deletion, and the two cross-entry diagnostics.
- `PostgresIntegrationTest.shouldExecuteGeneratedFullRowQueriesIntoOneSharedRowTypeAgainstPostgres`
executes a returning write, a full-row read, and a qualified full-row list of
one table into one row type against PostgreSQL 16.
40 changes: 22 additions & 18 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,14 +272,15 @@ All eleven queries become methods of the one generated `AuthorRepository`:
`deleteAuthor`, `searchAuthors`, `countAuthors`, `listAuthorPage`, `createBook`,
and `listAuthorBooks`. `CreateAuthor`, `GetAuthor`, `FindAuthor`, `ListAuthors`,
`SearchAuthors`, and `ListAuthorPage` each return one complete `authors` row, so
all six share the nested record `AuthorRepository.AuthorsRow`, generated once
from the schema's column order. A query with its own result shape, such as a
partial projection or a `RETURNING` column list, generates a nested
`AuthorRepository.<QueryName>Result` record instead: `CountAuthors` generates
`CountAuthorsResult` with the single non-null `Long` component `total`, and
`ListAuthorBooks` generates `ListAuthorBooksResult` with the components `name`
and `title`, where `title` is `null` for an author that the left-joined `books`
table does not match.
all six share the top-level record `AuthorsRow`, generated once for the package
`com.example.app.db` from the schema's column order. Another entry of the same
package that returns the full `authors` row would return that same record. A
query with its own result shape, such as a partial projection or a `RETURNING`
column list, generates a nested `AuthorRepository.<QueryName>Result` record
instead: `CountAuthors` generates `CountAuthorsResult` with the single non-null
`Long` component `total`, and `ListAuthorBooks` generates
`ListAuthorBooksResult` with the components `name` and `title`, where `title` is
`null` for an author that the left-joined `books` table does not match.

`GetAuthor` and `FindAuthor` read the same row by the same key and differ only
in cardinality: `getAuthor` returns `AuthorsRow` and requires exactly one row,
Expand All @@ -292,6 +293,7 @@ query contract is documented in [Queries](queries.md).
package com.example.app;

import com.example.app.db.AuthorRepository;
import com.example.app.db.AuthorsRow;
import dev.sqlcj.runtime.JdbcQueryExecutor;
import dev.sqlcj.runtime.QueryCardinalityException;
import dev.sqlcj.runtime.QueryExecutor;
Expand All @@ -311,23 +313,23 @@ public final class App {

AuthorRepository authors = new AuthorRepository(executor);

AuthorRepository.AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer");
AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer");

System.out.println("created: " + created.id() + " " + created.name());

AuthorRepository.AuthorsRow read = authors.getAuthor(created.id());
AuthorsRow read = authors.getAuthor(created.id());

System.out.println("read: " + read.name() + " / " + read.bio());

int updatedRows = authors.updateAuthorBio(created.id(), "Mathematician");

System.out.println("updated rows: " + updatedRows);

for (AuthorRepository.AuthorsRow author : authors.listAuthors()) {
for (AuthorsRow author : authors.listAuthors()) {
System.out.println("listed: " + author.id() + " " + author.name());
}

Optional<AuthorRepository.AuthorsRow> missing = authors.findAuthor(-1L);
Optional<AuthorsRow> missing = authors.findAuthor(-1L);

System.out.println("missing row: " + missing.isPresent());

Expand All @@ -344,7 +346,7 @@ public final class App {

AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection));

AuthorRepository.AuthorsRow committed = transactionalAuthors.createAuthor("Grace Hopper", null);
AuthorsRow committed = transactionalAuthors.createAuthor("Grace Hopper", null);

transactionalAuthors.updateAuthorBio(committed.id(), "Compiler pioneer");

Expand All @@ -360,7 +362,7 @@ public final class App {

AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection));

AuthorRepository.AuthorsRow discarded = transactionalAuthors.createAuthor("Temporary Author", null);
AuthorsRow discarded = transactionalAuthors.createAuthor("Temporary Author", null);

transactionalAuthors.updateAuthorBio(discarded.id(), "never stored");

Expand All @@ -369,13 +371,13 @@ public final class App {
System.out.println("rolled back: " + authors.findAuthor(discarded.id()).isPresent());
}

for (AuthorRepository.AuthorsRow author : authors.searchAuthors("%lovelace%")) {
for (AuthorsRow author : authors.searchAuthors("%lovelace%")) {
System.out.println("searched: " + author.id() + " " + author.name());
}

System.out.println("count: " + authors.countAuthors().total());

for (AuthorRepository.AuthorsRow author : authors.listAuthorPage(1, 1)) {
for (AuthorsRow author : authors.listAuthorPage(1, 1)) {
System.out.println("page: " + author.id() + " " + author.name());
}

Expand Down Expand Up @@ -455,11 +457,13 @@ mvn exec:java
- `mvn compile` then compiles `src/main/java` together with
`target/generated-sources/sqlcj`.

Generation writes one repository per configured entry, plus the manifest that
records what it wrote:
Generation writes one repository per configured entry, one row record per table
whose complete row an entry returns, plus the manifest that records what it
wrote:

```text
target/generated-sources/sqlcj/com/example/app/db/AuthorRepository.java
target/generated-sources/sqlcj/com/example/app/db/AuthorsRow.java
target/generated-sources/sqlcj/sqlcj-manifest.txt
```

Expand Down
Loading
Loading