kandra-codegen
kandra-codegen is a single KSP SymbolProcessor — io.kandra.codegen.KandraProcessor — that scans
for @ScyllaTable-annotated classes and emits one *Table Kotlin object per entity, with a
KandraColumnRef<T> property for every non-@Transient field. That's the entire module: no config
DSL, no processor options, one class doing one job. It's what makes
UserTable.email eq "alice@example.com" a compile-checked reference to a real column instead of a
magic string, by generating UserTable from User's own annotations at build time.
Why this exists as a separate module
Everything else in Kandra is either annotation/schema logic (kandra-core) or runtime driver calls
(kandra-runtime); kandra-codegen is neither — it's a build-time KSP processor, applied with
ksp(...), not implementation(...), and it runs in its own Gradle KSP task, not in your
application's classpath at runtime at all. Separating it means a consumer's build.gradle.kts
declares it distinctly from the libraries the compiled code depends on:
dependencies {
implementation("ke.co.coinx.kandra:kandra-runtime")
ksp("ke.co.coinx.kandra:kandra-codegen") // build-time only — never on the runtime classpath
}It depends on kandra-core (implementation(project(":kandra-core")), for the FQN of
@ScyllaTable/@Column/@Transient/@LookupIndex it looks for) and the KSP API — nothing else. No
DataStax driver, no coroutines: it never touches a real query, only source text.
process(resolver) — what triggers generation
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("io.kandra.core.annotations.ScyllaTable")
val unprocessed = symbols.filter { !it.validate() }.toList()
symbols.filter { it is KSClassDeclaration && it.validate() }.forEach { processClass(it as KSClassDeclaration) }
return unprocessed
}Triggers on exactly one annotation FQN: @ScyllaTable. Nothing else — not @PartitionKey, not
@Column standing alone — causes a file to be generated; a class with those but no class-level
@ScyllaTable produces nothing.
The only shape check is it is KSClassDeclaration. Putting @ScyllaTable on an object, an
interface, an enum class, or an abstract class is not rejected — the processor will happily
generate a *Table object referencing that type as the entity type parameter, which will very
likely fail to compile or make no sense downstream. @ScyllaTable is trusted to only ever be
applied to a normal concrete/data class; nothing here enforces it.
What actually gets generated
Note what's missing from the right side: sessionToken (@Transient, excluded entirely), and
nothing about @ScyllaTable's gcGraceSeconds, @SoftDelete, @ClusteringKey's order, @Version,
or @Sensitive — none of that is read by codegen at all (see the table below). The generated
property names (userId, email, ...) match the Kotlin source exactly; only the CQL string
argument ("user_id", "email") is snake-cased, or taken verbatim from @Column.
Naming
| Thing | Rule |
|---|---|
| Generated object name | "${className}Table", no configurability — User → UserTable. |
| Generated property name | Identical to the Kotlin property name — no transformation. |
| CQL column name | @Column(name = "...") if present and non-blank, else camelToSnake(propertyName). |
private fun camelToSnake(name: String): String =
name.replace(Regex("([A-Z])")) { "_${it.value.lowercase()}" }.trimStart('_')This is a per-character regex, not word-boundary-aware: every individual uppercase letter gets its
own _ + lowercase. userId → user_id is fine, but userURL → user_u_r_l, and parseHTML →
parse_h_t_m_l. This exact regex is duplicated (not shared) in kandra-core's
SchemaRegistry.camelToSnake — the two derivations have to agree, since the CQL column name codegen
bakes into UserTable.userUrl's KandraColumnRef has to match the CQL name SchemaRegistry derives
for the same property, or a query built through the generated table object silently targets a column
name that doesn't match what DdlGenerator created. If an entity has a property with a run of
capitals (an acronym or initialism), either rename the property to avoid the run (userUrl instead
of userURL) or pin the CQL name explicitly with @Column(name = "user_url") — don't rely on the
auto-derivation to do the sensible thing with acronyms, because it doesn't.
@Column's name parameter has no default, so you can't omit it — but you can pass
@Column(name = ""). The processor treats a blank string the same as "no @Column at all" and
snake-cases the property name instead, with no warning. If a blank-@Column field lands at the
auto-derived name instead of erroring, this is why.
Type resolution
private fun resolveTypeName(type: KSType): String {
val qualifiedName = type.declaration.qualifiedName?.asString() ?: "kotlin.Any"
if (type.arguments.isEmpty()) return qualifiedName
val argNames = type.arguments.joinToString(", ") { arg -> arg.type?.resolve()?.let { resolveTypeName(it) } ?: "*" }
return "$qualifiedName<$argNames>"
}Recurses into generic type arguments — Kotlin has no raw-type escape hatch the way Java does, so a
Set<String> column must get its type argument spliced in, or the generated
KandraColumnRef<kotlin.collections.Set> fails to compile with "one type argument expected." Every
type reference in the generated file is fully qualified, so it never needs an import.
If a type declaration can't be resolved (declaration.qualifiedName is null), the processor falls
back to the literal string "kotlin.Any" for that position — emitting
KandraColumnRef<kotlin.Any>(...) instead of failing. If a generated column ref is unexpectedly
typed Any, this is why; go check whether the source type resolves cleanly (usually a KSP/classpath
issue) rather than assuming the annotation usage itself is wrong.
getAllProperties() — the inheritance gotcha
classDecl.getAllProperties() returns every member property visible on the class, including
ones inherited from a superclass or interface — not just properties declared in the entity's own
primary constructor.
A val with a custom getter and no backing CQL column — a computed/derived property — still gets a
KandraColumnRef generated for it unless explicitly marked @Transient. The processor never
inspects whether a property is computed vs. stored; it has no way to know on its own. Always mark
computed properties @Transient, or the generated *Table object will reference a CQL column that
doesn't exist in the actual DDL.
If an entity extends a base class or implements an interface with properties, those properties are
pulled into the generated *Table object too (unless @Transient) — and the relative ordering of
inherited vs. own-declared properties in the emitted file is whatever getAllProperties() happens to
yield, not guaranteed "own properties first." Don't rely on the generated object's property order for
anything; access is always by name.
What gets no special treatment at all
Tracing KandraProcessor.kt line by line yields a genuinely useful negative result: only three
property-level annotations and one class-level annotation are ever read.
| Annotation | Read by codegen? | Effect |
|---|---|---|
@ScyllaTable (class) | Yes | Triggers processing at all; name/gcGraceSeconds arguments are not read. |
@Transient (property) | Yes | Property excluded entirely. |
@Column (property) | Yes | Supplies the CQL column-name string. |
@LookupIndex (property) | Yes | Sets isLookup = true; tableSuffix/consistency are not read. |
@PartitionKey, @ClusteringKey, @Counter, @CreatedAt, @UpdatedAt, @Version, @Sensitive, @SecondaryIndex | No | Zero effect. A partition-key column generates a plain KandraColumnRef indistinguishable from any other column. |
@Ttl, @SoftDelete, @CacheResult, @ReadConsistency, @WriteConsistency (class-level) | No | None of these class annotations are referenced anywhere in KandraProcessor.kt. |
Whatever enforces "every non-key column in a counter table must be @Counter," or assembles
composite-partition-key CQL, or reads @ClusteringKey(order = ...) to build an ORDER BY clause,
does so by reflecting on the annotations directly at runtime in kandra-core/kandra-runtime — not
by reading anything the *Table object carries, because it carries nothing about this. Every
KandraColumnRef on a generated table object is structurally identical (cqlName: String, isLookup: Boolean) regardless of whether the source property was a partition key, a clustering key,
a counter, or a plain column. UserTable.userId looks exactly like UserTable.displayName at the
type level — codegen has no opinion on which one is "special."
Error conditions — what the processor actually raises
The most important thing to know before relying on this for feedback: KandraProcessor never
calls logger.error(...) or logger.warn(...) anywhere. The only KSPLogger call in the file is
one logger.info(...) on successful generation. There is no compile-time diagnostic for a
@ScyllaTable class with no @PartitionKey, colliding @PartitionKey(index) values, @Counter
mixed with non-@Counter columns, or @ScyllaTable on a non-class declaration — all of that is
either enforced elsewhere (schema registration in kandra-core, at runtime registration, not
compile time) or not enforced at all in this module.
In practice there are exactly two failure modes: a silent fallback (blank @Column, unresolvable
type → Any) with no log at all, or a KSP-level deferral for a symbol that fails validate()
(typically transient during multi-round builds), which is not a Kandra-specific diagnostic either.
A third, rarer path is an uncaught NullPointerException from classDecl.containingFile!! if
@ScyllaTable somehow lands on a binary-only declaration with no attached source — a raw KSP/Gradle
crash, not a clean message. If you're debugging why a generated *Table looks wrong, you have to
read the generated file by hand and cross-check it against the annotations you actually wrote —
nothing in this module will point you at the mistake.
Full API reference
kandra-codegen on Dokka →
Where this shows up elsewhere on this site
/battle-scars/codegen-collection-types— the real bug behind whyresolveTypeNamehas to recurse into generic type arguments instead of emitting a rawSet/Map/Listtype./getting-startedand/tutorial/03-entities— the generated*Tableobject in context, used from a real query.