Skip to content

Table Mappings

Arda uses Exposed as its relational mapping layer. This document describes how to create the data classes, components, tables, and record classes needed to map a domain payload to a bitemporal table.

Basic entity mappings use only natively supported Exposed Column Types.

A class must:

  1. Extend cards.arda.common.lib.persistence.universe.EntityPayload
  2. Be a Kotlin data class

Pattern:

@Serializable
sealed interface PayloadClass : EntityPayload {
@Serializable(with = UUIDSerializer::class)
override val eId: EntityId
// other properties...
data class Entity(
@Serializable(with = UUIDSerializer::class)
override val eId: EntityId,
// other properties...
) : PayloadClass {
override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
// Return success or failure with AppError
}
}
}

Entity metadata must extend from PayloadMetadata or one of its subtypes:

@Serializable
data class SampleEntityMetadata(
@Serializable(with = UUIDSerializer::class)
override val tenantId: UUID
) : ScopedMetadata
object SampleTableConfiguration: TableConfiguration {
override val name = "SAMPLE"
}
object SAMPLE_TABLE : ScopedTable(SampleTableConfiguration) {
val sampleProperty = varchar("sample_property", 255)
// other columns...
}

Column names must be lowercase snake_case. While Exposed allows uppercase, the project standard is lowercase to prevent surprises in SQL queries and external tools.

class SampleRecord(rId: EntityID<UUID>): ScopedRecord<SamplePayload, SampleMetadata, SAMPLE_TABLE, SampleRecord>(
rId, SAMPLE_TABLE
) {
var sampleProperty by SAMPLE_TABLE.sampleProperty
// other delegates...
companion object : Persistence<SamplePayload, SampleMetadata, SAMPLE_TABLE, SampleRecord>(
SAMPLE_TABLE,
SampleRecord::class.java,
{ SampleRecord(it) },
{
sampleProperty = payload.sampleProperty
// other fill operations...
}
)
override fun fillPayload() {
payload = SamplePayload.Entity(
eId = eId,
sampleProperty = sampleProperty
)
}
override fun fillMetadata() {
metadata = SampleMetadata(tenantId)
}
}
fun validatorFor(u: SampleUniverse): ScopingValidator<SamplePayload, SampleMetadata> {
return object : ScopingValidator<SamplePayload, SampleMetadata>() { }
}
object SampleUniversalCondition: ScopedUniversalCondition()
val sampleQueryConfig = EntityServiceConfiguration.create(SamplePayload::class).also { it.freeze() }
class SampleUniverse :
AbstractScopedUniverse<SamplePayload, SampleMetadata, SAMPLE_TABLE, SampleRecord>(),
LogEnabled by LogProvider(SampleUniverse::class) {
override val persistence = SampleRecord.Companion
override val universalCondition = SampleUniversalCondition
override val validator = validatorFor(this)
override val translator by lazy { sampleQueryConfig.bindToTable(persistence.bt) }
}

The EntityServiceConfiguration introspects the @Serializable payload class and builds a structured locator translator. This enables query locators to use JSON field names (camelCase) in addition to raw column names. See Query DSL: EntityServiceConfiguration.

Composite mappings are those whose properties are defined as other Data Classes treated as “pure values” (without their own identity).

@Serializable
sealed interface PropertyType {
// properties...
@Serializable
data class Value(
// override properties...
) : PropertyType
}

The mapping is defined using a Component abstraction:

abstract class PropertyTypeComponent<TBL, R, DOMAIN: PropertyType?>(
override val cmpName: String,
override val parent: Component<TBL, R, *>,
override val tbl: TBL
) : Component.Sub<TBL, R, DOMAIN> {
// ...
override fun getComponentValue(row: R): Result<DOMAIN?> = with(row) {
when (build.requiring(childProperty, /* other required properties */)) {
true -> Result.success(PropertyType.Value(...) as DOMAIN)
false -> Result.success(null)
null -> Result.failure(AppError.IncompatibleState("must be all null or none null"))
}
}
override fun setComponentValue(r: R, valueObject: DOMAIN) = with(r) {
// set mapped properties from valueObject
}
}

All column definitions within a composite should be nullable() unless:

  1. The PropertyType usage will always be non-nullable (not reasonable to guarantee for non-private types)
  2. The atomic property itself is non-nullable

Local nullability when the PropertyType is non-null is defined by build.requiring(...), which takes up to 22 arguments.

Payload definition:

data class Entity(
override val eId: EntityId,
override val compositeProperty: PropertyType.Value? = null,
// other properties...
) : PayloadClass {
override fun validate(...): Result<Unit> { ... }
}

Table definition:

object SAMPLE_TABLE : ScopedTable(SampleTableConfiguration) {
private val root = Component.Root<SAMPLE_TABLE, SampleRecord>(this)
val compositeProperty = compositePropertyComponent<SAMPLE_TABLE, SampleRecord, CompositePropertyType.Value?>(
"composite_property", root
)
}

For nullable composite: CompositePropertyType.Value? For non-nullable composite: CompositePropertyType.Value

Final step — build the universe:

class ComponentsTestUniverse : AbstractUniverse<TestEntity, TestMetadata, TEST_TABLE, TestRow>(),
LogEnabled by LogProvider(ComponentsTestUniverse::class) {
override val persistence = TestRow.Companion
override val universalCondition = ScopedUniversalCondition()
override val validator = object : Validator<TestEntity, TestMetadata> {
override suspend fun validateForCreate(...): DBIO<Unit> = { Result.success(Unit) }
override suspend fun validateForUpdate(...): DBIO<Unit> = { Result.success(Unit) }
override suspend fun validateForDelete(...): DBIO<Unit> = { Result.success(Unit) }
}
}

This universe can now be used to perform CRUD operations on the collection of entities it manages.