Skip to content

Commit 2c02674

Browse files
committed
Merge metadata-ktx — Kotlin facade over Java MetaObjects + FR-004 render
New module under server/java/metadata-ktx/ — thin idiomatic-Kotlin facade over the Java engine, mirrors omdb-ktx shape. Extension functions only; NO wrapper class. Wraps where Kotlin interop has real friction (Optional→T?, platform-nullable pinning, typed nullable enums, reified generics, top-level loader factory fns matching cross-port convention, render { ... } property-bag builder for FR-004 RenderRequest). Does NOT wrap custom subclasses, mutators, or the registry builder — Kotlin uses those Java APIs directly. 7 source files (Loader/MetaObjects/Fields/Attrs/Identity/Relationships/Render) + 8 test files (33 tests). Deps: metaobjects-metadata + metaobjects-render (no omdb — metadata-ktx sits below omdb-ktx in the stack). Unblocks the planned codegen-kotlin module — consumers writing Kotlin apps that load metadata + render LLM prompts via template.prompt can now use idiomatic Kotlin: loadResources(...), .promptTemplateOrNull(name), render { }.
2 parents 1456a16 + 4e98483 commit 2c02674

21 files changed

Lines changed: 2284 additions & 0 deletions

File tree

docs/superpowers/plans/2026-05-25-metadata-ktx.md

Lines changed: 1106 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-05-25-metadata-ktx-kotlin-facade-design.md

Lines changed: 327 additions & 0 deletions
Large diffs are not rendered by default.

server/java/metadata-ktx/README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# MetaObjects :: Metadata Kotlin Facade (`metadata-ktx`)
2+
3+
A thin Kotlin extension layer over the Java MetaObjects engine.
4+
No forking, no reimplementation — just idiomatic Kotlin syntax on top of the existing Java API.
5+
6+
## Dependency
7+
8+
```xml
9+
<dependency>
10+
<groupId>com.metaobjects</groupId>
11+
<artifactId>metaobjects-metadata-ktx</artifactId>
12+
<version>${project.version}</version>
13+
</dependency>
14+
```
15+
16+
## Loading metadata
17+
18+
```kotlin
19+
import com.metaobjects.metadata.ktx.*
20+
import java.nio.file.Path
21+
22+
// From classpath resources
23+
val loader = loadResources("demo", listOf("meta.demo.json"))
24+
25+
// From a directory
26+
val dirLoader = loadDirectory("app", Path.of("./metadata"))
27+
28+
// From an inline string (defaults to JSON; pass MetaDataFormat.YAML for YAML)
29+
val inlineLoader = loadString("test", """{ "metadata.root": { "package": "x", "children": [] } }""")
30+
```
31+
32+
## Navigating metadata
33+
34+
```kotlin
35+
import com.metaobjects.field.StringField
36+
37+
// Null-returning lookups (idiomatic Kotlin)
38+
val author = loader.metaObjectOrNull("acme::demo::Author") // null if not found
39+
40+
// Reified typed field access
41+
val name = author?.field<StringField>("name") // null if absent or wrong type
42+
val required = author!!.requireField<StringField>("name") // throws if absent
43+
44+
// Typed nullable enums
45+
val rel = ... // some MetaRelationship
46+
val cardinality: Cardinality? = rel.cardinalityType // null if absent/unknown
47+
val target = rel.targetObjectOrNull // resolved MetaObject or null
48+
49+
// Own-only attribute reads
50+
val maxLen: String? = field.attrStringOrNull("maxLength")
51+
```
52+
53+
## Template + render (FR-004)
54+
55+
```kotlin
56+
import com.metaobjects.render.InMemoryProvider
57+
58+
val prompt = loader.promptTemplateOrNull("acme::demo::WelcomePrompt")
59+
println(prompt?.payloadRef) // "Author"
60+
61+
// Render via the Kotlin builder
62+
val out = render {
63+
template = "Hello {{name}}, welcome!"
64+
payload = mapOf("name" to "Ada")
65+
provider = InMemoryProvider(emptyMap())
66+
}
67+
// out == "Hello Ada, welcome!"
68+
69+
// Render via a ref + filesystem provider
70+
import com.metaobjects.render.FilesystemProvider
71+
val rendered = render {
72+
ref = "lobby/welcome"
73+
payload = mapOf("name" to "Bob")
74+
provider = FilesystemProvider(Path.of("./prompts"))
75+
format = "html"
76+
}
77+
```
78+
79+
## Patterns not wrapped (Java works fine from Kotlin)
80+
81+
- **Custom MetaObject / MetaField subtypes:** `class MyType : EntityMetaObject("MyType")` — Kotlin subclasses Java directly.
82+
- **Custom Provider:** `class MyProvider : Provider { override fun resolve(reference: String): String? = ... }`
83+
- **Custom MetaDataSource:** same pattern.
84+
- **MetaObject / MetaField mutation:** the Java mutator API (`addChild`, `addMetaAttribute`) reads fine from Kotlin.
85+
- **Registry builder:** Java's `MetaDataRegistry.registerType(MyType::class.java) { def -> def.type(...)... }` is already lambda-friendly.
86+
87+
## Status
88+
89+
`metaobjects-metadata-ktx` 7.0.0-SNAPSHOT. Mirrors the `metaobjects-omdb-ktx` Kotlin facade pattern.

server/java/metadata-ktx/pom.xml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<parent>
8+
<groupId>com.metaobjects</groupId>
9+
<artifactId>metaobjects</artifactId>
10+
<version>7.0.0-SNAPSHOT</version>
11+
</parent>
12+
13+
<artifactId>metaobjects-metadata-ktx</artifactId>
14+
<name>MetaObjects :: Metadata Kotlin Facade</name>
15+
<description>Idiomatic Kotlin facade over the Java metadata loader + FR-004 template + render engine.</description>
16+
17+
<properties>
18+
<kotlin.version>2.0.21</kotlin.version>
19+
<maven.compiler.release>21</maven.compiler.release>
20+
</properties>
21+
22+
<dependencies>
23+
<dependency>
24+
<groupId>org.jetbrains.kotlin</groupId>
25+
<artifactId>kotlin-stdlib</artifactId>
26+
<version>${kotlin.version}</version>
27+
</dependency>
28+
<dependency>
29+
<groupId>com.metaobjects</groupId>
30+
<artifactId>metaobjects-metadata</artifactId>
31+
<version>${project.version}</version>
32+
</dependency>
33+
<dependency>
34+
<groupId>com.metaobjects</groupId>
35+
<artifactId>metaobjects-render</artifactId>
36+
<version>${project.version}</version>
37+
</dependency>
38+
39+
<!-- test -->
40+
<dependency>
41+
<groupId>org.jetbrains.kotlin</groupId>
42+
<artifactId>kotlin-test-junit5</artifactId>
43+
<version>${kotlin.version}</version>
44+
<scope>test</scope>
45+
</dependency>
46+
<dependency>
47+
<groupId>org.junit.jupiter</groupId>
48+
<artifactId>junit-jupiter</artifactId>
49+
<version>5.10.2</version>
50+
<scope>test</scope>
51+
</dependency>
52+
<dependency>
53+
<groupId>com.metaobjects</groupId>
54+
<artifactId>metaobjects-metadata</artifactId>
55+
<version>${project.version}</version>
56+
<type>test-jar</type>
57+
<scope>test</scope>
58+
</dependency>
59+
</dependencies>
60+
61+
<build>
62+
<sourceDirectory>src/main/kotlin</sourceDirectory>
63+
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
64+
<plugins>
65+
<plugin>
66+
<groupId>org.jetbrains.kotlin</groupId>
67+
<artifactId>kotlin-maven-plugin</artifactId>
68+
<version>${kotlin.version}</version>
69+
<executions>
70+
<execution>
71+
<id>compile</id>
72+
<phase>compile</phase>
73+
<goals><goal>compile</goal></goals>
74+
</execution>
75+
<execution>
76+
<id>test-compile</id>
77+
<phase>test-compile</phase>
78+
<goals><goal>test-compile</goal></goals>
79+
</execution>
80+
</executions>
81+
<configuration>
82+
<jvmTarget>21</jvmTarget>
83+
</configuration>
84+
</plugin>
85+
<plugin>
86+
<groupId>org.apache.maven.plugins</groupId>
87+
<artifactId>maven-surefire-plugin</artifactId>
88+
<version>${surefire.plugin.version}</version>
89+
</plugin>
90+
</plugins>
91+
</build>
92+
</project>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.MetaData
4+
import com.metaobjects.attr.MetaAttribute
5+
6+
/**
7+
* Own-only attribute helpers — read attributes declared directly on a node,
8+
* ignoring anything inherited from a super. Mirrors [MetaData.hasMetaAttr] +
9+
* [MetaData.getMetaAttr] with the `includeParentData=false` flag pinned.
10+
*/
11+
12+
/** Read an own-only attribute as [MetaAttribute]; null if absent. */
13+
fun MetaData.attrOrNull(name: String): MetaAttribute<*>? =
14+
if (hasMetaAttr(name, false)) getMetaAttr(name, false) else null
15+
16+
/** Read an own-only attribute's string form (via `DataConverter`); null if absent. */
17+
fun MetaData.attrStringOrNull(name: String): String? =
18+
attrOrNull(name)?.valueAsString
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.MetaDataNotFoundException
4+
import com.metaobjects.field.MetaField
5+
import com.metaobjects.`object`.MetaObject
6+
7+
/**
8+
* Reified typed-field accessors over [MetaObject.getMetaField] / [MetaObject.getMetaFields].
9+
*
10+
* Kotlin's `inline reified` lets the caller write `obj.field<StringField>("s")`
11+
* instead of the Java class-token shape `obj.getMetaField("s", StringField.class)`.
12+
*/
13+
14+
/** Typed lookup; returns null when the field is absent OR not the requested subtype. */
15+
inline fun <reified T : MetaField<*>> MetaObject.field(name: String): T? =
16+
try {
17+
getMetaField(name) as? T
18+
} catch (_: MetaDataNotFoundException) {
19+
null
20+
}
21+
22+
/**
23+
* Typed lookup; throws when the field is absent (mirrors [MetaObject.getMetaField]).
24+
* Casts to [T]; throws [ClassCastException] when the field is the wrong subtype.
25+
*/
26+
inline fun <reified T : MetaField<*>> MetaObject.requireField(name: String): T =
27+
getMetaField(name) as T
28+
29+
/** All fields matching the requested subtype, in declaration order. */
30+
inline fun <reified T : MetaField<*>> MetaObject.fieldsOfType(): List<T> =
31+
metaFields.filterIsInstance<T>()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.identity.MetaIdentity
4+
5+
/**
6+
* Typed nullable enum mapping for [MetaIdentity.getGeneration].
7+
*
8+
* Java exposes `@generation` as a raw String drawn from a closed set
9+
* ([MetaIdentity.GENERATION_INCREMENT], [MetaIdentity.GENERATION_UUID],
10+
* [MetaIdentity.GENERATION_ASSIGNED]). Kotlin gets a typed nullable enum
11+
* so the `when` is exhaustive and unknown/absent values surface as `null`.
12+
*/
13+
enum class IdentityGeneration { INCREMENT, UUID, ASSIGNED }
14+
15+
/** Typed view of [MetaIdentity.getGeneration]; null on absent or unrecognized value. */
16+
val MetaIdentity.generationStrategy: IdentityGeneration?
17+
get() = when (generation?.lowercase()) {
18+
MetaIdentity.GENERATION_INCREMENT -> IdentityGeneration.INCREMENT
19+
MetaIdentity.GENERATION_UUID -> IdentityGeneration.UUID
20+
MetaIdentity.GENERATION_ASSIGNED -> IdentityGeneration.ASSIGNED
21+
else -> null
22+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.loader.DirectorySource
4+
import com.metaobjects.loader.LoaderOptions
5+
import com.metaobjects.loader.MetaDataLoader
6+
import com.metaobjects.loader.MetaDataSource.MetaDataFormat
7+
import java.net.URI
8+
import java.nio.file.Path
9+
10+
/**
11+
* Top-level loader factory shortcuts — match the cross-port convention used by
12+
* the TS and Python implementations. Each shortcut forwards to the corresponding
13+
* [MetaDataLoader] static factory.
14+
*/
15+
16+
/** Load metadata from a filesystem directory; pass [DirectorySource.Options] to tune expansion. */
17+
fun loadDirectory(
18+
name: String,
19+
directory: Path,
20+
opts: DirectorySource.Options = DirectorySource.Options(),
21+
): MetaDataLoader = MetaDataLoader.fromDirectory(name, directory, opts)
22+
23+
/** Load metadata from a list of URIs; pass [LoaderOptions] to tune (e.g. `strict=true`). */
24+
fun loadUris(
25+
name: String,
26+
uris: List<URI>,
27+
opts: LoaderOptions? = null,
28+
): MetaDataLoader = MetaDataLoader.fromUris(name, uris, opts)
29+
30+
/** Load metadata from classpath resource paths; pass [LoaderOptions] to tune. */
31+
fun loadResources(
32+
name: String,
33+
resources: List<String>,
34+
opts: LoaderOptions? = null,
35+
): MetaDataLoader = MetaDataLoader.fromResources(name, resources, opts)
36+
37+
/** Load metadata from a single inline string (defaults to JSON). */
38+
fun loadString(
39+
name: String,
40+
content: String,
41+
format: MetaDataFormat = MetaDataFormat.JSON,
42+
): MetaDataLoader = MetaDataLoader.fromString(name, content, format)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.MetaDataNotFoundException
4+
import com.metaobjects.loader.MetaDataLoader
5+
import com.metaobjects.`object`.MetaObject
6+
import com.metaobjects.registry.MetaDataLoaderRegistry
7+
import com.metaobjects.template.MetaTemplate
8+
import com.metaobjects.template.OutputTemplate
9+
import com.metaobjects.template.PromptTemplate
10+
import com.metaobjects.template.TemplateConstants
11+
12+
/**
13+
* Null-returning lookups for [MetaObject] and the FR-004 [MetaTemplate] subtypes.
14+
*
15+
* Java's [MetaDataLoader.getMetaObjectByName] throws [MetaDataNotFoundException]
16+
* on miss; idiomatic Kotlin prefers `T?`. These extensions trade the throw for
17+
* `null`, which is what every call site that "may or may not find it" wants.
18+
*/
19+
20+
/** Lookup a [MetaObject] by FQN on this loader; returns null if not found. */
21+
fun MetaDataLoader.metaObjectOrNull(name: String): MetaObject? =
22+
try {
23+
getMetaObjectByName(name)
24+
} catch (_: MetaDataNotFoundException) {
25+
null
26+
}
27+
28+
/** Lookup a [MetaObject] by FQN across all registered loaders; returns null if not found. */
29+
fun MetaDataLoaderRegistry.metaObjectOrNull(name: String): MetaObject? =
30+
try {
31+
findMetaObjectByName(name)
32+
} catch (_: MetaDataNotFoundException) {
33+
null
34+
}
35+
36+
/** Lookup a [MetaTemplate] (any subtype) by FQN; returns null if not found or not a template. */
37+
fun MetaDataLoader.templateOrNull(name: String): MetaTemplate? =
38+
try {
39+
root.getChildOfType(TemplateConstants.TYPE_TEMPLATE, name) as? MetaTemplate
40+
} catch (_: MetaDataNotFoundException) {
41+
null
42+
}
43+
44+
/** Lookup a [PromptTemplate] by FQN; returns null if not found OR not a prompt template. */
45+
fun MetaDataLoader.promptTemplateOrNull(name: String): PromptTemplate? =
46+
templateOrNull(name) as? PromptTemplate
47+
48+
/** Lookup an [OutputTemplate] by FQN; returns null if not found OR not an output template. */
49+
fun MetaDataLoader.outputTemplateOrNull(name: String): OutputTemplate? =
50+
templateOrNull(name) as? OutputTemplate
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.MetaDataNotFoundException
4+
import com.metaobjects.`object`.MetaObject
5+
import com.metaobjects.relationship.MetaRelationship
6+
7+
/**
8+
* Typed nullable enum + reference resolution for [MetaRelationship].
9+
*
10+
* Java exposes `@cardinality` as a raw String drawn from a closed set
11+
* ([MetaRelationship.CARDINALITY_ONE], [MetaRelationship.CARDINALITY_MANY]).
12+
* Java does NOT ship a `getTargetObject()` — only `getObjectRef()` returning
13+
* the raw `@objectRef` string (bare or FQN). This module resolves it against
14+
* the owning loader, accepting either form.
15+
*/
16+
enum class Cardinality { ONE, MANY }
17+
18+
/** Typed view of [MetaRelationship.getCardinality]; null on absent or unrecognized value. */
19+
val MetaRelationship.cardinalityType: Cardinality?
20+
get() = when (cardinality?.lowercase()) {
21+
MetaRelationship.CARDINALITY_ONE -> Cardinality.ONE
22+
MetaRelationship.CARDINALITY_MANY -> Cardinality.MANY
23+
else -> null
24+
}
25+
26+
/**
27+
* Resolve `@objectRef` against this relationship's owning loader; null if absent or unresolved.
28+
*
29+
* Tries the value as an FQN first ([com.metaobjects.loader.MetaDataLoader.getMetaObjectByName]);
30+
* falls back to a short-name scan of the loader's root, mirroring what the validation phase
31+
* does for origin `@via` path resolution (origin/relationship attrs accept bare entity names).
32+
*/
33+
val MetaRelationship.targetObjectOrNull: MetaObject?
34+
get() {
35+
val ref = objectRef ?: return null
36+
val loader = loader ?: return null
37+
try {
38+
return loader.getMetaObjectByName(ref)
39+
} catch (_: MetaDataNotFoundException) {
40+
// fall through to short-name scan
41+
}
42+
return loader.root.getChildren(MetaObject::class.java, false)
43+
.firstOrNull { it.shortName == ref || it.name == ref }
44+
}

0 commit comments

Comments
 (0)