Nested fields
- Why a builder?
.then()— nested objects.then()into collection elements@SerialNameinteroperability- Ordering on nested paths
- Where to next
Real data classes nest. Sqkon’s JsonPathBuilder lets you point at a field
inside a nested object — or inside an element of a list — using the same
KProperty1 references you already use for top-level fields.
@Serializable
data class Merchant(
val id: String,
val name: String,
val location: Location,
val tags: List<Tag>,
)
@Serializable data class Location(val city: String, val zip: String)
@Serializable data class Tag(val name: String, val score: Int)
Why a builder?
A KProperty1<Merchant, Location> knows about exactly one hop. Kotlin
reflection can’t chain Merchant::location into Location::city and give you
a single property reference — there’s no such thing in the language.
JsonPathBuilder fills that gap. It walks the chain you describe and emits a
JSON path string ($.location.city) plus the serial descriptors needed to
handle value classes, sealed types, and collection elements correctly. Every
operator that takes a KProperty1 also has an overload that takes a
JsonPathBuilder<T>, so the rest of the Where DSL is unchanged.
.then() — nested objects
Chain into a child object with .then(<child property>):
merchants.select(
where = Merchant::location.then(Location::city) eq "Brooklyn",
).first()
That builds the path $.location.city. You can chain further by passing a
block:
val builder = Merchant::location.builder {
then(Location::city)
}
// builder.buildPath() == "$.location.city"
.then() works for arbitrary depth — keep chaining then(...) blocks until
you reach the leaf value you want to match on.
.then() into collection elements
When the next hop is a List (or any Collection), use the same .then()
function — Kotlin picks the right overload from the property’s type. Sqkon
emits the JSON path with [%] so JSONB matches against any element:
merchants.select(
where = Merchant::tags.then(Tag::name) eq "vegan",
).first()
That builds $.tags[%].name, which JSONB evaluates as “any element of tags
whose name is 'vegan'”.
The same
.then(...)symbol covers nested-object hops AND list-element hops. The compiler picks the overload by inspecting the property’s type (KProperty1<R, Foo>vs.KProperty1<R, Collection<Foo>>). At the JVM level the list overload is namedthenList(via@JvmName), but you always call it as.then(...)from Kotlin. There is no.thenList(...)Kotlin symbol.
A few common shapes:
// List<Object> — chain into a property of each element
val builder = Merchant::tags.then(Tag::name)
// path: $.tags[%].name
// Top-level Collection of Strings — start from the class
val builder2 = Merchant::class.withList(Merchant::aliases) { /* leaf */ }
// path: $.aliases[%]
The JsonPathBuilderTest.kt suite is the source of truth for what each
combination produces — build_with_then_list, build_with_list_then, and
build_with_list_path are the cases you’ll hit most.
Filtering on a list element matches if any element satisfies the predicate. There’s no built-in “all elements satisfy X” — combine with
not(... someElement neq X)if you need it, but think hard about the resulting query plan first.
@SerialName interoperability
Sqkon stores values as JSON, so the field name in the database is the serial name — not always the Kotlin property name. When they differ, you have two options.
1. Pass the serial name explicitly:
val builder = Merchant::serialName.builder(serialName = "different_name")
// builder.buildPath() == "$.different_name"
2. Or annotate at the data class:
@Serializable
data class Merchant(
@SerialName("snake_case_name") val camelCaseName: String,
/* ... */
)
In the second form, the builder uses the property’s Kotlin name by default —
not the serial name. Most code paths in Sqkon detect serial names from the
SerialDescriptor, but property-name overrides like @SerialName on a single
field are not reflected automatically. The pragmatic rule:
- If you renamed exactly one field with
@SerialName, pass the serial name throughbuilder(serialName = "...")or as theserialNameparameter on.then(). - If you renamed via a serializer-level convention (snake_case naming strategy, etc.), the descriptor carries the right name and the builder uses it automatically.
For value classes (@JvmInline value class), Sqkon inlines them into the
parent path correctly — $.testValue rather than $.testValue.test. Sealed
classes get a [1] discriminator ($.sealed[1].boolean); see the
build_with_sealed_path test for the exact shape.
Ordering on nested paths
Same builder, same operators — pass it to OrderBy:
merchants.selectAll(
orderBy = listOf(
OrderBy(Merchant::location.then(Location::city)),
),
).first()
See Ordering for direction and multi-field precedence.
Where to next
- Querying — operator reference and combinators.
- Ordering — sort by nested or top-level fields.
- Performance — when nested-path queries get expensive.