This walkthrough builds a small Maven application that reads and writes two PostgreSQL tables 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.
- A full JDK 21 on the
PATH(java -versionreports 21). - Maven (verified with 3.8.7).
- Docker, or another way to reach a PostgreSQL 16 server.
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:
mvn -f /path/to/sqlcj/pom.xml -DskipTests installThat command:
- installs
dev.sqlcj:sqlcj-runtime:0.1.0-SNAPSHOTinto 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:
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 version0.1.0-SNAPSHOT
The CLI and the runtime dependency must always be the same version.
my-app/
├── pom.xml
├── sqlcj.yaml
├── sql/
│ ├── migrations/
│ │ ├── V1__create_authors_and_books.sql
│ │ └── V2__add_author_created_at_and_book_index.sql
│ └── queries.sql
├── src/main/java/com/example/app/App.java
└── tools/sqlcj-cli-0.1.0-SNAPSHOT.jar
mkdir -p sql/migrations src/main/java/com/example/app<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<sqlcj.version>0.1.0-SNAPSHOT</sqlcj.version>
<sqlcj.generated.sources>${project.build.directory}/generated-sources/sqlcj</sqlcj.generated.sources>
</properties>
<dependencies>
<dependency>
<groupId>dev.sqlcj</groupId>
<artifactId>sqlcj-runtime</artifactId>
<version>${sqlcj.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.13</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<compileSourceRoots>
<compileSourceRoot>${project.basedir}/src/main/java</compileSourceRoot>
<compileSourceRoot>${sqlcj.generated.sources}</compileSourceRoot>
</compileSourceRoots>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<mainClass>com.example.app.App</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>Two details matter:
sqlcj-runtimeis 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 bothsrc/main/javaand the sqlcj output directory must be named explicitly. Generated sources placed undertarget/generated-sourcesare not picked up automatically, because the plugin only adds that directory to the project after the compile execution has already chosen its inputs.
version: "1"
sql:
- name: Author
schema: sql/migrations
queries: sql/queries.sql
java:
package: com.example.app.db
out: target/generated-sources/sqlcjsql[].name is the identity of the query group. It names the generated
repository, so the entry above generates one AuthorRepository holding every
query of sql/queries.sql.
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.
--config <path> names another file when the command runs from elsewhere.
Generated output therefore belongs in the build directory, where mvn clean
removes it. See Configuration for the full file format.
The configured schema is a migration directory rather than one snapshot file. sqlcj reads its files; it never runs them.
sql/migrations/V1__create_authors_and_books.sql:
CREATE TABLE authors
(
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
bio TEXT
);
CREATE TABLE books
(
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES authors (id),
title VARCHAR(255) NOT NULL
);sql/migrations/V2__add_author_created_at_and_book_index.sql:
ALTER TABLE authors ADD COLUMN created_at TIMESTAMP;
CREATE INDEX books_author_id_idx ON books (author_id);A directory named by sql[].schema is read in Flyway version order, so V1
comes before V2 no matter how the filesystem lists the two files. The
statements of both files build one schema model: V2 applies its
ALTER TABLE ... ADD COLUMN to the authors table V1 created, appending
created_at after bio, and its CREATE INDEX is accepted and ignored,
because an index changes no column the queries can read. The modeled tables are
therefore exactly the two tables the queries use, with authors carrying id,
name, bio, and created_at in that order.
See sql[].schema for the directory and ordering
rules, Ordered Table DDL for the statements
that update an already modeled table, and
Ignored Statements for the statements that
are accepted without changing the model. The accepted column types and
CREATE TABLE constructs are listed in
PostgreSQL Support.
-- name: CreateAuthor :one
INSERT INTO authors (name, bio)
VALUES ($1, $2)
RETURNING *;
-- name: GetAuthor :one
SELECT *
FROM authors
WHERE id = $1;
-- name: FindAuthor :optional
SELECT *
FROM authors
WHERE id = $1;
-- name: ListAuthors :many
SELECT *
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;
-- name: SearchAuthors :many
SELECT *
FROM authors
WHERE name ILIKE $1
ORDER BY id;
-- name: CountAuthors :one
SELECT COUNT(*) AS total
FROM authors;
-- name: ListAuthorPage :many
SELECT *
FROM authors
ORDER BY id
LIMIT $1 OFFSET $2;
-- name: CreateBook :exec
INSERT INTO books (author_id, title)
VALUES ($1, $2);
-- name: ListAuthorBooks :many
SELECT a.name, b.title
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
ORDER BY a.id, b.id;All eleven queries become methods of the one generated AuthorRepository:
createAuthor, getAuthor, findAuthor, listAuthors, updateAuthorBio,
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 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,
while findAuthor returns Optional<AuthorsRow> and accepts none. The full
query contract is documented in Queries.
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;
import org.postgresql.ds.PGSimpleDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
public final class App {
public static void main(String[] args) throws SQLException {
DataSource dataSource = dataSource();
QueryExecutor executor = new JdbcQueryExecutor(dataSource);
AuthorRepository authors = new AuthorRepository(executor);
AuthorsRow created = authors.createAuthor("Ada Lovelace", "First programmer");
System.out.println("created: " + created.id() + " " + created.name());
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 (AuthorsRow author : authors.listAuthors()) {
System.out.println("listed: " + author.id() + " " + author.name());
}
Optional<AuthorsRow> missing = authors.findAuthor(-1L);
System.out.println("missing row: " + missing.isPresent());
try {
authors.getAuthor(-1L);
} catch (QueryCardinalityException e) {
System.out.println("missing row rejected: " + e.getMessage());
}
Long committedId;
try (Connection connection = dataSource.getConnection()) {
connection.setAutoCommit(false);
AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection));
AuthorsRow committed = transactionalAuthors.createAuthor("Grace Hopper", null);
transactionalAuthors.updateAuthorBio(committed.id(), "Compiler pioneer");
connection.commit();
committedId = committed.id();
System.out.println("committed: " + authors.getAuthor(committedId).bio());
}
try (Connection connection = dataSource.getConnection()) {
connection.setAutoCommit(false);
AuthorRepository transactionalAuthors = new AuthorRepository(new JdbcQueryExecutor(connection));
AuthorsRow discarded = transactionalAuthors.createAuthor("Temporary Author", null);
transactionalAuthors.updateAuthorBio(discarded.id(), "never stored");
connection.rollback();
System.out.println("rolled back: " + authors.findAuthor(discarded.id()).isPresent());
}
for (AuthorsRow author : authors.searchAuthors("%lovelace%")) {
System.out.println("searched: " + author.id() + " " + author.name());
}
System.out.println("count: " + authors.countAuthors().total());
for (AuthorsRow author : authors.listAuthorPage(1, 1)) {
System.out.println("page: " + author.id() + " " + author.name());
}
int bookRows = authors.createBook(committedId, "The Education of a Computer");
System.out.println("created book rows: " + bookRows);
for (AuthorRepository.ListAuthorBooksResult book : authors.listAuthorBooks()) {
System.out.println("book: " + book.name() + " / " + book.title());
}
System.out.println("deleted rows: " + authors.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;
}
}One repository instance serves the whole DataSource-backed execution context,
and each transaction constructs another repository over its caller-owned
connection. No code constructs a type per query.
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/migrations/V1__create_authors_and_books.sql
docker exec -i sqlcj-quickstart psql -U quickstart -d quickstart \
< sql/migrations/V2__add_author_created_at_and_book_index.sqlsqlcj reads the migration files and never runs them. Applying them is the job of
the application's migration tool, which is psql in the two commands above, run
in the same version order sqlcj reads them in. sqlcj never inspects the live
database, so keeping the migration directory the compiler reads and the database
the application connects to in step is your responsibility.
Run the three steps in this order, from the project root:
mvn clean
java -jar tools/sqlcj-cli-0.1.0-SNAPSHOT.jar generate
mvn compile
mvn exec:javamvn cleandeletestarget, including previously generated sources and the classes compiled from them. sqlcj writes and overwrites its own files and deletes a repository its previous run recorded that the current run no longer generates, so a renamed or removedsqlentry leaves no stale repository source behind. It never deletes a compiled class, though, somvn cleanstays in the sequence to drop the class an earlier run compiled from a repository that is no longer generated.sqlcj generateis a separate command. It is not bound to the Maven lifecycle, so it must run aftercleanand beforecompile. The commands above run from the project root, becausegeneratereadssqlcj.yamlfrom the current directory; from another directory, pass--config <path to sqlcj.yaml>instead, and the paths inside the file keep resolving against the project root.mvn compilethen compilessrc/main/javatogether withtarget/generated-sources/sqlcj.
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:
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
mvn exec:java prints:
created: 1 Ada Lovelace
read: Ada Lovelace / First programmer
updated rows: 1
listed: 1 Ada Lovelace
missing row: false
missing row rejected: Query 'GetAuthor' in AuthorRepository returned no row; expected exactly one
committed: Compiler pioneer
rolled back: false
searched: 1 Ada Lovelace
count: 2
page: 2 Grace Hopper
created book rows: 1
book: Ada Lovelace / null
book: Grace Hopper / The Education of a Computer
deleted rows: 1
That output is the whole MVP contract in one run:
- One
AuthorRepositoryinstance answers every call, and each generated method keeps the types and order of its named query. CreateAuthoris a:onewrite whoseRETURNINGclause reads back the database-generatedBIGSERIALidentifier as a typedLong.GetAuthoris a:oneread: it returns the row when exactly one matches, and fails withdev.sqlcj.runtime.QueryCardinalityExceptionnaming the query and the repository when none matches or several do.FindAuthoris an:optionalread of the same row, and returnsOptional.empty()when no row matches, which is how the sample checks for a missing, rolled back, or deleted author.ListAuthorsis a:manyread, and returns an empty list when no row matches.SearchAuthorsfilters withname ILIKE $1, so the pattern is aStringparameter carrying its own%wildcards, and PostgreSQL matches it without regard to case.CountAuthorsprojects an aliasedCOUNT(*)as its only result item, socountAuthors()returns aCountAuthorsResultwhosetotalis a non-nullLong.ListAuthorPagepages withLIMIT $1 OFFSET $2, which generateslistAuthorPage(Integer limit, Integer offset), so asking for one row after the first returns the second author alone.ListAuthorBooksselects one column from each side of aLEFT JOIN, so it gets its ownListAuthorBooksResultrecord and readstitleasnullfor the author with no book, even thoughbooks.titleis declaredNOT NULL.UpdateAuthorBio,DeleteAuthor, andCreateBookare:execwrites and return their affected-row counts.UpdateAuthorBioalso shows that parameter order and binding order are different things:$1is the first method parameter even though$2occurs first in the SQL text.CreateBookwrites the schema's other table, so the methods of one query group may span every table of its schema.- The two
tryblocks run generated operations on a caller-ownedConnectionwith auto-commit disabled. The runtime never closes, commits, rolls back, or reconfigures that connection, so the application's owncommitmakes both writes durable and its ownrollbackdiscards them.
After adding a migration under sql/migrations or editing sql/queries.sql,
repeat the same order:
mvn clean
java -jar tools/sqlcj-cli-0.1.0-SNAPSHOT.jar generate
mvn compileRegeneration cleans up after itself. The previous run recorded every file it
wrote in target/generated-sources/sqlcj/sqlcj-manifest.txt, so renaming or
removing an sql entry deletes the repository that entry used to generate,
while a file sqlcj never generated is left alone. See
Configuration for the full
manifest and cleanup rules.
If a query is invalid, sqlcj generate prints one diagnostic naming the source,
the query, and the line, and exits with status 1:
sqlcj: Invalid query 'CountAuthorBios' in /home/dev/my-app/sql/queries.sql at line 57: 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.
docker rm -f sqlcj-quickstart- Queries — annotations, supported SQL shapes, parameters, and the generated API.
- Configuration —
sqlcj.yaml, multiple source entries, and generated naming. - PostgreSQL Support — column types, nulls, and the runtime connection contract.