Skip to content

Creating Table Mappings

Arda’s system uses Exposed as its relational mapping layer. Using this layer, Arda’s system
defines Bitemporal Records that to map payload to records in the Database. Furthermore, it defines the concept of Universe as a bitemporal repository
with additional capabilities for scoping and validation of CRUD and Query operations.

Basic Entity Mappings

Basic Entity Mappings are those that only use natively supported Column Types in Exposed

This document describes how to create the data classes, components, tables, and record classes needed to set up a table that reflects a data type with nested data classes at multiple levels. It also explains how to build a DataAuthorityUniverse that persists a pair of data types representing Payload and Metadata.

Defining a Payload for Entity Persistence

To map a Kotlin class to such these capabilities, a class must:

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

The pattern to define a Class as a payload for persistence mapping is:

@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 if validation is O.K. Failure with exception for the reason otherwise
    }
  }
}

Properties can be any of the supported column types from Exposed including JSON values.

Entities in Arda’s system also require defining associated Metadata that will control non-domain aspects of their behavior. Typically
these could be things like the Scope in which an entity needs to be interpreted, lifecycle information (e.g. Draft, Published, etc…) or
other similar characteristics. An EntityPayload metadata must extend from cards.arda.common.lib.persistence.types.PayloadMetadata or one of its
subtypes. For example

@Serializable
data class SampleEntityMetadata(
  @Serializable(with = UUIDSerializer::class)
  override val tenantId: UUID
) : ScopedMetadata

Defining the Persistence Mapping for and EntityPayload

Persistence is defined by:

  • A Table Configuration that defines the Table Name (and in the future other potential mapping characteristics)
  • A Table definition that declares the column mappings for each defined property
  • A Record Type that must extends BitemporalRecord with properties provided by the table mapped columns and the logic to fill
    in the payload and metadata associated with the entity.
  • One or multiple UniversalCondition to configure the set of entities that a particular Universe manages.
  • A Universe, extending from AbstractUniverse or one of its subtypes.

Table Configuration and Table Pattern

object SampleTableConfiguration: TableConfiguration {
  override val name = "SAMPLE"
}


object SAMPLE_TABLE : ScopedTable(ItemTableConfiguration) {
  val sampleProperty = varchar("SAMPLE_PROPERTY", 255)
    <<...>>
}

Record Type Pattern

class SampleRecord(rId: EntityID<UUID>): ScopedRecord<SamplePayload, SampleMetadata, SAMPLE_TABLE, SampleRecord>(rId,
  ITEM_TABLE) {
  var sampleProperty by SAMPLE_TABLE.sampleProperty
    <<...>>

  companion object : Persistence<SamplePayload, SampleMetadata, SAMPLE_TABLE, SampleRecord>(
    SAMPLE_TABLE,
    SampleRecord::class.java,
    { SampleRecord(it) },
    {
      sampleProperty = payload.sampleProperty
        <<...>>
    }
  )
  override fun fillPayload() {
    payload = Item.Entity(
      eId = eId,
      sampleProperty = sampleProperty
    )
  }

  override fun fillMetadata() {
    metadata = SampleMetadata(tenantId)
  }
}

Sample Universal Condition and Universe

fun validatorFor(u: SampleUniverse): ScopingValidator<SamplePayload, SampleMetadata> {
  return object : ScopingValidator<SamplePayload, SampleMetadata>() {
  }
}

object SampleUniversalCondition: ScopedUniversalCondition()

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)
}

Extensions for Composite Mappings

Composite Mappings are those whose properties can be defined as other Data Classes that are treated as “pure values” (i.e. they don’t have an identity of their own)

Arda’s common libraries support Composite mappings following a pattern to define the data classes to be used as value properties:

Defining and Mapping Property Types

@Schema(
  description = "Classification for an Item with two levels, type and subtype. They will be " +
          "migrated to represent actual category entities in the future"
)
@Serializable
sealed interface PropertyType {
    <<properties>>

  @Serializable
  data class Value(
      <<override properties>>
  ) : PropertyType
}

The mapping of the property type is defined as:

inline fun <TBL : EntityTable, R: RowRecord<TBL, R>, reified DOMAIN : PropertyType?>
        TBL.propertyTypeComponent(name: String, parent: Component<TBL, R, *>): PropertyTypeComponent<TBL, R, DOMAIN> {
  return object : PropertyTypeComponent<TBL, R, DOMAIN>(name, parent, this@propertyTypeComponent) {
    override val isNullable = (null is DOMAIN)
  }
}

abstract class PropertyTypeComponent<TBL: EntityTable, R: RowRecord<TBL, R>, DOMAIN: PropertyType?>(
  override val cmpName: String,
  override val parent: Component<TBL, R, *>,
  override val tbl: TBL
) : Component.Sub<TBL, R, DOMAIN> {
  override val prefix = prefixGetter()
  val childProperty =   <<column or sub-component definition>>
    <<other property definitions>>

  @ExperimentalContracts
  @Suppress("UNCHECKED_CAST")
  override fun getComponentValue(row: R): Result<DOMAIN?> =
    with(row) {
      val childProperty = childProperty.getValue(row, ItemClassification::type)
      when (build.requiring(childProperty,   <<other required properties  >>)) {
        true -> Result.success(
          ItemClassification.Value(
            childProperty = childProperty,
              <<initialize other properties from row>>
          ) as DOMAIN
        )

        false -> Result.success(null)
        null -> Result.failure(
          AppError.IncompatibleState("ItemClassification: {\n  type: ${type}\n} must be all null or none null")
        )
      }
    }

  override fun setComponentValue(r: R, valueObject: DOMAIN) = with(r) {
      << set mapped properties from valueObject >>
  }
}

Notes

  1. All property definitions should be defined as nullable() unless:
    1. The usage of the PropertyType will always be not-nullable itself, which is not reasonable to guarantee for non-private types.
    2. The atomic property itself is non-nullable
  2. Local Nullability when the PropertyType is non-null is defined byt the build.requiring(type) condition which can take up to 22 arguments.

Using Property Type Definitions in Entity Mappings

With these definitions, the EntityPayload is defined as:

@Serializable
sealed interface PayloadClass : EntityPayload {
  @Serializable(with = UUIDSerializer::class)
  override val eId: EntityId

  val compositeProperty: PropertyType.Value?


  data class Entity(
    @Serializable(with = UUIDSerializer::class)
    override val eId: EntityId,
    override val compositeProperty: Property.Value? = null
      <<other properties>>
  ) : PayloadClass {
    override fun validate(ctx: ApplicationContext, mutation: Mutation): Result<Unit> {
      // Return Success if validation is O.K. Failure with exception for the reason otherwise
    }
  }
}

And only the Table Definition changes:

Table Definition

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

Note that if the component itself is nullable or not is indicated by the generic parameters:

For nullable component:

compositePropertyComponent<SAMPLE_TABLE, SampleRecord, CompositePropertyType.Value?>("composite_property", root)

For Non-nullable component:

compositePropertyComponent<SAMPLE_TABLE, SampleRecord, CompositePropertyType.Value>("composite_property", root)

Building a DataAuthorityUniverse

Finally, build a DataAuthorityUniverse to manage the persistence of your data. The DataAuthorityUniverse ties together the data classes, components, tables, and record classes.

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(
      ctx: ApplicationContext,
      payload: TestEntity,
      metadata: TestMetadata,
      asOf: TimeCoordinates,
      author: String
    ): DBIO<Unit> = {
      Result.success(Unit)
    }

    override suspend fun validateForUpdate(
      ctx: ApplicationContext,
      payload: TestEntity,
      metadata: TestMetadata,
      asOf: TimeCoordinates,
      author: String,
      idempotency: Idempotency,
      previous: BitemporalEntity<TestEntity, TestMetadata>?
    ): DBIO<Unit> = {
      Result.success(Unit)
    }

    override suspend fun validateForDelete(
      ctx: ApplicationContext,
      candidate: BitemporalEntity<TestEntity, TestMetadata>,
      metadata: TestMetadata,
      asOf: TimeCoordinates,
      author: String
    ): DBIO<Unit> = {
      Result.success(Unit)
    }
  }
}

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


Copyright: © Arda Systems 2025, All rights reserved

Comments