Skip to content

Latest commit

 

History

History
544 lines (461 loc) · 25.6 KB

File metadata and controls

544 lines (461 loc) · 25.6 KB

Kotlin port

Idiomatic Kotlin codegen target for Spring-Boot-Kotlin consumers on Exposed + Flyway. The Kotlin port is a codegen tier built on top of the Java port — the loader, OMDB persistence engine, render engine, Maven plugin, and conformance runners are all Java; Kotlin emits idiomatic Kotlin (data class, Exposed Table objects, extension-fn relationship helpers, Spring @Configuration wiring) via KotlinPoet.

Two modules:

  • metaobjects-codegen-kotlin — 15 KotlinPoet-based generators.
  • metaobjects-metadata-ktx — thin Kotlin facade over the Java loader + render engine for idiomatic Kotlin runtime use.

Install

<!-- pom.xml -->
<dependencies>
  <dependency>
    <groupId>com.metaobjects</groupId>
    <artifactId>metaobjects-metadata</artifactId>
    <version>${metaobjects.version}</version>
  </dependency>
  <dependency>
    <groupId>com.metaobjects</groupId>
    <artifactId>metaobjects-metadata-ktx</artifactId>
    <version>${metaobjects.version}</version>
  </dependency>
  <dependency>
    <groupId>com.metaobjects</groupId>
    <artifactId>metaobjects-render</artifactId>
    <version>${metaobjects.version}</version>
  </dependency>

  <dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-core</artifactId>
    <version>${exposed.version}</version>
  </dependency>
  <!-- The generated tables import these two: instant (tz-aware timestamp) columns come from
       exposed-java-time, jsonb columns from exposed-json. exposed-jdbc is what
       `Database.connect(...)` needs at RUNTIME — without it the app starts and the first
       query fails. -->
  <dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-jdbc</artifactId>
    <version>${exposed.version}</version>
  </dependency>
  <dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-java-time</artifactId>
    <version>${exposed.version}</version>
  </dependency>
  <dependency>
    <groupId>org.jetbrains.exposed</groupId>
    <artifactId>exposed-json</artifactId>
    <version>${exposed.version}</version>
  </dependency>
  <!-- Generated typed `field.object @storage:jsonb` / `field.map` columns serialize through a
       generated per-package `MetaJsonbMapper.kt` Jackson `ObjectMapper` (no kotlinx-serialization
       compiler plugin required). -->
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>${jackson.version}</version>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-kotlin</artifactId>
    <version>${jackson.version}</version>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>${jackson.version}</version>
  </dependency>
  <!-- FR-006 output parser + prompt-payload lane; also backs the open-bag
       `field.string @dbColumnType:jsonb` → kotlinx `JsonElement` path. -->
  <dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-serialization-json</artifactId>
    <version>${kotlinx-serialization.version}</version>
  </dependency>
</dependencies>

Configure

The 15 generators registered in codegen-kotlin (GeneratorRegistry.kt):

Generator Output Per
KotlinEntityGenerator <Entity>.kt — Kotlin data class (Jackson-compatible; no @Serializable). For a value object this data class IS the template tier's payload/response type (ADR-0056); a value object a responding prompt parses into also gets <Vo>Extracted.kt (the lenient mirror, written by the parser tier once per run, beside it) every object.entity, object.value, and object.projection
KotlinExposedTableGenerator <Entity>Table.kt — Exposed Table object with PK + FK + @storage columns entities with source.rdb
KotlinNamesGenerator <Entity>Names.kt — physical database name constants mirroring the metadata tree (per-role source name + kind + schema, columns, identity/index names) every object with a declared/inherited primary source.rdb
KotlinRelationsGenerator <Entity>Relations.kt — extension fns for cardinality=many query helpers entities with to-many relationships
KotlinRepositoryGenerator <Entity>RepositoryBase.kt — persistence repository base (row-mapper + CRUD + patch) writable entities (source.rdb @kind="table")
KotlinFilterAllowlistGenerator <Entity>FilterAllowlist.kt — FR-009 filter allowlist (filterable field names + allowed ops per field) writable entities (source.rdb @kind="table")
KotlinOutputParserGenerator <Prompt>Parser.kt — object with parseXxx (Jackson; throws on a malformed or mismatched reply) + safeParseXxx (returns Result<TResponse>), where TResponse is the @responseRef value object's own data class every responding template.prompt (FR-006); strict tier JSON-only
KotlinOutputPromptGenerator <Prompt>ResponseFormat.kt — response-format prompt fragment (FR-010) every responding template.prompt
KotlinRenderHelperGenerator <Template>RenderHelper.kt — typed render() wrappers (document/email, keyed off @kind) every template.output
KotlinExtractorGenerator <Prompt>Extractor.kt — strict typed extract<Name> response helper (FR-010) every responding template.prompt
KotlinValidatorGenerator MetadataStartupValidator.kt + ExposedTableValidator.kt once per project
KotlinSpringConfigGenerator MetadataExposedConfig.kt — @Configuration wiring Database.connect() + auto-validator once per project
KotlinStoredProcGenerator Stored-procedure call wrappers entities with source.rdb @kind="storedProc"
KotlinSpringControllerGenerator <Entity>Controller.kt — Spring @RestController (5 CRUD endpoints; cross-port API contract). Select KotlinRelationsGenerator with it when the model has a M:N relationship: the traversal routes call the <rel>Query helpers only that generator emits, and without it the controller does not compile entities with source.rdb @kind="table"

Maven wiring:

<plugin>
  <groupId>com.metaobjects</groupId>
  <artifactId>metaobjects-maven-plugin</artifactId>
  <version>${metaobjects.version}</version>
  <configuration>
    <loader>
      <sourceDir>src/main/metaobjects</sourceDir>
    </loader>
    <generators>
      <generator>
        <classname>com.metaobjects.generator.kotlin.KotlinEntityGenerator</classname>
        <args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
      </generator>
      <generator>
        <classname>com.metaobjects.generator.kotlin.KotlinExposedTableGenerator</classname>
        <args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
      </generator>
      <generator>
        <classname>com.metaobjects.generator.kotlin.KotlinRelationsGenerator</classname>
        <args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
      </generator>
      <generator>
        <classname>com.metaobjects.generator.kotlin.KotlinValidatorGenerator</classname>
        <args>
          <outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
          <packageName>com.yourapp</packageName>
        </args>
      </generator>
      <generator>
        <classname>com.metaobjects.generator.kotlin.KotlinSpringConfigGenerator</classname>
        <args>
          <outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
          <packageName>com.yourapp</packageName>
          <metadataResource>meta.blog.json</metadataResource>
        </args>
      </generator>
    </generators>
  </configuration>
</plugin>

Own a generator. mvn metaobjects:eject -Dnames=<name,...> copies a reference generator into a codegen/ Maven module under your own package, and prints the module, dependency and <classname> to wire. See Own your codegen → Java and Kotlin.

Nothing else to hand over. Unlike the Java routes/dto/repository output, no Kotlin generator's output imports helper runtime. The generated controller declares its filter parser, constraint mapping and patch handling inline, per file, and the rest imports only core: render and extract, the loader, and metadata-ktx. So an ejected Kotlin generator's output depends on nothing you do not own beyond the core. EjectRuntimeRoundTripTest in the Maven plugin checks this against every Kotlin generator's real output. com.metaobjects.generator.kotlin.runtime.M2mJoinResolver is a helper you may call from hand-written traversal code. No generated file imports it, so eject does not copy it; copy it yourself if you use it and want to own it.

Declarative template-codegen (TemplateScopeGenerator)

The 15 generators above are a starting point, not the ceiling. When you need a shape none of them emits, Kotlin has both authoring paths — and which to reach for is a real decision (tradeoff table: codegen-concepts.md §3).

Programmatic means implementing com.metaobjects.generator.Generator in your own project and naming the class in <classname>; the plugin loads it from the project classloader, so your generator wires exactly like a built-in one.

Declarative means a Mustache template and no generator code at all. Kotlin gets this from the shared JVM engine — TemplateScopeGenerator is a plain <generator>, wired in the same <generators> block as the Kotlin* generators above (it is language-neutral, so there is no KotlinPoet involvement and no Kotlin-specific variant):

<generator>
  <classname>com.metaobjects.generator.template.TemplateScopeGenerator</classname>
  <args>
    <templatesDir>src/main/templates</templatesDir>
    <template>service/entity-service</template>
    <scope>perEntity</scope>
    <outputPattern>{package}/{Name}Service.kt</outputPattern>
    <outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
  </args>
</generator>

template, scope (perEntity | perPackage | perModel), outputPattern, templatesDir and outputDir are required; format defaults to text. The outputPattern placeholders are {name}, {Name} and {package} (whose :: segments become nested directories). Abstract objects are excluded from every scope.

The walks, the data dict and the pattern grammar are gated byte-identical against the shared fixtures/template-codegen-conformance/ corpus, so one template emits the same output here, in Java, in TypeScript, in C# and in Python. Full arg table and the @generated-marker note: the Java port page. The data dict itself: codegen-data-shapes.md.

Custom providers (optional)

Kotlin inherits Java's SPI-based provider discovery directly — write a MetaDataTypeProvider implementation (or its Kotlin DSL equivalent in metadata-ktx), drop the FQCN into META-INF/services/com.metaobjects.registry.MetaDataTypeProvider, and the loader picks it up alongside the core providers. See the Java port's Custom providers section for the mechanism; the ../features/extending-with-providers.md reference covers the cross-port contract.

Generate

mvn compile                            # runs the codegen as part of generate-sources

Schema migrations are owned by the TypeScript toolchain — see the Migrations section for the meta migrate commands.

Use

For the Author example (see entities.md), the codegen emits:

// generated/acme/blog/Author.kt  (jakarta.validation imports elided)
/**
 * GENERATED — do not hand-edit. Regenerated from metadata.
 */
public data class Author(
    public val id: Long? = null,        // field.long PK → nullable, auto-assigned on insert
    @field:NotNull
    @field:Size(min = 1, max = 200)
    public val name: String,            // @required + @maxLength: 200
    @field:Size(max = 2000)
    public val bio: String? = null,     // optional + @maxLength: 2000
)

// generated/acme/blog/AuthorTable.kt
object AuthorTable : Table("authors") {
    val id   = long("id").autoIncrement()
    val name = varchar("name", 200)
    val bio  = varchar("bio", 2000).nullable()
    override val primaryKey = PrimaryKey(id)
}

…and the Spring wiring is also generated, so consumer Kotlin code is purely business logic:

// Your AuthorService.kt — handwritten
@Service
class AuthorService(private val db: Database) {
    fun list(): List<Author> = transaction(db) {
        AuthorTable.selectAll().map {
            Author(
                id = it[AuthorTable.id],
                name = it[AuthorTable.name],
                bio = it[AuthorTable.bio],
            )
        }
    }

    fun create(name: String, bio: String? = null): Long = transaction(db) {
        AuthorTable.insert {
            it[AuthorTable.name] = name
            it[AuthorTable.bio] = bio
        } get AuthorTable.id
    }
}

<Entity>Names — the physical names, as constants

names (KotlinNamesGenerator) is not wired above — it is opt-in, like every Kotlin generator (there is no default suite on the JVM; <generators> in the pom is the complete list, per generator). Add it explicitly:

<generator>
  <classname>com.metaobjects.generator.kotlin.KotlinNamesGenerator</classname>
  <args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
</generator>

It emits one <Entity>Names.kt per object with a declared or inherited primary source.rdb:

// generated/acme/blog/AuthorNames.kt (package line elided)
object AuthorNames {
    const val TYPE: String = "object"
    const val SUB_TYPE: String = "entity"
    const val NAME: String = "Author"

    const val SOURCE_PRIMARY_TYPE: String = "source"
    const val SOURCE_PRIMARY_SUB_TYPE: String = "rdb"
    const val SOURCE_PRIMARY_KIND: String = "table"
    const val SOURCE_PRIMARY_TABLE: String = "authors"

    const val NAME_FIELD: String = "name"
    const val NAME_COLUMN: String = "name"

    const val IDENTITY_PK_TYPE: String = "identity"
    const val IDENTITY_PK_SUB_TYPE: String = "primary"
    const val IDENTITY_PK_NAME: String = "pk"

    val COLUMNS_BY_FIELD: Map<String, String> = mapOf(
        "name" to NAME_COLUMN,
    )
}

The object MIRRORS THE METADATA TREE. Every node carries its own TYPE, SUB_TYPE and NAME, so AuthorNames.NAME is the OBJECT's name ("Author") and a physical name sits under the member that says what it IS — SOURCE_<ROLE>_TABLE / _VIEW / _MATERIALIZED_VIEW / _PROC / _FUNCTION, from the metamodel's own @kind-to-alias map. <ROLE> is PRIMARY or REPLICA, so a write-through entity — one table, one replica view, two physical names — has a member for each instead of one member between them.

An identity.secondary or index.lookup also carries IDENTITY_<NAME>_INDEX / INDEX_<NAME>_INDEX, the database index name that the generated init { uniqueIndex(…) } / init { index(…) } block references; identity.primary deliberately carries none, because migrate names a primary key by a dialect-conditional formula this artifact must not restate.

There is no READ_ONLY. It was never metadata — it is a derivation over @kind — so ask SOURCE_<ROLE>_KIND.

Prefer a typed handle where one exists. If the ORM gives you a type-checked object for the same thing, use that. Replacing it with a string constant trades an error the compiler catches for one the database raises at runtime. These constants are for the places with no typed handle: raw SQL, a migration script, a log line, an external system's column mapping.

Here, that handle is the Exposed Column object on the generated Table — AuthorTable.name, not AuthorNames.NAME_COLUMN, is what a query should bind against; Exposed's DSL is already type-checked column-by-column. Reach for the constant instead in raw SQL, a Flyway migration, a log line, or an external system's column mapping — the places AuthorTable gives you nothing to hold onto.

KotlinExposedTableGenerator reads these constants instead of independently re-deriving the same table/column names whenever the names generator is in the same run. You do not have to ask: the Maven plugin builds the whole <generators> list before executing any of it, so adding KotlinNamesGenerator above is what switches the table binding over. The useNames arg exists to override that decision — pin it false to keep byte-identical output, or true for a direct programmatic call outside the plugin, where nothing aggregates the run and it defaults false:

<generator>
  <classname>com.metaobjects.generator.kotlin.KotlinExposedTableGenerator</classname>
  <args>
    <outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
    <useNames>true</useNames>
  </args>
</generator>

With useNames on, AuthorTable reads Table(AuthorNames.SOURCE_PRIMARY_TABLE), varchar(AuthorNames.NAME_COLUMN, 200) and uniqueIndex(AuthorNames.IDENTITY_BY_NAME_INDEX, name) rather than the literals shown above — useful once you have hand-written code depending on AuthorNames too, so the table binding and that code share one resolution instead of two independent ones that could drift. Both generators must be given the same columnNaming argument, or the table and the constants file disagree with each other about a column's name.

A suite without the names generator keeps the literals, which is what makes the output compile either way — referencing AuthorNames in a run that generated no such object would not.

It follows extends, so a constant you do not find in an object is in its parent's. Kotlin has no static inheritance — an object cannot extend another — so an artifact whose object extends another re-exports the inherited constants by REFERENCE:

object CopayAuthNames {
    const val NAME: String = "CopayAuth"               // its OWN name, always restated
    const val COPAY_AMOUNT_COLUMN: String = "copay_cents"

    // A TPH subtype declares no source of its own, so the SHARED table — and every
    // inherited column — is re-exported by reference and spelled once, on the base.
    const val SOURCE_PRIMARY_TABLE: String = AuthNames.SOURCE_PRIMARY_TABLE
    const val ID_COLUMN: String = AuthNames.ID_COLUMN
    // COLUMNS_BY_FIELD stays complete — inherited entries included.
}

An abstract base a persisted entity extends gets an object of its own carrying the columns and keys it declares and no SOURCE_* block at all — it has no table, and must never acquire one.

FR-004 — render

metadata-ktx wraps the Java Renderer in an idiomatic Kotlin builder. The payload is the @payloadRef value object's own data class from KotlinEntityGenerator (ADR-0056), so the builder is type-safe end-to-end.

import com.metaobjects.metadata.ktx.render
import com.metaobjects.render.FilesystemProvider
import java.nio.file.Path

val out = render {
    ref = "lobby/welcome"
    payload = WelcomePayload(
        displayName = "Ada",
        postCount = 12,
        posts = listOf(PostSummary("Hello")),
    )
    provider = FilesystemProvider(Path.of("./prompts"))
    format = "xml"
}

FR-006 — response parsing

KotlinOutputParserGenerator emits a typed parser per responding template.prompt — one declaring @responseRef. It decodes into the @responseRef value object's own data class (ADR-0056) with Jackson (jackson-module-kotlin), the codec those data classes are built for, and pairs the throwing entry with the stdlib's Result<T> convention.

ADR-0052: the shape parsed INTO is @responseRef, never @payloadRef (which types the request the prompt renders outbound), and template.output gets no parser at all. The strict tier is JSON-only — an @responseFormat: xml reply gets the tolerant extract and nothing strict.

// generated/acme/ai/prompts/NpcResponseParser.kt
import acme.ai.NpcReply   // the @responseRef value object's own data class

object NpcResponseParser {
    private val mapper = jacksonObjectMapper().findAndRegisterModules()

    /** @throws com.fasterxml.jackson.core.JsonProcessingException on bad input. */
    fun parseNpcResponse(text: String): NpcReply =
        mapper.readValue(text, NpcReply::class.java)

    /** Result-style — does not throw. */
    fun safeParseNpcResponse(text: String): Result<NpcReply> =
        runCatching { parseNpcResponse(text) }
}

Consumer wiring:

val response: String = myLlmClient.complete(promptText)

// Throwing path — propagate to your error handler
val npc = NpcResponseParser.parseNpcResponse(response)

// Or Result-style
NpcResponseParser.safeParseNpcResponse(response)
    .onSuccess { npc -> /* use it */ }
    .onFailure { ex -> log.warn("LLM returned malformed payload", ex) }

Consumer dependency. The emitted strict parser uses Jackson with the Kotlin module:

dependencies {
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.x")
}

No kotlinx-serialization plugin is needed for the template tier: the value objects' data classes carry no @Serializable (it was decorative — no build enabled the compiler plugin it needs). See codegen-kotlin/KNOWN_GAPS.md for the full consumer-wiring contract. Cross-port design is at ADR-0010; the feature reference is at features/templates-and-payloads.md.

Angular 18 frontend

KotlinSpringControllerGenerator emits a Spring @RestController per writable entity (source.rdb @kind="table") conforming to the cross-port REST contract at docs/features/api-contract.md. Any universal browser client built against that contract — including the @metaobjectsdev/angular runtime + the @metaobjectsdev/codegen-ts-angular codegen (source-only; not published to npm) — consumes it directly: services, reactive forms, and grids point at the same URL grammar (/api/<entity-plural>), the same ?withCount=1 envelope, and the same JSON wire format used by the C# .NET 8 + ASP.NET Minimal API backend.

The C#-side recipe at docs/recipes/csharp-angular18.md walks through the dev-server CORS wiring, provideHttpClient(), and grid/form/ service usage end-to-end. Swap the ASP.NET sections for Spring Boot configuration (Spring WebMvcConfigurer instead of AddCors, application port 8080 instead of 5000) — every other line carries over verbatim because the contract is universal.

Drift detection (Tier-2 integration)

Drift source Where caught When
Code-vs-DB KotlinEntityGenerator + KotlinExposedTableGenerator (one metadata, two emitters) Build time
Code-vs-API-doc Cross-port codegen from same metadata Build time
DB-vs-metadata MetadataStartupValidator.validate(loader) at Spring ApplicationReadyEvent; live-DB schema drift: TS toolchain meta verify --db App startup; CI on every PR (TS)
Migration-vs-metadata TS toolchain meta migrate emits from metadata diffs (meta:migrate Maven goal was removed) Build time
Generated-edited @generated KotlinPoet headers Code review
Prompt-vs-payload KotlinRenderHelperGenerator's build-time drift gate + Java Renderer.verify Build time + runtime
Generated-vs-runtime MetadataStartupValidator.validate(loader) from Spring ApplicationReadyEvent App startup

Capability snapshot

Feature Status
Entities + fields Yes
Relationships + FK Yes
Source kinds (table / view / storedProc) Yes — storedProc has its own generator
REST controllers (Spring @RestController) Yes — KotlinSpringControllerGenerator per writable entity; cross-port API contract
field.currency / field.enum / field.object + @storage Yes (incl. flattened per-sub-field columns)
Templates + render (FR-004) Yes (wraps the Java engine)
Output parser codegen (FR-006) Yes (KotlinOutputParserGenerator — Jackson + Result<T> dual API)
Payload-VO codegen Yes — the payload IS the value object's own data class, from KotlinEntityGenerator (ADR-0056); no separate payload generator
Migrations Via the TS toolchain (@metaobjectsdev/cli migrate)
Drift verify Template-drift: Renderer.verify (build-time); generated-table drift: MetadataStartupValidator (startup)
Runtime metadata Via Java OMDB (or hand-written Exposed transactions)

Test count

Several hundred tests in codegen-kotlin (mvn -pl codegen-kotlin test; ~290 @Test methods across ~50 test files). Snapshot tests gate within-Java output stability; kotlin-compile-testing gates generated-code validity; an end-to-end test exercises the full loop including the Java Renderer. Persistence-conformance + the cross-port API contract run in integration-tests-kotlin (33 / 33 — 12 persistence + 20 api-contract + 1 codegen-matches-reference, all runnable via scripts/integration-test.sh kotlin).

See also