Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ and the consumer PRs that deliver it).

### Added

- A Kotlin DSL for stamping in `dice-metamodel`. **EXPERIMENTAL** (shape may change
before 1.0): `MetamodelVersion(dictionary) { }` and `DeclaredSchema(dictionary) { }`
take a receiver block, so the call site is a sequence of statements with no receiver
prefix and no chain. `governedBy` takes a selector, a set of names, or names written
out; `aliases { }` declares renames inline through `type(...)` and `property(...)`,
accumulating names so a rename chain keeps every older one. `@DslMarker` scopes the
builders, whose constructors are internal and whose lifetime is the block. What comes
back is immutable. The entries are `invoke` on each companion, the shape
`embabel-agent` uses for its configured constructors, and are `@JvmSynthetic`, so
Java sees only the chain.
**Compatibility: additive.** Two companion entries and two builders. The block
reaches the same stamp as the chain and as `MetamodelVersion.from`, asserted directly.

- Chained stamping in `dice-metamodel`. **EXPERIMENTAL** (shape may change before
1.0): `MetamodelStamping`, reached through `MetamodelVersion.stamping(dictionary)`,
carries the dictionary, the governance selector and the aliases, and finishes as
either a stamp (`stamp()`) or a declaration (`declare()`). `governedBy` also takes
a set of type names directly. Every step returns a new stamping, so a partly built
one can be held and finished more than once.
**Compatibility: additive.** A new type and one new factory. The three
`MetamodelVersion.from` overloads and both `DeclaredSchema.from` overloads are
untouched, and the chain delegates to them, so both spellings produce the same
stamp. `MetamodelStamping` carries `@JvmOverloads` on its constructor, so Java
keeps the one, two and three-argument arities.

- `dice-metamodel` module, first slice of schema versioning: `MetamodelVersion`
content-hash stamping with per-type governance selection, the declared-schema
opt-in contract, and the `MetamodelVersionStore` contract. Pure JVM.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,33 @@ class DeclaredSchema(
fun ownLabelOf(entityTypeName: String): String =
entityTypeName.substringAfterLast('.').ifEmpty { entityTypeName }

/**
* Declare the governed part of [dataDictionary], saying inside [block] whatever should
* differ from the defaults:
*
* ```kotlin
* val declared = DeclaredSchema(dictionary) {
* governedBy("Person", "Company")
* }
* ```
*
* Both halves of the declaration, the stamp and the relationship names, come from the one
* block, so they cannot disagree about what is governed. An empty block declares the whole
* dictionary.
*
* Hidden from Java, which has [MetamodelVersion.stamping] finishing with `declare()`.
*
* @param dataDictionary The schema to declare.
* @param block Applied to the builder before the declaration is built.
* @return The declaration.
* @throws IllegalArgumentException when the declared aliases don't fit the governed types.
*/
@JvmSynthetic
operator fun invoke(
dataDictionary: DataDictionary,
block: MetamodelStampingBuilder.() -> Unit = {},
): DeclaredSchema = stampingOf(dataDictionary, block).declare()

/**
* Declare the governed part of [dataDictionary]: stamp it and carry through the bare
* relationship names the same governed types declare.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ import org.jetbrains.annotations.ApiStatus
* val version = MetamodelVersion.from(dataDictionary, GovernedTypeSelector { it.name in governed })
* ```
*
* A set of names is common enough that the block form takes them directly, and builds the same
* selector:
*
* ```kotlin
* val version = MetamodelVersion(dataDictionary) {
* governedBy("Person", "Company")
* }
* ```
*
* The chained form does the same for Java: `MetamodelVersion.stamping(dataDictionary).governedBy(governed).stamp()`.
*
* Selecting a subset changes which types the stamp covers, and leaves the encoding alone. Adding an
* ungoverned type to the dictionary leaves the content hash as it was, while touching a governed
* one changes it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.embabel.dice.metamodel

import com.embabel.agent.core.DataDictionary
import org.jetbrains.annotations.ApiStatus

/**
* Scopes the metamodel builders so a nested block can't reach the enclosing one by accident. Writing
* `governedBy(...)` inside an `aliases { }` block is a compile error, not a silent surprise.
*/
@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class MetamodelDsl

/** Runs [block] against a fresh builder and hands back the immutable stamping it collected. */
internal fun stampingOf(
dataDictionary: DataDictionary,
block: MetamodelStampingBuilder.() -> Unit,
): MetamodelStamping = MetamodelStampingBuilder(dataDictionary).apply(block).build()

/**
* Collects what to stamp while a `MetamodelVersion(dictionary) { }` or
* `DeclaredSchema(dictionary) { }` block runs.
*
* ```kotlin
* val version = MetamodelVersion(dictionary) {
* governedBy("Person", "Company")
* aliases {
* type("Organisation", formerly = setOf("Company"))
* property("Person", "emailAddress", formerly = setOf("email"))
* }
* }
* ```
*
* The block is a plain sequence of statements against this builder, so nothing needs a receiver
* prefix or a chain. An empty block governs everything and declares no former names, which is the
* same stamp [MetamodelVersion.from] gives.
*
* Mutable, and scoped to the block that configures it: it is built into an immutable
* [MetamodelStamping] the moment the block returns, and changing it afterwards affects nothing. The
* constructor is internal, so the only way to hold one is inside a block.
*
* Later calls win. `governedBy` twice governs whatever the second call said.
*
* Java callers want [MetamodelVersion.stamping], which takes the same three inputs as a chain.
*
* EXPERIMENTAL. The shape may still change before 1.0.
*/
@MetamodelDsl
@ApiStatus.Experimental
class MetamodelStampingBuilder internal constructor(
private val dataDictionary: DataDictionary,
) {

private var governedTypes: GovernedTypeSelector = GovernedTypeSelector.ALL

private var aliases: SchemaAliases = SchemaAliases.NONE

/**
* Govern the types [selector] picks out.
*
* @param selector The predicate deciding which types the stamp covers.
*/
fun governedBy(selector: GovernedTypeSelector) {
governedTypes = selector
}

/**
* Govern the types named in [typeNames], matched against `DomainType.name`. A name no type
* carries governs nothing and is not an error.
*
* @param typeNames The names of the types to govern.
*/
fun governedBy(typeNames: Set<String>) {
governedTypes = GovernedTypeSelector { it.name in typeNames }
}

/**
* Govern the types named, for the common case of writing them out at the call site.
*
* @param typeNames The names of the types to govern.
*/
fun governedBy(vararg typeNames: String) {
governedBy(typeNames.toSet())
}

/**
* Carry former names already held as a [SchemaAliases].
*
* @param aliases Former names for the schema's types and properties.
*/
fun aliases(aliases: SchemaAliases) {
this.aliases = aliases
}

/**
* Declare former names inline:
*
* ```kotlin
* aliases {
* type("Organisation", formerly = setOf("Company"))
* property("Person", "emailAddress", formerly = setOf("email"))
* }
* ```
*
* Replaces whatever aliases were set before it, so a block and a [SchemaAliases] don't merge.
*
* @param block Applied to the alias builder.
*/
fun aliases(block: SchemaAliasesBuilder.() -> Unit) {
aliases = SchemaAliasesBuilder().apply(block).build()
}

internal fun build(): MetamodelStamping =
MetamodelStamping(dataDictionary, governedTypes, aliases)
}

/**
* Collects declared renames while an `aliases { }` block runs.
*
* Names accumulate per type and per property, so declaring the same one twice adds to it rather
* than replacing it. A type renamed `A` to `B` to `C` therefore declares both older names, which is
* what lets a comparison across non-adjacent stamps still pair them.
*
* EXPERIMENTAL. The shape may still change before 1.0.
*/
@MetamodelDsl
@ApiStatus.Experimental
class SchemaAliasesBuilder internal constructor() {

private val typeAliases = mutableMapOf<String, MutableSet<String>>()

private val propertyAliases = mutableMapOf<String, MutableMap<String, MutableSet<String>>>()

/**
* Declare the names an entity type used to go by.
*
* @param typeName The type's current name.
* @param formerly The names it used to have. Exact and case-sensitive.
*/
fun type(typeName: String, formerly: Set<String>) {
typeAliases.getOrPut(typeName) { mutableSetOf() } += formerly
}

/**
* Declare the names a property on a type used to go by.
*
* @param typeName The current name of the type holding the property.
* @param propertyName The property's current name.
* @param formerly The names it used to have. Exact and case-sensitive.
*/
fun property(typeName: String, propertyName: String, formerly: Set<String>) {
propertyAliases
.getOrPut(typeName) { mutableMapOf() }
.getOrPut(propertyName) { mutableSetOf() } += formerly
}

internal fun build(): SchemaAliases = SchemaAliases(
typeAliases = typeAliases.mapValues { (_, names) -> names.toSet() },
propertyAliases = propertyAliases.mapValues { (_, byProperty) ->
byProperty.mapValues { (_, names) -> names.toSet() }
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.embabel.dice.metamodel

import com.embabel.agent.core.DataDictionary
import org.jetbrains.annotations.ApiStatus

/**
* What to stamp, and how, built up a step at a time.
*
* The three inputs a stamp needs are the same three a declaration needs: a dictionary, which of its
* types are governed, and the former names those types go by. This carries all three and hands them
* to whichever of the two you finish with, [stamp] or [declare]:
*
* ```kotlin
* val version = MetamodelVersion.stamping(dictionary)
* .governedBy(setOf("Person", "Company"))
* .withAliases(aliases)
* .stamp()
* ```
*
* Reads the same from Java, because every step is an ordinary method taking one argument:
*
* ```java
* MetamodelVersion version = MetamodelVersion.stamping(dictionary)
* .governedBy(Set.of("Person", "Company"))
* .withAliases(aliases)
* .stamp();
* ```
*
* Every step returns a new instance and leaves the one it was called on alone, so a half-built
* stamping is safe to hold onto and finish more than once. That is what makes a shared base worth
* keeping in a field:
*
* ```kotlin
* val governed = MetamodelVersion.stamping(dictionary).governedBy(governedTypes)
* val plain = governed.stamp()
* val renamed = governed.withAliases(aliases).stamp()
* ```
*
* Nothing is validated here. The rules about aliases live where the stamp is built, so a bad
* declaration fails at [stamp] or [declare] with the same message it would have given the
* three-argument factory.
*
* Equality is the data-class default, which compares the three fields. [governedTypes] is usually a
* lambda, and lambdas compare by identity, so two stampings built the same way from two separate
* lambdas are not equal. Compare what they produce when that matters.
*
* EXPERIMENTAL. The shape may still change before 1.0.
*
* @property dataDictionary The schema to stamp or declare.
* @property governedTypes Which of its types are under governance.
* @property aliases Former names for those types and their properties.
*/
@ApiStatus.Experimental
data class MetamodelStamping @JvmOverloads constructor(
val dataDictionary: DataDictionary,
val governedTypes: GovernedTypeSelector = GovernedTypeSelector.ALL,
val aliases: SchemaAliases = SchemaAliases.NONE,
) {

/**
* Govern the types [selector] picks out.
*
* @param selector The predicate deciding which types the stamp covers.
* @return A new stamping governed by [selector].
*/
fun governedBy(selector: GovernedTypeSelector): MetamodelStamping =
copy(governedTypes = selector)

/**
* Govern the types named in [typeNames], which is the common case: a set of names the
* application already holds.
*
* The names are matched against `DomainType.name`. A name no type in the dictionary carries
* governs nothing and is not an error, the same as any other selector that matches nothing.
*
* @param typeNames The names of the types to govern.
* @return A new stamping governed by those names.
*/
fun governedBy(typeNames: Set<String>): MetamodelStamping =
copy(governedTypes = GovernedTypeSelector { it.name in typeNames })

/**
* Carry the former names [aliases] declares.
*
* @param aliases Former names for the schema's types and properties.
* @return A new stamping carrying those aliases.
*/
fun withAliases(aliases: SchemaAliases): MetamodelStamping =
copy(aliases = aliases)

/**
* @return The version stamp, the same one [MetamodelVersion.from] builds from these three
* arguments.
* @throws IllegalArgumentException when the aliases are not declarable against the governed
* types.
*/
fun stamp(): MetamodelVersion =
MetamodelVersion.from(dataDictionary, governedTypes, aliases)

/**
* Finish as a declaration, which is the stamp plus the relationship names the same governed
* types declare.
*
* @return The declaration, the same one [DeclaredSchema.from] builds from these three
* arguments.
* @throws IllegalArgumentException when the aliases are not declarable against the governed
* types.
*/
fun declare(): DeclaredSchema =
DeclaredSchema.from(dataDictionary, governedTypes, aliases)
}
Loading
Loading