From ea71cb93996e8ad1f9fd71061f295f33b60c4f5a Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:51:51 -0400 Subject: [PATCH 1/2] Stamp a schema by chaining, not by counting arguments MetamodelVersion.stamping(dictionary) starts an immutable MetamodelStamping that names each input as it is set and finishes as either a stamp or a declaration. Every step copies, so a partly built stamping can be held and finished more than once. The from overloads stay and the chain delegates to them, so both spellings produce the same stamp and no compiled caller moves. Java gets the same chain, tested, with the constructor arities pinned. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com> --- CHANGELOG.md | 12 ++ .../dice/metamodel/GovernedTypeSelector.kt | 7 + .../dice/metamodel/MetamodelStamping.kt | 126 ++++++++++++++++ .../dice/metamodel/MetamodelVersion.kt | 19 +++ .../metamodel/MetamodelJavaCompatTest.java | 55 +++++++ .../dice/metamodel/MetamodelStampingTest.kt | 140 ++++++++++++++++++ docs/design/metamodel-versioning.md | 36 +++++ 7 files changed, 395 insertions(+) create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelStamping.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelStampingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c05ab3..9845ae31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ and the consumer PRs that deliver it). ### Added +- 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/GovernedTypeSelector.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt index 1c77ee48..27c886d9 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,13 @@ import org.jetbrains.annotations.ApiStatus * val version = MetamodelVersion.from(dataDictionary, GovernedTypeSelector { it.name in governed }) * ``` * + * A set of names is common enough that the chained form takes one directly, and builds the same + * selector: + * + * ```kotlin + * val version = 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/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..2894e235 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,25 @@ class MetamodelVersion @JvmOverloads constructor( } } + /** + * Start a stamping you finish by chaining, which is the readable way in when there is + * more than a dictionary to say: + * + * ```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/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..24557620 100644 --- a/docs/design/metamodel-versioning.md +++ b/docs/design/metamodel-versioning.md @@ -143,6 +143,42 @@ 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. + 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 From 72d46cf45c4cbf2579ec690bd51f487c7ea40951 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:04:56 -0400 Subject: [PATCH 2/2] Stamp a schema in a block MetamodelVersion(dictionary) { } and DeclaredSchema(dictionary) { } take a receiver block, so the Kotlin call site is statements with no prefix and no chain. The entry is invoke on the companion, the shape embabel-agent uses for its configured constructors, and is JvmSynthetic so Java sees only the chain. An aliases { } block declares renames inline and accumulates names, which is what a rename chain needs. The builders are scoped to the block by DslMarker and internal constructors; what comes back is immutable. Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com> --- CHANGELOG.md | 13 ++ .../dice/metamodel/DeclaredSchemaSource.kt | 27 +++ .../dice/metamodel/GovernedTypeSelector.kt | 8 +- .../embabel/dice/metamodel/MetamodelDsl.kt | 178 ++++++++++++++++++ .../dice/metamodel/MetamodelVersion.kt | 34 +++- .../dice/metamodel/MetamodelDslTest.kt | 174 +++++++++++++++++ docs/design/metamodel-versioning.md | 40 ++++ 7 files changed, 470 insertions(+), 4 deletions(-) create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDsl.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDslTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9845ae31..724ff97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ 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 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 27c886d9..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,13 +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 chained form takes one directly, and builds the same + * A set of names is common enough that the block form takes them directly, and builds the same * selector: * * ```kotlin - * val version = MetamodelVersion.stamping(dataDictionary).governedBy(governed).stamp() + * 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/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt index 2894e235..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 @@ -403,8 +403,38 @@ class MetamodelVersion @JvmOverloads constructor( } /** - * Start a stamping you finish by chaining, which is the readable way in when there is - * more than a dictionary to say: + * 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() 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/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md index 24557620..e0f41e12 100644 --- a/docs/design/metamodel-versioning.md +++ b/docs/design/metamodel-versioning.md @@ -179,6 +179,46 @@ MetamodelVersion version = MetamodelVersion.stamping(dataDictionary) 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