From 0d04f9e9d023d7c723acdefb1b7c2585fc30ee04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Wed, 23 Sep 2026 01:48:04 +0300 Subject: [PATCH] docs: publish adoption and support guides --- README.md | 82 ++++++++ docs/configuration.md | 203 ++++++++++++++++++++ docs/postgresql.md | 17 +- docs/queries.md | 424 ++++++++++++++++++++++++++++++++++++++++++ docs/quickstart.md | 411 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 README.md create mode 100644 docs/configuration.md create mode 100644 docs/queries.md create mode 100644 docs/quickstart.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..2489344 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# sqlcj + +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: + +```text +schema + named SQL -> sqlcj generate -> generated Java -> JDBC +``` + +```sql +-- name: GetAuthor :one +SELECT id, name, bio +FROM authors +WHERE id = $1; +``` + +```java +GetAuthor.GetAuthorResult author = new GetAuthor(executor).getAuthor(1L); +``` + +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. + +## Project status + +sqlcj is pre-release. The current version is `0.1.0-SNAPSHOT`, and no artifact +has been published to Maven Central or any other public repository yet, so the +CLI and runtime must be built from this repository. There is no released license +or release procedure yet either. + +## Requirements + +- Java 21 for the CLI and for applications that use the generated code. +- Maven, to build sqlcj and to build a consuming project. +- PostgreSQL, reached through the application's own PostgreSQL JDBC driver. + sqlcj does not ship a driver. Behavior is verified against PostgreSQL 16. + +## Artifacts + +`mvn -DskipTests install` produces both artifacts from this repository: + +| Artifact | What it is | +| --- | --- | +| `sqlcj-cli/target/sqlcj-cli-0.1.0-SNAPSHOT.jar` | The executable command line tool. It bundles its own dependencies and starts `dev.sqlcj.Main`. | +| `dev.sqlcj:sqlcj-runtime:0.1.0-SNAPSHOT` | The dependency of generated code. It has no dependencies of its own, so compiler, YAML, and SQL-parser libraries never reach an application. | + +The CLI and the runtime must always be used at the same version. + +## Commands + +```bash +java -jar sqlcj-cli-0.1.0-SNAPSHOT.jar version # print the tool version +java -jar sqlcj-cli-0.1.0-SNAPSHOT.jar generate # compile sqlcj.yaml into Java +java -jar sqlcj-cli-0.1.0-SNAPSHOT.jar --help # list the commands +``` + +`generate` reads `sqlcj.yaml` from the directory it is run in. It exits with +status `0` on success, and with status `1` after printing one diagnostic for +invalid configuration, an unreadable source, or a query it cannot compile. + +## Getting started + +Follow the [Quickstart](docs/quickstart.md) to build a Maven application that +generates, compiles, and runs PostgreSQL create, read, list, update, and delete +operations, including an application-controlled commit and rollback. + +## Documentation + +- [Quickstart](docs/quickstart.md) — an end-to-end PostgreSQL project from an + 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. +- [PostgreSQL Support](docs/postgresql.md) — the engine contract, accepted + column types and `CREATE TABLE` constructs, null handling, and connection + ownership. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0ea9c95 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,203 @@ +# Configuration + +sqlcj is configured by a single YAML file named `sqlcj.yaml`. + +For a project that uses this file end to end, see the +[Quickstart](quickstart.md). For the query annotations, supported SQL, and +generated API, see [Queries](queries.md). + +The `generate` command reads `sqlcj.yaml` from the directory it is run in: + +```bash +sqlcj generate +``` + +There is no configuration-path option and no configuration discovery in parent +directories. + +## Supported Version + +Only configuration version `"1"` is supported. Every field below is required; +none of them has a default value. + +Version `"1"` commits sqlcj to PostgreSQL schema and query input and to +blocking JDBC execution; there is no engine or dialect option. See +[PostgreSQL Support](postgresql.md) for the accepted column types, the accepted +`CREATE TABLE` constructs, and the null contract. + +```yaml +version: "1" +sql: + - schema: schema.sql + queries: queries.sql +java: + package: dev.example.generated + out: generated +``` + +## Fields + +| Field | Type | Description | +| --- | --- | --- | +| `version` | string | Configuration contract version. Must be `"1"`. | +| `sql` | list | Non-empty, ordered list of source entries. | +| `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. | +| `java.package` | string | Package of the generated Java classes. | +| `java.out` | string | Directory that receives the generated package directories. | + +### `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. + +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. + +### `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 +`java.out: generated` is written to: + +```text +generated/dev/example/generated/GetUser.java +``` + +### Value rules + +- String values must not be blank. +- 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. + +## Path Resolution + +A relative `schema`, `queries`, or `java.out` path is resolved against the +directory that contains `sqlcj.yaml`, not against the process working directory +at a later point in time. An absolute path is used as-is. + +Resolved paths are normalized lexically, so `../sql/schema.sql` is supported. +Resolution does not require `java.out` to exist, does not expand `~`, +environment variables, or globs, and does not follow symbolic links to a +canonical location. + +## Multiple Entries + +```yaml +version: "1" +sql: + - schema: sql/users/schema.sql + queries: sql/users/queries.sql + - 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: + +```text +target/generated-sources/sqlcj/dev/example/generated/GetUser.java +target/generated-sources/sqlcj/dev/example/generated/ListOrders.java +``` + +## 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. + +A 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 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 leading `_` is added when the first character cannot start an identifier, so + `1stQuery` generates the class `_1stQuery`, +- 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`. + +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`. + +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. + +### 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: + +```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 +``` + +## Diagnostics + +Invalid configuration and unreadable SQL sources make `sqlcj generate` print a +single message on standard error and exit with a non-zero status. The message +names the configuration file and, when available, the offending field, value, or +source path: + +```text +sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'java.package' value 'dev.class.generated' is not a valid Java package name +sqlcj: Cannot read queries source: /home/dev/project/sql/missing.sql +``` + +## Generated Output and Failures + +Every configured entry is loaded, analyzed, and generated before the run writes +its first file. A configuration, source, schema, query, or generated-path +failure therefore ends the run without writing any file, and output from an +earlier successful run is left unchanged. + +Writing itself is not transactional. The files of a successful compilation are +written one after another, each created or truncated in place, so a filesystem +failure part-way through can leave a mixture of newly written files and files +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 +build-owned directory and let the build's clean step remove stale output, as the +[Quickstart](quickstart.md) does. diff --git a/docs/postgresql.md b/docs/postgresql.md index 1928f83..1e82dc1 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -9,7 +9,9 @@ use. Configuration version `"1"` means PostgreSQL schema and query input and blocking JDBC execution. There is no engine option and no dialect option in -`sqlcj.yaml`; the full file format is documented separately. +`sqlcj.yaml`; the full file format is documented in +[Configuration](configuration.md), and the accepted query shapes in +[Queries](queries.md). The application owns the database connection: sqlcj's runtime `dev.sqlcj.runtime.JdbcQueryExecutor` executes each generated query as a JDBC @@ -32,7 +34,8 @@ 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 -savepoints, and no isolation configuration. +savepoints, and no isolation configuration. The [Quickstart](quickstart.md) +runs that pattern end to end. In both paths the `PreparedStatement` and any `ResultSet` opened for an operation are closed before that operation returns, on success and on failure. @@ -180,10 +183,12 @@ accepted: ### Failure Behavior An unsupported type or an unsupported statement stops compilation. `sqlcj -generate` prints a single message on standard error, exits with a non-zero -status, and writes no generated file for that run, because every configured -source is compiled before any file is written. Output written by an earlier -successful run is left unchanged. +generate` prints a single message on standard error and exits with a non-zero +status. Because every configured source is analyzed and generated before the +run writes its first file, such a failure writes no generated file, and output +written by an earlier successful run is left unchanged. The writing step itself +is sequential rather than atomic; see +[Generated Output and Failures](configuration.md#generated-output-and-failures). The message names the schema source and the offending type or statement: diff --git a/docs/queries.md b/docs/queries.md new file mode 100644 index 0000000..3e2fe6e --- /dev/null +++ b/docs/queries.md @@ -0,0 +1,424 @@ +# Queries + +This document defines the query contract of sqlcj: the named-query file format, +the accepted SQL shapes, how parameters are ordered and bound, and the Java that +each query generates. + +Related documents: + +- [Configuration](configuration.md) owns `sqlcj.yaml`, path resolution, and the + rules that turn a SQL name into a Java name. +- [PostgreSQL Support](postgresql.md) owns the schema snapshot, the column + types, the null contract, and the runtime connection contract. +- [Quickstart](quickstart.md) runs the whole path end to end. + +## Query Sources + +A `sql[].queries` file is a plain SQL file in which every statement is preceded +by a sqlcj header: + +```sql +-- name: GetAuthor :one +SELECT id, name, bio +FROM authors +WHERE id = $1; +``` + +- A header line starts with `-- name:` and contains exactly the query name and + the query annotation, separated by whitespace. +- A query owns every following line until the next header or the end of the + file. The statement's trailing `;` is part of the query. +- Query order inside a file is preserved. +- A query name must be unique across every configured query source, because it + becomes a generated Java class name. +- An unparsable header, an unknown annotation, a duplicate name, and a header + without SQL are all rejected. + +## Annotations and Cardinality + +sqlcj accepts three annotations. Any other annotation is rejected. + +| Annotation | Accepted statements | Generated return type | Result when no row matches | +| --- | --- | --- | --- | +| `:one` | `SELECT`, or a write with `RETURNING` | the generated result record | `null` | +| `:many` | `SELECT`, or a write with `RETURNING` | `List<`result record`>` | an empty list | +| `:exec` | `INSERT`, `UPDATE`, `DELETE` without `RETURNING` | `int` affected-row count | `0` | + +The annotation and the statement must agree: + +- a `SELECT` must be `:one` or `:many`, +- a write without `RETURNING` must be `:exec`, +- a write with `RETURNING` must be `:one` or `:many`. + +`:one` reads the first row of the result set and does not read any further row, +so a query that matches several rows returns the first one rather than failing. +`:many` returns every row in the order the database produced it. + +## Reads + +A `SELECT` query is analyzed against the schema snapshot of its own +configuration entry. + +### Sources + +- The `FROM` item must be a table of that schema, optionally with an alias. +- A table may be joined with `JOIN` or `INNER JOIN`. A comma-separated source + list and every other join modifier — `LEFT`, `RIGHT`, `FULL`, `OUTER`, + `CROSS`, `NATURAL`, `SEMI`, `APPLY`, `STRAIGHT`, `GLOBAL`, a join hint, and + `USING (...)` — are rejected. +- Each join requires exactly one `ON` equality between a qualified column of the + joined source and a qualified column of a source introduced earlier. +- An alias replaces the table name as the exposed source name, so a qualified + reference to an aliased table must use the alias and not the table name. +- Two sources may not expose the same name. + +### Projections + +- A direct column of a source is selected by its own name or qualified with the + exposed source name. +- `*` expands across the sources in their declared order, each source in its + schema column order. +- `qualifier.*` expands one source in its schema column order. +- Selected-column order is the order of the generated result record and of the + positional row mapper. +- An unqualified column must be found in exactly one source; an ambiguous or + unknown column is rejected. +- A result expression that is not a direct column or a wildcard, such as a + function call, an arithmetic expression, or a literal, is rejected. +- Result aliases are not supported. `SELECT id AS author_id` is accepted, but + the alias is ignored: the result component is still named after the column. + +### Predicates + +A `WHERE` clause may combine: + +- the comparisons `=`, `<>`, `>`, `>=`, `<`, `<=` between a direct column and a + `$N` placeholder, in either order, +- `AND`, `OR`, and parentheses, +- `IN` with a fixed list of `$N` placeholders, such as `id IN ($1, $2)`. + +A comparison that binds no placeholder, such as `active = TRUE`, contributes no +generated parameter and reaches the database as written. + +### Ordering + +`ORDER BY` over direct columns is supported for a stable list order, as in +`ORDER BY id`. sqlcj rewrites only `$N` parameter tokens; the rest of the +statement, including the ordering clause, reaches JDBC exactly as written. A +placeholder in `ORDER BY` is rejected, because it is not an analyzed parameter +location. + +## Writes + +A write targets exactly one table of its entry's schema. + +| Statement | Accepted shape | +| --- | --- | +| `INSERT` | an explicit column list and a single `VALUES` row whose values are all `$N` placeholders | +| `UPDATE` | `SET` assignments that each assign one direct column a single `$N` placeholder, with an optional `WHERE` using the read predicate forms | +| `DELETE` | one target table with an optional `WHERE` using the read predicate forms | + +```sql +-- name: CreateAuthor :one +INSERT INTO authors (name, bio) +VALUES ($1, $2) +RETURNING id, name, bio; + +-- name: UpdateAuthorBio :exec +UPDATE authors +SET bio = $2 +WHERE id = $1; + +-- name: DeleteAuthor :exec +DELETE +FROM authors +WHERE id = $1; +``` + +### `RETURNING` + +A supported `INSERT`, `UPDATE`, or `DELETE` declared `:one` or `:many` may end +with a `RETURNING` clause that lists either: + +- unaliased direct columns of the target table, in the order they are declared, + or +- a bare `*`, which expands in the target table's schema column order. + +A returning write produces the same generated result record, positional row +mapper, and cardinality behavior as a read, so a database-generated `SERIAL` or +`BIGSERIAL` value is read back with its declared type. + +An aliased or computed `RETURNING` item, a qualified `table.*`, an unknown +column, and `RETURNING` on `:exec` are rejected. `ON CONFLICT`, multi-row +`VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, and common +table expressions are rejected in a returning write. + +## Parameters + +sqlcj uses PostgreSQL `$N` placeholders. The compiler replaces each real +placeholder token with a JDBC `?` and leaves every other character of the +statement byte-for-byte unchanged, so `$1` inside a string literal, a quoted +identifier, or a comment is not a parameter. + +- Placeholder indexes must be positive and contiguous from `$1`. +- The Java type of a placeholder is the type of the column it is compared with, + assigned to, or inserted into. +- Anonymous `?` placeholders and named `:name` placeholders are rejected. +- A placeholder in a location sqlcj does not analyze — for example `LIMIT $1` — + is rejected rather than left unbound. + +### Logical order versus textual order + +The two orders are distinct and both are observable: + +- **Logical order** is placeholder index order. It is the order of the generated + method parameters: `$1` is the first method parameter, `$2` the second. +- **Textual order** is the order in which placeholder tokens appear in the SQL. + It is the JDBC binding order of the generated `?` positions. + +`UpdateAuthorBio` above reads `$1` in its `WHERE` clause but writes `$2` first +in its `SET` clause, so the generated method takes `(id, bio)` and binds +`(bio, id)`: + +```java +public int updateAuthorBio(Long id, String bio) { + return executor.execute( + """ + UPDATE authors + SET bio = ? + WHERE id = ?; + """, + java.util.Arrays.asList(bio, id) + ); +} +``` + +An index may repeat. A repeated index produces one method parameter, named and +typed from its first occurrence, and its value is bound at every textual +position where the index occurs. Occurrences of one index whose inferred Java +types differ are rejected. + +## Generated Java + +Each query generates one final class in the configured `java.package`, written +to the package directory under `java.out` and named after the query. + +- The class has a single constructor taking a + `dev.sqlcj.runtime.QueryExecutor`. +- The generated method name is the class name with a lower-case first character. +- A `:one` or `:many` query also generates a nested `public record` named + `Result` whose components follow the selected-column order, plus a + private `RowMapper` that reads each column by its one-based position. +- A `:exec` query generates no result record and returns `int`. +- Generated source imports only `dev.sqlcj.runtime.QueryExecutor`, + `dev.sqlcj.runtime.RowMapper`, `java.util.List`, and the JDK types of the + mapped columns, so the runtime artifact is the only sqlcj dependency a + consumer needs. + +`CreateAuthor` above generates: + +```java +package com.example.app.db; + +import dev.sqlcj.runtime.QueryExecutor; +import dev.sqlcj.runtime.RowMapper; +import java.util.List; + +/** + * Generated by sqlcj. + * + * Query: CreateAuthor + * Table: authors + * Type: ONE + */ + + +public final class CreateAuthor { + + private final QueryExecutor executor; + + public CreateAuthor(QueryExecutor executor) { + this.executor = executor; + } + + public record CreateAuthorResult( + Long id, + String name, + String bio + ) { + } + + private static final RowMapper ROW_MAPPER = + resultSet -> new CreateAuthorResult( + resultSet.getObject(1, Long.class), + resultSet.getObject(2, String.class), + resultSet.getObject(3, String.class) + ); + + public CreateAuthorResult createAuthor(String name, String bio) { + return executor.query( + """ + INSERT INTO authors (name, bio) + VALUES (?, ?) + RETURNING id, name, bio; + """, + java.util.Arrays.asList(name, bio), + ROW_MAPPER + ); + } +} +``` + +A query name, column name, or parameter name that is not already a valid Java +identifier is normalized deterministically, and names that would collide inside +one generated class are disambiguated in their SQL order. Those rules, and the +rejection of two queries whose generated files would collide, are documented in +[Generated Java Names](configuration.md#generated-java-names). + +The generated classes are executed through `dev.sqlcj.runtime.JdbcQueryExecutor` +on a `DataSource` or on a caller-owned `Connection`. Connection ownership, +transaction control, and exception translation are documented in +[Connection Ownership and Transactions](postgresql.md#connection-ownership-and-transactions). + +## Unsupported Queries + +The supported subset is exactly the shapes described above. sqlcj does not +validate the whole SQL language: a construct outside the subset is either +rejected with a diagnostic or, when it introduces no parameter and no result +column that sqlcj must type, carried into the executable SQL unanalyzed. Only +the documented shapes are contract, tested, and safe to rely on. + +### Rejected + +Rejection stops the run with a diagnostic naming the query source, the query +name, and its header line. + +- A result expression that is not a direct column or a wildcard, including a + function call, an aggregate, and a literal. +- A `FROM` item that is not a table, a comma-separated source list, a join that + is not a plain inner join, a join predicate that is not one qualified + equality, and a set operation such as `UNION`. +- A placeholder in a location sqlcj does not analyze, including `LIKE $1`, + `BETWEEN $1 AND $2`, and `LIMIT $1`, so parameterized pagination and dynamic + `IN` expansion are unavailable. +- Anonymous `?` and named `:name` placeholders, and non-contiguous or + non-positive placeholder indexes. +- An `INSERT` without an explicit column list, with more than one `VALUES` row, + with a value that is not a placeholder, or built from a `SELECT`; and an + `UPDATE` assignment that is not a single placeholder. +- An aliased, computed, qualified-wildcard, or unknown `RETURNING` item; + `RETURNING` on `:exec`; and `ON CONFLICT`, `UPDATE ... FROM`, + `DELETE ... USING`, or a common table expression in a returning write. +- Any annotation other than `:one`, `:many`, and `:exec`. + +### Not analyzed + +These are outside the subset and are not part of the contract. sqlcj neither +models nor rejects them, so a statement that uses one may still compile while +the generated Java describes only the part sqlcj did analyze. Do not rely on +them: + +- result aliases, which are ignored, +- `DISTINCT`, `GROUP BY`, and `HAVING`, +- predicate forms other than the listed comparisons, `AND`/`OR`, and fixed `IN` + lists, such as `IS NULL`, `LIKE` with a literal, or `IN` with a subquery, +- common table expressions, and subqueries outside the `FROM` item, +- `LIMIT` and `OFFSET` with literal values, +- `ON CONFLICT`, `UPDATE ... FROM`, and `DELETE ... USING` on a non-returning + `:exec` write. + +sqlcj itself provides no named parameters, macros, array parameters, dynamic +`IN` expansion, or query-building API. + +Unsupported schema input and unsupported column types are listed in +[PostgreSQL Support](postgresql.md#unsupported-types-and-ddl). + +## Verified by + +Query sources and annotations: + +- `DefaultQueryParserTest.parsesSingleQuery`, + `DefaultQueryParserTest.parsesMultipleQueries`, + `DefaultQueryParserTest.preservesQueryOrder`, + `DefaultQueryParserTest.parsesHeaderLineOfEachQuery`, + `DefaultQueryParserTest.rejectsInvalidHeader`, + `DefaultQueryParserTest.rejectsInvalidQueryType`, + `DefaultQueryParserTest.rejectsDuplicateQueryNames`, and + `DefaultQueryParserTest.rejectsQueryWithoutSql` cover the file format. +- `QueryAnalyzerTest.shouldRejectSelectWithoutResultQueryType`, + `QueryAnalyzerTest.shouldRejectWriteWithoutExecQueryType`, + `QueryAnalyzerTest.shouldRejectReturningWriteDeclaredAsExec`, and + `QueryAnalyzerTest.shouldAnalyzeReturningWriteForBothResultQueryTypes` cover + annotation and statement agreement. +- `JdbcQueryExecutorTest.shouldReturnNullWhenQueryFindsNoRows`, + `JdbcQueryExecutorTest.shouldReturnEmptyListWhenQueryManyFindsNoRows`, + `JdbcQueryExecutorTest.shouldReturnAllRowsForQueryMany`, and + `JdbcQueryExecutorTest.shouldReturnAffectedRowCountForExecute` cover the + cardinality results. + +Reads: + +- `QueryAnalyzerTest.shouldAnalyzeSelectWithExplicitColumns`, + `QueryAnalyzerTest.shouldResolveAllColumns`, + `QueryAnalyzerTest.shouldAnalyzeAliasedSingleTableSelect`, + `QueryAnalyzerTest.shouldAnalyzeSingleInnerJoin`, + `QueryAnalyzerTest.shouldExpandAllColumnsAcrossJoinedSourcesInOrder`, + `QueryAnalyzerTest.shouldExpandQualifiedAllColumnsForOneSource`, + `QueryAnalyzerTest.shouldRejectExcludedJoinReportedAsInnerJoin`, + `QueryAnalyzerTest.shouldRejectAmbiguousUnqualifiedColumn`, + `QueryAnalyzerTest.shouldResolveQueryParameterForComparisonOperator`, + `QueryAnalyzerTest.shouldResolveQueryParametersInsideNestedAndOrExpressions`, + and `QueryAnalyzerTest.shouldResolveQueryParametersInsideInExpression` cover + the accepted read shapes. +- `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedAliasedQualifiedQuery`, + `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedJoinQueryWithDuplicateColumnNames`, + and `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedMultipleJoinQuery` + compile and execute them. +- `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres` and + `PostgresIntegrationTest.shouldExecuteGeneratedManyQueryAgainstPostgres` + execute an ordered list read against PostgreSQL 16. + +Writes and `RETURNING`: + +- `QueryAnalyzerTest.shouldAnalyzeInsert`, `QueryAnalyzerTest.shouldAnalyzeUpdate`, + `QueryAnalyzerTest.shouldAnalyzeDelete`, + `QueryAnalyzerTest.shouldAnalyzeInsertReturningColumnsInDeclaredOrder`, + `QueryAnalyzerTest.shouldExpandInsertReturningAllColumnsInSchemaOrder`, + `QueryAnalyzerTest.shouldAnalyzeDeleteReturningAllColumns`, + `QueryAnalyzerTest.shouldRejectUnsupportedReturningItem`, + `QueryAnalyzerTest.shouldRejectUnknownReturningColumn`, and + `QueryAnalyzerTest.shouldRejectExcludedReturningWriteForm` cover the write + shapes. +- `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, + `PostgresIntegrationTest.shouldExecuteGeneratedInsertReturningAgainstPostgres`, + `PostgresIntegrationTest.shouldExecuteGeneratedUpdateReturningAgainstPostgres`, + and + `PostgresIntegrationTest.shouldExecuteGeneratedDeleteReturningAgainstPostgres` + execute them against PostgreSQL 16. + +Parameters: + +- `QueryAnalyzerTest.shouldResolveBindingIndexesInTextualOrder`, + `QueryAnalyzerTest.shouldRetainOneParameterForRepeatedIndex`, + `QueryAnalyzerTest.shouldRejectRepeatedIndexWithConflictingType`, + `QueryAnalyzerTest.shouldRejectGappedParameterIndexes`, + `QueryAnalyzerTest.shouldRejectZeroParameterIndex`, + `QueryAnalyzerTest.shouldRejectPlaceholderInUnsupportedLocation`, + `QueryAnalyzerTest.shouldRejectAnonymousParameter`, + `QueryAnalyzerTest.shouldRejectNamedParameter`, and + `QueryAnalyzerTest.shouldKeepPlaceholderTextThatIsNotAParameter` cover + ordering, repetition, and rejection. +- `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedQueryWithOutOfOrderPlaceholders`, + `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedQueryWithRepeatedPlaceholder`, + `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedUpdateWithOutOfOrderPlaceholders`, + and + `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedQueryWithProtectedPlaceholderText` + execute the compiled binding order. + +Generated Java: + +- `JavaCodeGeneratorTest` covers the generated class, constructor, result + record, row mapper, method signature, and compilation of the generated source. +- `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaFiles` and + `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForReturningWrites` + compile the generated output of the supported query shapes. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..ab50683 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,411 @@ +# Quickstart + +This walkthrough builds a small Maven application that reads and writes a +PostgreSQL table through sqlcj-generated Java. Every file is listed in full, so +the steps can be followed from an empty directory. + +The finished project uses only the packaged sqlcj artifacts: the executable CLI +JAR and the `dev.sqlcj:sqlcj-runtime` dependency. The sqlcj source tree is never +on the application's classpath. + +## 1. Prerequisites + +- A full JDK 21 on the `PATH` (`java -version` reports 21). +- Maven (verified with 3.8.7). +- Docker, or another way to reach a PostgreSQL 16 server. + +## 2. Get the sqlcj artifacts + +sqlcj has no public release yet, so the artifacts are not downloadable from +Maven Central or any other repository. Build them once from a clone of the sqlcj +repository: + +```bash +mvn -f /path/to/sqlcj/pom.xml -DskipTests install +``` + +That command: + +- installs `dev.sqlcj:sqlcj-runtime:0.1.0-SNAPSHOT` into the local Maven + repository, where the application below resolves it, and +- produces the executable CLI at + `/path/to/sqlcj/sqlcj-cli/target/sqlcj-cli-0.1.0-SNAPSHOT.jar`. + +The CLI JAR carries its own dependencies and starts `dev.sqlcj.Main`. Copy it +into the new project and confirm its version: + +```bash +mkdir -p my-app/tools +cp /path/to/sqlcj/sqlcj-cli/target/sqlcj-cli-0.1.0-SNAPSHOT.jar my-app/tools/ +cd my-app +java -jar tools/sqlcj-cli-0.1.0-SNAPSHOT.jar version +``` + +```text +0.1.0-SNAPSHOT +``` + +The CLI and the runtime dependency must always be the same version. + +## 3. Create the project layout + +```text +my-app/ +├── pom.xml +├── sqlcj.yaml +├── sql/ +│ ├── queries.sql +│ └── schema.sql +├── src/main/java/com/example/app/App.java +└── tools/sqlcj-cli-0.1.0-SNAPSHOT.jar +``` + +```bash +mkdir -p sql src/main/java/com/example/app +``` + +## 4. `pom.xml` + +```xml + + + 4.0.0 + + com.example + my-app + 1.0.0-SNAPSHOT + + + 21 + UTF-8 + 0.1.0-SNAPSHOT + ${project.build.directory}/generated-sources/sqlcj + + + + + dev.sqlcj + sqlcj-runtime + ${sqlcj.version} + + + org.postgresql + postgresql + 42.7.13 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + ${project.basedir}/src/main/java + ${sqlcj.generated.sources} + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + com.example.app.App + + + + + +``` + +Two details matter: + +- `sqlcj-runtime` is the only sqlcj dependency. It has no dependencies of its + own; the compiler, YAML, and SQL-parser libraries stay inside the CLI JAR. +- Maven Compiler Plugin 3.15.0 compiles the directories listed in + `compileSourceRoots`. Setting that list replaces the default, so both + `src/main/java` and the sqlcj output directory must be named explicitly. + Generated sources placed under `target/generated-sources` are not picked up + automatically, because the plugin only adds that directory to the project + after the compile execution has already chosen its inputs. + +## 5. `sqlcj.yaml` + +```yaml +version: "1" +sql: + - schema: sql/schema.sql + queries: sql/queries.sql +java: + package: com.example.app.db + out: target/generated-sources/sqlcj +``` + +`sqlcj generate` reads `sqlcj.yaml` from the directory it is run in, and the +relative paths above are resolved against the directory that contains the file. +Generated output therefore belongs in the build directory, where `mvn clean` +removes it. See [Configuration](configuration.md) for the full file format. + +## 6. `sql/schema.sql` + +The schema file is a snapshot of the tables the queries use. sqlcj reads it; it +never runs it. + +```sql +CREATE TABLE authors +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + bio TEXT, + created_at TIMESTAMP +); +``` + +The accepted column types and `CREATE TABLE` constructs are listed in +[PostgreSQL Support](postgresql.md). + +## 7. `sql/queries.sql` + +```sql +-- name: CreateAuthor :one +INSERT INTO authors (name, bio) +VALUES ($1, $2) +RETURNING id, name, bio; + +-- name: GetAuthor :one +SELECT id, name, bio +FROM authors +WHERE id = $1; + +-- name: ListAuthors :many +SELECT id, name +FROM authors +ORDER BY id; + +-- name: UpdateAuthorBio :exec +UPDATE authors +SET bio = $2 +WHERE id = $1; + +-- name: DeleteAuthor :exec +DELETE +FROM authors +WHERE id = $1; +``` + +Each query generates one class named after it: `CreateAuthor`, `GetAuthor`, +`ListAuthors`, `UpdateAuthorBio`, and `DeleteAuthor`. A `:one` or `:many` query +also generates a nested result record such as `GetAuthor.GetAuthorResult`. The +full query contract is documented in [Queries](queries.md). + +## 8. `src/main/java/com/example/app/App.java` + +```java +package com.example.app; + +import com.example.app.db.CreateAuthor; +import com.example.app.db.DeleteAuthor; +import com.example.app.db.GetAuthor; +import com.example.app.db.ListAuthors; +import com.example.app.db.UpdateAuthorBio; +import dev.sqlcj.runtime.JdbcQueryExecutor; +import dev.sqlcj.runtime.QueryExecutor; +import org.postgresql.ds.PGSimpleDataSource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +public final class App { + + public static void main(String[] args) throws SQLException { + DataSource dataSource = dataSource(); + + QueryExecutor executor = new JdbcQueryExecutor(dataSource); + + CreateAuthor.CreateAuthorResult created = new CreateAuthor(executor) + .createAuthor("Ada Lovelace", "First programmer"); + + System.out.println("created: " + created.id() + " " + created.name()); + + GetAuthor.GetAuthorResult read = new GetAuthor(executor).getAuthor(created.id()); + + System.out.println("read: " + read.name() + " / " + read.bio()); + + int updatedRows = new UpdateAuthorBio(executor) + .updateAuthorBio(created.id(), "Mathematician"); + + System.out.println("updated rows: " + updatedRows); + + for (ListAuthors.ListAuthorsResult author : new ListAuthors(executor).listAuthors()) { + System.out.println("listed: " + author.id() + " " + author.name()); + } + + System.out.println("missing row: " + new GetAuthor(executor).getAuthor(-1L)); + + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + + QueryExecutor transactional = new JdbcQueryExecutor(connection); + + CreateAuthor.CreateAuthorResult committed = new CreateAuthor(transactional) + .createAuthor("Grace Hopper", null); + + new UpdateAuthorBio(transactional).updateAuthorBio(committed.id(), "Compiler pioneer"); + + connection.commit(); + + System.out.println( + "committed: " + new GetAuthor(executor).getAuthor(committed.id()).bio() + ); + } + + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + + QueryExecutor transactional = new JdbcQueryExecutor(connection); + + CreateAuthor.CreateAuthorResult discarded = new CreateAuthor(transactional) + .createAuthor("Temporary Author", null); + + new UpdateAuthorBio(transactional).updateAuthorBio(discarded.id(), "never stored"); + + connection.rollback(); + + System.out.println( + "rolled back: " + new GetAuthor(executor).getAuthor(discarded.id()) + ); + } + + System.out.println("deleted rows: " + new DeleteAuthor(executor).deleteAuthor(created.id())); + } + + private static DataSource dataSource() { + PGSimpleDataSource dataSource = new PGSimpleDataSource(); + + dataSource.setUrl("jdbc:postgresql://localhost:5432/quickstart"); + dataSource.setUser("quickstart"); + dataSource.setPassword("quickstart"); + + return dataSource; + } +} +``` + +## 9. Start PostgreSQL and apply the schema + +```bash +docker run --rm -d --name sqlcj-quickstart \ + -e POSTGRES_DB=quickstart \ + -e POSTGRES_USER=quickstart \ + -e POSTGRES_PASSWORD=quickstart \ + -p 5432:5432 \ + postgres:16-alpine + +docker exec -i sqlcj-quickstart psql -U quickstart -d quickstart < sql/schema.sql +``` + +sqlcj does not create or migrate tables. Applying the same snapshot text that +the compiler reads initializes the walkthrough database consistently with the +compiler's input. sqlcj never inspects the live database, so keeping the +snapshot synchronized with later database changes is your responsibility. + +## 10. Generate, compile, and run + +Run the three steps in this order, from the project root: + +```bash +mvn clean +java -jar tools/sqlcj-cli-0.1.0-SNAPSHOT.jar generate +mvn compile +mvn exec:java +``` + +- `mvn clean` deletes `target`, including previously generated sources. sqlcj + writes and overwrites its own files but never deletes a file it did not just + generate, so a query that was renamed or removed would otherwise leave a stale + class behind that still compiles. +- `sqlcj generate` is a separate command. It is not bound to the Maven + lifecycle, so it must run after `clean` and before `compile`. It must run from + the project root, because it reads `sqlcj.yaml` from the current directory. +- `mvn compile` then compiles `src/main/java` together with + `target/generated-sources/sqlcj`. + +Generation writes one file per query: + +```text +target/generated-sources/sqlcj/com/example/app/db/CreateAuthor.java +target/generated-sources/sqlcj/com/example/app/db/DeleteAuthor.java +target/generated-sources/sqlcj/com/example/app/db/GetAuthor.java +target/generated-sources/sqlcj/com/example/app/db/ListAuthors.java +target/generated-sources/sqlcj/com/example/app/db/UpdateAuthorBio.java +``` + +`mvn exec:java` prints: + +```text +created: 1 Ada Lovelace +read: Ada Lovelace / First programmer +updated rows: 1 +listed: 1 Ada Lovelace +missing row: null +committed: Compiler pioneer +rolled back: null +deleted rows: 1 +``` + +That output is the whole MVP contract in one run: + +- `CreateAuthor` is a `:one` write whose `RETURNING` clause reads back the + database-generated `BIGSERIAL` identifier as a typed `Long`. +- `GetAuthor` is a `:one` read, and returns `null` when no row matches. +- `ListAuthors` is a `:many` read, and returns an empty list when no row + matches. +- `UpdateAuthorBio` and `DeleteAuthor` are `:exec` writes and return their + affected-row counts. `UpdateAuthorBio` also shows that parameter order and + binding order are different things: `$1` is the first method parameter even + though `$2` occurs first in the SQL text. +- The two `try` blocks run generated operations on a caller-owned `Connection` + with auto-commit disabled. The runtime never closes, commits, rolls back, or + reconfigures that connection, so the application's own `commit` makes both + writes durable and its own `rollback` discards them. + +## 11. Re-running after a SQL change + +After editing `sql/schema.sql` or `sql/queries.sql`, repeat the same order: + +```bash +mvn clean +java -jar tools/sqlcj-cli-0.1.0-SNAPSHOT.jar generate +mvn compile +``` + +If a query is invalid, `sqlcj generate` prints one diagnostic naming the source, +the query, and the line, and exits with status `1`: + +```text +sqlcj: Invalid query 'CountAuthors' in /home/dev/my-app/sql/queries.sql at line 26: Unsupported SELECT expression: Function +``` + +Every configured source is compiled before any file is written, so a diagnostic +like this writes no new file and leaves the previous output in place. + +## 12. Clean up + +```bash +docker rm -f sqlcj-quickstart +``` + +## Next steps + +- [Queries](queries.md) — annotations, supported SQL shapes, parameters, and + the generated API. +- [Configuration](configuration.md) — `sqlcj.yaml`, multiple source entries, and + generated naming. +- [PostgreSQL Support](postgresql.md) — column types, nulls, and the runtime + connection contract.