Kandra

kandra-codegen

kandra-codegen is a single KSP SymbolProcessorio.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:

build.gradle.kts
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.

No classKind check

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

Entity
Generated UserTable.kt

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

ThingRule
Generated object name"${className}Table", no configurability — UserUserTable.
Generated property nameIdentical 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('_')
No acronym handling — the same gotcha lives in kandra-core

This is a per-character regex, not word-boundary-aware: every individual uppercase letter gets its own _ + lowercase. userIduser_id is fine, but userURLuser_u_r_l, and parseHTMLparse_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(name = "") silently falls back to auto-derivation

@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.

Unresolvable types silently degrade to Any, not a build error

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.

Computed properties need @Transient explicitly

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.

Inherited properties come along too, in undefined order

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.

AnnotationRead by codegen?Effect
@ScyllaTable (class)YesTriggers processing at all; name/gcGraceSeconds arguments are not read.
@Transient (property)YesProperty excluded entirely.
@Column (property)YesSupplies the CQL column-name string.
@LookupIndex (property)YesSets isLookup = true; tableSuffix/consistency are not read.
@PartitionKey, @ClusteringKey, @Counter, @CreatedAt, @UpdatedAt, @Version, @Sensitive, @SecondaryIndexNoZero effect. A partition-key column generates a plain KandraColumnRef indistinguishable from any other column.
@Ttl, @SoftDelete, @CacheResult, @ReadConsistency, @WriteConsistency (class-level)NoNone of these class annotations are referenced anywhere in KandraProcessor.kt.
Composite keys, clustering order, and counter columns carry zero codegen metadata

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.

If your entity's annotations are wrong, codegen will not tell you

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