Converters

Talks about:

The JDBC API has no object mapping. Something has to turn a java.sql.ResultSet row into a value of your domain, and in YoSQL that something is a converter: a plain Java class with a method taking a ResultSet and returning your type.

Name a record and the converter is written for you. Write one by hand when the mapping is doing something a record cannot express.

From a record

Point a statement at a record and YoSQL reads that record’s canonical constructor and emits the converter:

-- name: findTenant
-- returning: single
-- resultRowType: com.example.domain.Tenant
select id,
       account_id,
       slug,
       currency,
       created_at
from tenant
where id = :id
package com.example.domain;

public record Tenant(UUID id, UUID accountId, String slug, Currency currency, Instant createdAt) {
}

What comes out is one resultSet.getX(...) call per component and then the constructor — the same code you would have written:

public final class ToTenantConverter {
  public Tenant asUserType(final ResultSet resultSet) throws SQLException {
    final UUID id = resultSet.getObject("id", UUID.class);
    final UUID accountId = resultSet.getObject("account_id", UUID.class);
    final String slug = resultSet.getString("slug");
    final String currencyCode = resultSet.getString("currency");
    final Currency currency = currencyCode == null ? null : Currency.getInstance(currencyCode);
    final Timestamp createdAtTimestamp = resultSet.getTimestamp("created_at");
    final Instant createdAt = createdAtTimestamp == null ? null : createdAtTimestamp.toInstant();
    return new Tenant(id, accountId, slug, currency, createdAt);
  }
}

Nothing is looked up by name at runtime, so the result survives a GraalVM native image without a reflection hint — which is the reason to reach for generated JDBC over an ORM in the first place.

The record is read from its source, under sourceDirectory , because code generation runs before compilation and the record usually lives in the module being generated for. Nothing is loaded or executed while reading it.

Which column a component reads

A component’s own name, read as snake_case: tenantId reads tenant_id, createdAt reads created_at. Where the column is named something else, alias it in the query:

select amount_cents as minor_units,
       currency
from ledger_entry

That keeps the one place the two naming schemes meet next to the column being renamed, rather than in configuration you would have to go and find.

When the query is not yours to change — a view you do not own, or SQL generated elsewhere — name the column in the front matter instead. Keys are component paths from the root of the result row type:

-- name: findLedgerEntries
-- returning: multiple
-- resultRowType: com.example.domain.LedgerEntry
-- resultRowColumns:
--   amount.minorUnits: amount_cents
--   at: created_at

A column override belongs to the type rather than to one query, so every statement naming the same result row type shares one set: overrides declared on any of them apply to all of them, and two statements mapping the same component to different columns fail the build.

Value objects spanning several columns

A component whose type is itself a record is built from the same flat row. Nesting groups values on the Java side and the query knows nothing about it, so a nested component claims the column matching its own name — no prefix:

public record Money(long minorUnits, Currency currency) {
}

public record LedgerEntry(long id, Money amount, Reason reason, String reference, Instant at) {
}
-- name: findLedgerEntries
-- returning: multiple
-- resultRowType: com.example.domain.LedgerEntry
select id,
       amount_cents as minor_units,
       currency,
       reason,
       reference,
       created_at as at
from ledger_entry
where tenant_id = :tenantId

LedgerEntry arrives with its Money assembled from minor_units and currency.

Null, and what it must not become

getLong answers 0 for SQL NULL and getInt answers 0, which is how a nullable column quietly becomes a wrong number. So:

Make a component nullable by giving it a reference type: Instant activatedAt rather than a primitive.

Enums

An enum component is read as text and passed to valueOf. A value the enum does not know raises IllegalArgumentException naming the type and the value. Failing is the point: a persistence layer handed a state it cannot represent should stop rather than invent a default.

What fails the build

A mismatch between the query and the record is visible before anything is compiled, so it stops the build and names the file, the statement and the component:

Where the select list cannot be enumerated — select *, or an expression without an alias — the first two checks are skipped rather than guessed at. Aliasing your expressions gets them back.

Types that build themselves

A type nothing above can read still works, if it says how. Give it a static factory called valueOf taking one value the generator does know, and a component of that type is read from a single column and handed to it:

public record TenantId(UUID value) {
    public static TenantId valueOf(final UUID value) {
        return new TenantId(value);
    }
}
final UUID tenantIdValue = resultSet.getObject("tenant_id", UUID.class);
final TenantId tenantId = tenantIdValue == null ? null : TenantId.valueOf(tenantIdValue);

This is the same convention enums already follow — OrderState.valueOf(String) — extended to your own types, and it is the extension point for anything the built-in list cannot cover: an identifier wrapped around a UUID, a normalising short name, a column holding JSON that your factory parses. The mapping lives on the type it belongs to, written in Java, and the emitted call is still a direct one, so nothing about it needs a native-image hint.

Every rule above still holds, because the column is read exactly as a bare value of the parameter’s type would be: a primitive parameter refuses NULL, an object parameter accepts it, and the factory is only called for a value that is actually there.

The factory also settles what a one-component record means. With one, TenantId is a value wrapped around a column and reads the column its component in the outer record names. Without one, it is an ordinary nested record and its component reads a column called value. The type says which by declaring the factory or not.

Two valueOf overloads that both take a readable type is a build error rather than a coin flip — leave a single one taking the type the column holds.

Types a component can have

String, UUID, Instant, LocalDate, LocalDateTime, LocalTime, OffsetDateTime, BigDecimal, Currency, byte[], the primitives and their wrappers, any enum, any record built from those, and any type with a valueOf factory taking one of them.

Writes that answer with a row

insert … returning id is a write that produces a result set, and Postgres will only hand it back through executeQuery. Say so with type, because the method name decides otherwise — anything starting with insert, update, delete and the rest of allowedWritePrefixes is taken for a write, and a write runs executeUpdate and throws the row away:

-- name: insertSignIn
-- type: reading
-- returning: single
-- resultRowType: com.example.domain.SignInId
-- parameters:
--   - name: accountId
--     type: java.util.UUID
insert into sign_in (account_id, created_at)
values (:accountId, now())
returning id
public record SignInId(UUID id) {
}

The record’s component is named after the column the statement returns, so a single returned value needs a one-component record rather than a bare UUID — a result row type is always a row.

Results that are one value

A statement answering with a single value names that value’s type, and needs no record around it:

-- name: countTenants
-- type: reading
-- returning: single
-- resultRowType: java.lang.Long
select count(*)
from tenant
Optional<Long> countTenants()

The value is read from the first column by position, not by name, because a value has no name to go by — count(*) names nothing, and requiring an alias for it would be a rule about SQL style rather than about mapping. When the select list can be enumerated and holds more than one column, that is a build error: one value cannot hold two columns.

Anything a column can hold works — String, UUID, Instant, BigDecimal, the wrappers — as does an enum, and so does a type that builds itself:

-- name: findTenantIdentity
-- returning: single
-- resultRowType: com.example.domain.TenantId
select id from tenant where slug = :slug

A valueOf factory means one column here exactly as it does for a component, so TenantId reads the column rather than becoming a row of one. A one-component record without a factory is still a row, and reads the column its component names — which is what insert … returning id uses.

A primitive is refused: a statement that may return no row answers Optional, and Optional<long> is not a type. Name the wrapper.

A row holding SQL NULL answers Optional.empty() rather than throwing.

Parameters, on the way in

The same types travel back. A parameter declared as a value type is unwrapped through the accessor its valueOf takes, so a repository accepts what it hands back:

-- name: insertTenant
-- parameters:
--   - name: tenantId
--     type: com.example.domain.TenantId
--   - name: registeredAt
--     type: java.time.Instant
insert into tenant (id, registered_at)
values (:tenantId, :registeredAt)
final UUID tenantIdParameter = tenantId == null ? null : tenantId.value();
final Timestamp registeredAtParameter = registeredAt == null ? null : Timestamp.from(registeredAt);

Instant is there for a reason of its own: PostgreSQL refuses setObject with one — “Can’t infer the SQL type to use for an instance of java.time.Instant” — so a statement naming the type its domain actually uses would compile and fail at run time. Instant, Currency and enums are converted; everything a driver already takes is passed through untouched, so a statement whose parameters need nothing generates exactly what it always generated.

The conversion happens once, in front of the loop that binds the parameter, so a name used twice in one statement is unwrapped once. Passing null writes SQL NULL rather than throwing.

Two things this refuses, both at build time. A record that is not one value — Money(long minorUnits, Currency currency) — because a statement binds one value per parameter; declare a parameter per component. And a value type that is not a record, because reading one needs only the valueOf factory while writing one needs the accessor it came from, and a class does not say which of its methods that is.

Map converter

Without a resultRowType or a converter of your own, generated code returns Map<String, Object>. That converter is the defaultConverter unless you say otherwise, so freshly generated code returns maps to begin with. Turn it off with generateMapConverter , move it with mapConverterClass , rename its method with mapConverterMethod and its alias with mapConverterAlias . Methods using it look like:

Optional<Map<String, Object>> someMethod()
List<Map<String, Object>> someMethod()
Stream<Map<String, Object>> someMethod()

Generated record converters live in the same package as the map converter, so mapConverterClass decides where they all go. What they are called comes from recordConverterPrefix and recordConverterSuffixTo and Converter by default, giving ToTenantConverter and a repository field named tenantConverter. The method each one declares is recordConverterMethod , asUserType by default. Set them to whatever the hand-written converters in your project already use, so a repository reads the same whichever kind it calls.

Default converter

Every statement that does not say otherwise uses the default converter. Set defaultConverter to change what that is for all of them at once. It points at the map converter above until you change it.

Writing one by hand

Some mappings a record cannot express: a discriminator column choosing between subtypes, a column holding JSON you want parsed, a legacy shape you do not want in your domain. Write the converter yourself:

package my.own;

import java.sql.ResultSet;
import java.sql.SQLException;

import my.own.User;

public class UserConverter {

    public User apply(ResultSet resultSet) throws SQLException {
        User pojo = new User();
        pojo.setId(resultSet.getInt("id"));
        pojo.setName(resultSet.getString("name"));
        return pojo;
    }

}

Package, class name and method name are yours to choose. Name the class on a statement as its resultRowConverter — that is the whole configuration:

-- resultRowConverter: my.own.UserConverter
select id, name
from users

YoSQL reads the class to find the one public method taking a ResultSet. That method’s name is what the repository calls, its return type is what the statement produces, and the field the converter is injected into is the class name with a lower-case first letter — userConverter here. Nothing about the method is repeated in configuration, so it cannot go stale when you rename it.

Name the same class as defaultConverter to use it for every statement that names none of its own.

Like a record, the class is read from its source under sourceDirectory , and three things fail the build rather than the compile that follows: a class with no source there, a class declaring no public method taking a ResultSet, and a class declaring more than one — with nothing to choose between them, guessing would be worse than stopping.

A statement naming both a resultRowConverter and a resultRowType keeps the converter: naming a converter names the exact code to call, and there is nothing left to infer.

Either way, generated methods return the converter’s result type:

Optional<User> someMethod()
List<User> someMethod()
Stream<User> someMethod()