diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c05ab3..724ff97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt index d576fb43..e68dfa4f 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt @@ -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. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt index 1c77ee48..96a84d4c 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt @@ -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. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDsl.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDsl.kt new file mode 100644 index 00000000..1293887b --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDsl.kt @@ -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) { + 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>() + + private val propertyAliases = mutableMapOf>>() + + /** + * 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) { + 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) { + 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() } + }, + ) +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelStamping.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelStamping.kt new file mode 100644 index 00000000..2984219a --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelStamping.kt @@ -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): 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) +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt index eaaec421..7f5b8cbe 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt @@ -402,6 +402,55 @@ class MetamodelVersion @JvmOverloads constructor( } } + /** + * Stamp [dataDictionary], saying inside [block] whatever should differ from the defaults: + * + * ```kotlin + * val version = MetamodelVersion(dictionary) { + * governedBy("Person", "Company") + * aliases { + * type("Organisation", formerly = setOf("Company")) + * } + * } + * ``` + * + * This is the Kotlin entry. The block is a sequence of statements against a + * [MetamodelStampingBuilder], so nothing needs a prefix or a chain, and an empty block is + * the whole-schema stamp [from] gives. What comes back is immutable. + * + * Hidden from Java, which has [stamping]: a receiver lambda from Java means returning + * `Unit.INSTANCE` by hand, so Java gets the chain and Kotlin gets the block. + * + * @param dataDictionary The schema to stamp. + * @param block Applied to the builder before the stamp is built. + * @return An immutable version stamp. + * @throws IllegalArgumentException when the declared aliases don't fit the governed types. + */ + @JvmSynthetic + operator fun invoke( + dataDictionary: DataDictionary, + block: MetamodelStampingBuilder.() -> Unit = {}, + ): MetamodelVersion = stampingOf(dataDictionary, block).stamp() + + /** + * Start a stamping you finish by chaining, which is the Java entry and reads the same + * from Kotlin when a chain suits the call site better than a block: + * + * ```kotlin + * MetamodelVersion.stamping(dictionary).governedBy(governed).withAliases(aliases).stamp() + * ``` + * + * The [from] overloads below stay: they are the short forms, and this is the long one that + * names its arguments as it goes. See [MetamodelStamping] for what each step does. + * + * @param dataDictionary The schema to stamp. + * @return A stamping over the whole dictionary, governing everything and declaring no + * former names until told otherwise. + */ + @JvmStatic + fun stamping(dataDictionary: DataDictionary): MetamodelStamping = + MetamodelStamping(dataDictionary) + /** * Create a [MetamodelVersion] stamp covering every type in [dataDictionary], which is the * right stamp for a domain that is closed-world throughout. diff --git a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java index a2ac7a2d..f070192b 100644 --- a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java +++ b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -179,6 +180,60 @@ void theShippedKotlinDefaultSyntheticKeepsItsDescriptor() throws Exception { Object.class)); } + @Test + @DisplayName("the fluent stamping chain reads the same from Java") + void theFluentStampingChainReadsTheSameFromJava() { + SchemaAliases aliases = new SchemaAliases( + Map.of(), Map.of("Person", Map.of("age", Set.of("years")))); + + MetamodelVersion chained = MetamodelVersion.stamping(goldenSchema()) + .governedBy(Set.of("Person")) + .withAliases(aliases) + .stamp(); + + assertEquals(List.of("Person"), chained.getEntityTypeNames()); + assertEquals( + MetamodelVersion.from(goldenSchema(), type -> type.getName().equals("Person"), aliases), + chained); + } + + @Test + @DisplayName("a stamping finishes as a declaration from Java too") + void aStampingFinishesAsADeclarationFromJava() { + DeclaredSchema declared = MetamodelVersion.stamping(goldenSchema()) + .governedBy(Set.of("Person")) + .declare(); + + assertEquals(List.of("Person"), declared.getVersion().getEntityTypeNames()); + assertEquals(DeclaredSchema.from(goldenSchema(), type -> type.getName().equals("Person")), declared); + } + + @Test + @DisplayName("a stamping step hands back a new value and leaves the old one alone") + void aStampingStepHandsBackANewValueAndLeavesTheOldOneAlone() { + MetamodelStamping base = MetamodelVersion.stamping(goldenSchema()); + + MetamodelStamping narrowed = base.governedBy(Set.of("Person")); + + assertNotSame(base, narrowed); + assertEquals(List.of("Company", "Person"), base.stamp().getEntityTypeNames()); + assertEquals(List.of("Person"), narrowed.stamp().getEntityTypeNames()); + } + + @Test + @DisplayName("the stamping constructor keeps a one-argument arity for Java") + void theStampingConstructorKeepsAOneArgumentArityForJava() throws Exception { + // @JvmOverloads on the data class constructor is what generates the shorter arities. A + // Java caller that builds a stamping directly links against this one, so losing it is a + // NoSuchMethodError rather than a compile error here. + assertNotNull(MetamodelStamping.class.getDeclaredConstructor(DataDictionary.class)); + + MetamodelStamping stamping = new MetamodelStamping(goldenSchema()); + + assertEquals(GovernedTypeSelector.ALL, stamping.getGovernedTypes()); + assertEquals(SchemaAliases.NONE, stamping.getAliases()); + } + @Test @DisplayName("the stamping factories take no defaulted parameters") void theStampingFactoriesTakeNoDefaultedParameters() throws Exception { diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDslTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDslTest.kt new file mode 100644 index 00000000..348c4728 --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDslTest.kt @@ -0,0 +1,174 @@ +/* + * 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 com.embabel.agent.core.DomainTypePropertyDefinition +import com.embabel.agent.core.DynamicType +import com.embabel.agent.core.ValuePropertyDefinition +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class MetamodelDslTest { + + private val company = DynamicType("Company") + private val person = DynamicType( + name = "Person", + ownProperties = listOf( + DomainTypePropertyDefinition("worksAt", company), + ValuePropertyDefinition("emailAddress", "string"), + ), + ) + private val sighting = DynamicType( + name = "Sighting", + ownProperties = listOf(DomainTypePropertyDefinition("about", person)), + ) + + private val governed = setOf("Person", "Company") + + private fun dictionary() = DataDictionary.fromDomainTypes("app", listOf(person, company, sighting)) + + @Test + fun `an empty block stamps the whole dictionary`() { + assertEquals(MetamodelVersion.from(dictionary()), MetamodelVersion(dictionary()) {}) + assertEquals(MetamodelVersion.from(dictionary()), MetamodelVersion(dictionary())) + } + + @Test + fun `governing by name lands on the same stamp as the selector form`() { + val fromDsl = MetamodelVersion(dictionary()) { + governedBy("Person", "Company") + } + + assertEquals(MetamodelVersion.from(dictionary(), GovernedTypeSelector { it.name in governed }), fromDsl) + assertEquals(listOf("Company", "Person"), fromDsl.entityTypeNames) + } + + @Test + fun `a set and a selector reach the same stamp as the names`() { + val bySet = MetamodelVersion(dictionary()) { governedBy(governed) } + val bySelector = MetamodelVersion(dictionary()) { governedBy(GovernedTypeSelector { it.name in governed }) } + + assertEquals(bySet, bySelector) + assertEquals(bySet, MetamodelVersion(dictionary()) { governedBy("Person", "Company") }) + } + + @Test + fun `aliases declared in the block reach the stamp`() { + val fromDsl = MetamodelVersion(dictionary()) { + governedBy(governed) + aliases { + type("Person", formerly = setOf("Human")) + property("Person", "emailAddress", formerly = setOf("email")) + } + } + + val expected = SchemaAliases( + typeAliases = mapOf("Person" to setOf("Human")), + propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))), + ) + assertEquals( + MetamodelVersion.from(dictionary(), GovernedTypeSelector { it.name in governed }, expected), + fromDsl, + ) + assertEquals(mapOf("Person" to setOf("Human")), fromDsl.entityTypeAliases) + } + + @Test + fun `declaring the same name twice accumulates, so a rename chain keeps both`() { + val fromDsl = MetamodelVersion(dictionary()) { + governedBy(governed) + aliases { + type("Person", formerly = setOf("Human")) + type("Person", formerly = setOf("Individual")) + } + } + + assertEquals(setOf("Human", "Individual"), fromDsl.entityTypeAliases["Person"]) + } + + @Test + fun `a prebuilt SchemaAliases works the same as the block`() { + val prebuilt = SchemaAliases(typeAliases = mapOf("Person" to setOf("Human"))) + + val fromValue = MetamodelVersion(dictionary()) { + governedBy(governed) + aliases(prebuilt) + } + val fromBlock = MetamodelVersion(dictionary()) { + governedBy(governed) + aliases { type("Person", formerly = setOf("Human")) } + } + + assertEquals(fromValue, fromBlock) + } + + @Test + fun `the last call of a step wins`() { + val fromDsl = MetamodelVersion(dictionary()) { + governedBy("Person") + governedBy(governed) + } + + assertEquals(listOf("Company", "Person"), fromDsl.entityTypeNames) + } + + @Test + fun `declaredSchema takes both halves from the one block`() { + val declared = DeclaredSchema(dictionary()) { + governedBy(governed) + } + + assertEquals(DeclaredSchema.from(dictionary(), GovernedTypeSelector { it.name in governed }), declared) + assertEquals(setOf("worksAt"), declared.relationshipTypeNames) + } + + @Test + fun `the block and the chain reach the same stamp`() { + val aliases = SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email")))) + + val fromDsl = MetamodelVersion(dictionary()) { + governedBy(governed) + aliases(aliases) + } + val fromChain = MetamodelVersion.stamping(dictionary()) + .governedBy(governed) + .withAliases(aliases) + .stamp() + + assertEquals(fromChain, fromDsl) + } + + @Test + fun `aliases that don't fit the governed types fail when the block returns`() { + assertThrows { + MetamodelVersion(dictionary()) { + aliases { type("Person", formerly = setOf("Company")) } + } + } + assertThrows { + DeclaredSchema(dictionary()) { + aliases { type("Person", formerly = setOf("Company")) } + } + } + } + + @Test + fun `a governed name no type carries governs nothing`() { + assertEquals(emptyList(), MetamodelVersion(dictionary()) { governedBy("Absent") }.entityTypeNames) + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelStampingTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelStampingTest.kt new file mode 100644 index 00000000..11b0800d --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelStampingTest.kt @@ -0,0 +1,140 @@ +/* + * 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 com.embabel.agent.core.DomainTypePropertyDefinition +import com.embabel.agent.core.DynamicType +import com.embabel.agent.core.ValuePropertyDefinition +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class MetamodelStampingTest { + + private val company = DynamicType("Company") + private val person = DynamicType( + name = "Person", + ownProperties = listOf( + DomainTypePropertyDefinition("worksAt", company), + ValuePropertyDefinition("emailAddress", "string"), + ), + ) + private val sighting = DynamicType( + name = "Sighting", + ownProperties = listOf(DomainTypePropertyDefinition("about", person)), + ) + + private val governed = setOf("Person", "Company") + + private fun dictionary() = DataDictionary.fromDomainTypes("app", listOf(person, company, sighting)) + + private fun aliases() = SchemaAliases( + propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))), + ) + + @Test + fun `a bare stamping governs everything and declares no former names`() { + val stamping = MetamodelVersion.stamping(dictionary()) + + assertEquals(GovernedTypeSelector.ALL, stamping.governedTypes) + assertEquals(SchemaAliases.NONE, stamping.aliases) + assertEquals(MetamodelVersion.from(dictionary()), stamping.stamp()) + } + + @Test + fun `the chain lands on the same stamp as the three-argument factory`() { + val selector = GovernedTypeSelector { it.name in governed } + + val chained = MetamodelVersion.stamping(dictionary()) + .governedBy(selector) + .withAliases(aliases()) + .stamp() + + assertEquals(MetamodelVersion.from(dictionary(), selector, aliases()), chained) + } + + @Test + fun `governing by name matches on the type's name`() { + val byName = MetamodelVersion.stamping(dictionary()).governedBy(governed).stamp() + + assertEquals(listOf("Company", "Person"), byName.entityTypeNames) + assertEquals( + MetamodelVersion.from(dictionary(), GovernedTypeSelector { it.name in governed }), + byName, + ) + } + + @Test + fun `a name no type carries governs nothing and is not an error`() { + val stamp = MetamodelVersion.stamping(dictionary()).governedBy(setOf("Absent")).stamp() + + assertEquals(emptyList(), stamp.entityTypeNames) + } + + @Test + fun `every step leaves the stamping it was called on alone`() { + val base = MetamodelVersion.stamping(dictionary()).governedBy(governed) + + val withAliases = base.withAliases(aliases()) + + assertEquals(SchemaAliases.NONE, base.aliases) + assertEquals(aliases(), withAliases.aliases) + assertNotEquals(base.stamp().contentHash, withAliases.stamp().contentHash) + } + + @Test + fun `a half-built stamping can be finished more than once`() { + val base = MetamodelVersion.stamping(dictionary()).governedBy(governed) + + assertEquals(base.stamp(), base.stamp()) + assertEquals(MetamodelVersion.from(dictionary(), GovernedTypeSelector { it.name in governed }), base.stamp()) + } + + @Test + fun `the last call of a step wins`() { + val stamping = MetamodelVersion.stamping(dictionary()) + .governedBy(setOf("Person")) + .governedBy(governed) + + assertEquals(listOf("Company", "Person"), stamping.stamp().entityTypeNames) + } + + @Test + fun `declaring lands on the same declaration as the three-argument factory`() { + val selector = GovernedTypeSelector { it.name in governed } + + val chained = MetamodelVersion.stamping(dictionary()) + .governedBy(selector) + .withAliases(aliases()) + .declare() + + assertEquals(DeclaredSchema.from(dictionary(), selector, aliases()), chained) + assertEquals(setOf("worksAt"), chained.relationshipTypeNames) + } + + @Test + fun `a declaration the aliases don't fit fails where the stamp is built, not while chaining`() { + val undeclarable = SchemaAliases(typeAliases = mapOf("Person" to setOf("Company"))) + + // Chaining it is fine. The rule about a type name appearing in another type's alias set + // belongs to the factory, so it fires on the terminal call. + val stamping = MetamodelVersion.stamping(dictionary()).withAliases(undeclarable) + + assertThrows { stamping.stamp() } + assertThrows { stamping.declare() } + } +} diff --git a/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md index 6afd0dc7..e0f41e12 100644 --- a/docs/design/metamodel-versioning.md +++ b/docs/design/metamodel-versioning.md @@ -143,6 +143,82 @@ for a domain that is closed-world throughout. The model is Hibernate's `@Version`: governance is declared per entity, and nothing is versioned by default. +### Saying it in steps + +Three positional arguments read poorly at the call site once all three are given, and the third is +usually `SchemaAliases.NONE`. `MetamodelStamping` carries the same three and names each one as it +is set: + +```kotlin +val version = MetamodelVersion.stamping(dataDictionary) + .governedBy(governed) + .withAliases(aliases) + .stamp() +``` + +Every step returns a new stamping and leaves the one it was called on alone, so a partly built +stamping is a value a caller can keep in a field and finish more than once. Governance by a set of +names is common enough that `governedBy` takes one directly and builds the selector. + +The stamp and the declaration take the same three inputs, so one stamping finishes as either: +`stamp()` for a `MetamodelVersion`, `declare()` for a `DeclaredSchema`. That makes the pairing rule +below structural: both halves come from one set of arguments, so they cannot disagree about what is +governed. + +Nothing is validated while chaining. The alias rules belong to the factories, so a bad declaration +fails on the terminal call with the message the three-argument form gives. + +Every step is a plain method taking one argument, which is what keeps the chain identical from Java: + +```java +MetamodelVersion version = MetamodelVersion.stamping(dataDictionary) + .governedBy(governed) + .withAliases(aliases) + .stamp(); +``` + +The `from` overloads stay. They are the short forms, and the chain is the long one. + +### Saying it as a block + +Kotlin gets a receiver block, so the call site is a sequence of statements with no prefix and no +chain: + +```kotlin +val version = MetamodelVersion(dictionary) { + governedBy("Person", "Company") + aliases { + type("Organisation", formerly = setOf("Company")) + property("Person", "emailAddress", formerly = setOf("email")) + } +} +``` + +The entry is `operator fun invoke` on the companion, which is how `embabel-agent` shapes its own +configured constructors (`ActionContext`, `OperationContext`, `Tool.Definition`) and the same shape +as `Json { }` and `HttpClient { }`. It reads as a constructor with a trailing block, which is what +it is, and it stays on the class. `DeclaredSchema(dictionary) { }` is the same block finishing as a +declaration, which is what keeps the stamp and the relationship names from disagreeing about what +is governed. An empty block is the whole-schema stamp. + +The nested `aliases` block earns its place: `SchemaAliases` is two levels of map, and writing those +literals at a call site is the least readable part of declaring a rename. Names accumulate there +rather than replace, so a type renamed `A` to `B` to `C` declares both older names, which is what a +comparison across non-adjacent stamps needs. + +`@DslMarker` scopes the two builders, so reaching the outer one from inside `aliases { }` is a +compile error. + +The builders are mutable and live only for the block. Their constructors are internal, so one +cannot be obtained outside a block, and what comes back is immutable. A block of statements against +an immutable receiver would discard every call, which is why the mutability sits here and nowhere +else. + +Java keeps the chain. A receiver lambda from Java means returning `Unit.INSTANCE` by hand, so the +block is `@JvmSynthetic` and Java never sees it. Two entries off the one class, one per language: +`MetamodelVersion(dictionary) { }` for Kotlin, `MetamodelVersion.stamping(dictionary)` for Java. A +test asserts they reach the same stamp. + Relationships follow the type that declares them. A governed type's outgoing relationship is part of that type's declared shape, so it stays in the stamp even when it points at an ungoverned type; a relationship declared *by* an ungoverned type is left out entirely. Without that rule, an