Skip to content

Commit e22bd3b

Browse files
committed
Merge code-reviewer + simplifier gate findings (Phase T + docs)
Simplifier closed Phase T code nits: - ReferenceIdentity.isEnforced() simplified to Boolean.TRUE.equals(value) - KotlinExposedTableGenerator: dropped unused target param + @Suppress; soft references (@Enforce: false) now register decoration so dedup against inferred-from-many FK works; composite-FK skip emits LOG.warn; consolidated referentialActionSuffix() helper between identity + relationship paths Code-reviewer Important doc findings closed: - ADR-0007 link in source-kinds.md corrected to actual filename - CONFORMANCE.md metamodel fixture count corrected 85 → 88 - Root README conformance snippet includes Python + Kotlin pointer - Python + C# port install snippets clearly flagged unpublished 641 metadata + 78 codegen-kotlin = 719 tests still green.
2 parents 265f0a8 + 81b561f commit e22bd3b

2 files changed

Lines changed: 93 additions & 84 deletions

File tree

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt

Lines changed: 87 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
4747
// metadata used to decorate the field column with `.references(...)`
4848
// inline (rather than emit a separate FK row, which would duplicate the
4949
// field's regular column emission).
50-
val refDecorMap = buildIdentityReferenceDecorations(loader)
50+
val refDecorationMap = buildIdentityReferenceDecorations(loader)
5151

5252
// Pass 1b: compute FK columns globally across all entities so the "many"
5353
// side (`relationship.composition @cardinality: many`) on Author can
5454
// contribute the FK column to PostTable even when Post has no reciprocal.
55-
val fkMap = buildGlobalFkMap(loader, refDecorMap)
55+
val fkMap = buildGlobalFkMap(loader, refDecorationMap)
5656

5757
// Pass 2: emit one Table per entity using its own metadata + the
5858
// inbound FKs accumulated in Pass 1.
@@ -79,7 +79,7 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
7979
}
8080
val fkColumns = if (isViewKind(sourceRdb)) emptyList() else fkMap[entity.name].orEmpty()
8181
val refDecorations =
82-
if (isViewKind(sourceRdb)) emptyMap() else refDecorMap[entity.name].orEmpty()
82+
if (isViewKind(sourceRdb)) emptyMap() else refDecorationMap[entity.name].orEmpty()
8383
emit(entity, sourceRdb, outRoot, loader, fkColumns, refDecorations)
8484
}
8585
}
@@ -192,11 +192,14 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
192192
}
193193
val withAuto = if (isPk && incrementPk) "$baseSpec.autoIncrement()" else baseSpec
194194
// Decorate with .references(TargetTable.id[, onDelete=..., onUpdate=...])
195-
// when an identity.reference on this entity names this field. The FK
196-
// decoration applies BEFORE .nullable() so the chain reads naturally.
197-
val decorated = refDecorations[field.name]?.let { d ->
198-
"$withAuto.references(${d.targetTable}.id${d.refSuffix})"
199-
} ?: withAuto
195+
// when an enforced identity.reference on this entity names this field.
196+
// Soft references (@enforce: false) carry a null targetTable — the dedup
197+
// pass still uses the entry to suppress an inferred FK, but no physical
198+
// .references(...) call is emitted here. Decoration applies BEFORE
199+
// .nullable() so the chain reads naturally.
200+
val decoration = refDecorations[field.name]
201+
val decorated = if (decoration != null && decoration.emitsReference)
202+
"$withAuto.references(${decoration.targetTable}.id${decoration.refSuffix})" else withAuto
200203
val full = if (nullable) "$decorated.nullable()" else decorated
201204
append(" val ${field.name} = $full\n")
202205
}
@@ -304,7 +307,12 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
304307
private data class FkColumnSpec(
305308
val propertyName: String,
306309
val columnExpr: String,
307-
val hasReferenceOption: Boolean,
310+
/**
311+
* Suffix appended after the target column inside `.references(...)`; either
312+
* "" or {@code ", onDelete = ..., onUpdate = ..."}. Non-empty implies the
313+
* file must import {@code ReferenceOption}.
314+
*/
315+
val refSuffix: String,
308316
/**
309317
* `true` when authored directly on the FK-owning entity (to-one side);
310318
* `false` when inferred from the inverse to-many declaration on the
@@ -320,7 +328,10 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
320328
* must not double-emit a second FK to the same parent.
321329
*/
322330
val targetTable: String,
323-
)
331+
) {
332+
/** True when {@link #refSuffix} mentions a ReferenceOption (drives the import). */
333+
val hasReferenceOption: Boolean get() = refSuffix.isNotEmpty()
334+
}
324335

325336
/**
326337
* Pass 1: build a global FK map keyed by FQN of the entity that should
@@ -346,7 +357,7 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
346357
*/
347358
private fun buildGlobalFkMap(
348359
loader: MetaDataLoader,
349-
refDecorMap: Map<String, Map<String, RefDecoration>>,
360+
refDecorationMap: Map<String, Map<String, RefDecoration>>,
350361
): Map<String, List<FkColumnSpec>> {
351362
// Use list-per-entity so the emit order is deterministic (insertion-ordered).
352363
val acc = linkedMapOf<String, MutableList<FkColumnSpec>>()
@@ -362,7 +373,7 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
362373

363374
if (child.cardinality == MetaRelationship.CARDINALITY_MANY) {
364375
// Inferred: FK belongs on `target`, pointing back at `entity`.
365-
val spec = buildInverseFkSpec(entity, target, child) ?: continue
376+
val spec = buildInverseFkSpec(entity, child) ?: continue
366377
acc.getOrPut(target.name) { mutableListOf() }.add(spec)
367378
} else {
368379
// Declared (cardinality "one" or unspecified): FK belongs on `entity`.
@@ -383,13 +394,15 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
383394
// suppresses any inferred FK to the same target table — the canonical
384395
// shape (Program many→Week, Week identity.reference→Program) would
385396
// otherwise emit `programId` twice on WeekTable (once as the decorated
386-
// field column, once as a separate FK row).
397+
// field column, once as a separate FK row). Soft references (no targetTable)
398+
// still suppress by FIELD NAME — the field column already exists so the
399+
// inferred FK row would duplicate the column.
387400
return acc.mapValues { (entityName, specs) ->
388-
val declaredTargets = specs.filter { it.declared }.map { it.targetTable }.toSet()
389-
val declaredNames = specs.filter { it.declared }.map { it.propertyName }.toSet()
390-
val decoratedTargets = refDecorMap[entityName].orEmpty().values
391-
.map { it.targetTable }.toSet()
392-
val decoratedNames = refDecorMap[entityName].orEmpty().keys
401+
val declaredTargets = specs.asSequence().filter { it.declared }.map { it.targetTable }.toSet()
402+
val declaredNames = specs.asSequence().filter { it.declared }.map { it.propertyName }.toSet()
403+
val decorations = refDecorationMap[entityName].orEmpty()
404+
val decoratedTargets = decorations.values.mapNotNull { it.targetTable }.toSet()
405+
val decoratedNames = decorations.keys
393406
specs.filterNot { spec ->
394407
!spec.declared && (
395408
spec.targetTable in declaredTargets || spec.propertyName in declaredNames ||
@@ -413,12 +426,11 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
413426
val targetTable = PackageMapping.splitFqn(target.name).second + "Table"
414427
val relShortName = rel.shortName ?: rel.name
415428
val propertyName = readColumnAttr(rel) ?: (relShortName + "Id")
416-
val refArgs = buildReferenceOptionArgs(rel)
417-
val expr = "long(\"$propertyName\").references($targetTable.id${refArgs.first})"
429+
val refSuffix = referentialActionSuffix(rel.onDeleteRaw, rel.onUpdateRaw)
418430
return FkColumnSpec(
419431
propertyName = propertyName,
420-
columnExpr = expr,
421-
hasReferenceOption = refArgs.second,
432+
columnExpr = "long(\"$propertyName\").references($targetTable.id$refSuffix)",
433+
refSuffix = refSuffix,
422434
declared = true,
423435
targetTable = targetTable,
424436
)
@@ -434,47 +446,37 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
434446
* {@code @onDelete} / {@code @onUpdate} attrs propagate into the inferred
435447
* FK's ReferenceOption arguments.
436448
*/
437-
private fun buildInverseFkSpec(
438-
owner: MetaObject,
439-
target: MetaObject,
440-
rel: MetaRelationship,
441-
): FkColumnSpec? {
449+
private fun buildInverseFkSpec(owner: MetaObject, rel: MetaRelationship): FkColumnSpec? {
450+
// Skip when the owner has no rdb source — there would be no OwnerTable to reference.
451+
if (owner.children.filterIsInstance<RdbSource>().firstOrNull() == null) return null
442452
val ownerShort = PackageMapping.splitFqn(owner.name).second
443453
val ownerTable = ownerShort + "Table"
444-
// Verify the same-named Table exists in the loader; we don't strictly need it,
445-
// but we want a deterministic skip when the owner entity has no source.rdb.
446-
if (owner.children.filterIsInstance<RdbSource>().firstOrNull() == null) return null
447454
val propertyName = ownerShort.replaceFirstChar { it.lowercaseChar() } + "Id"
448-
val refArgs = buildReferenceOptionArgs(rel)
449-
val expr = "long(\"$propertyName\").references($ownerTable.id${refArgs.first})"
450-
// `target` reference is kept in the signature for parity with the declared path
451-
// and to allow future per-target customization (e.g. nullability inference).
452-
@Suppress("UNUSED_PARAMETER") target
455+
val refSuffix = referentialActionSuffix(rel.onDeleteRaw, rel.onUpdateRaw)
453456
return FkColumnSpec(
454457
propertyName = propertyName,
455-
columnExpr = expr,
456-
hasReferenceOption = refArgs.second,
458+
columnExpr = "long(\"$propertyName\").references($ownerTable.id$refSuffix)",
459+
refSuffix = refSuffix,
457460
declared = false,
458461
targetTable = ownerTable,
459462
)
460463
}
461464

462465
/**
463-
* Lower a relationship's {@code @onDelete} / {@code @onUpdate} into the
464-
* suffix portion of an Exposed {@code .references(...)} call.
466+
* Lower a pair of {@code @onDelete} / {@code @onUpdate} raw attr values
467+
* (kebab-case per the metamodel, e.g. {@code "cascade"} / {@code "set-null"})
468+
* into the suffix portion of an Exposed {@code .references(...)} call.
465469
*
466-
* Returns {@code (suffix, hasReferenceOption)}. Suffix is either "" (no
467-
* options) or {@code ", onDelete = ..., onUpdate = ..."} ready to splice
468-
* after the target column.
470+
* Returns either "" (no options) or {@code ", onDelete = ..., onUpdate = ..."}
471+
* ready to splice after the target column. Shared by both
472+
* `relationship.composition` (declared + inferred) and `identity.reference`
473+
* paths so the lowering rules stay in lockstep.
469474
*/
470-
private fun buildReferenceOptionArgs(rel: MetaRelationship): Pair<String, Boolean> {
471-
val refParts = mutableListOf<String>()
472-
val onDelete = rel.onDeleteRaw?.let { mapReferentialAction(it) }
473-
val onUpdate = rel.onUpdateRaw?.let { mapReferentialAction(it) }
474-
if (onDelete != null) refParts += "onDelete = ReferenceOption.$onDelete"
475-
if (onUpdate != null) refParts += "onUpdate = ReferenceOption.$onUpdate"
476-
val suffix = if (refParts.isEmpty()) "" else ", " + refParts.joinToString(", ")
477-
return suffix to refParts.isNotEmpty()
475+
private fun referentialActionSuffix(onDeleteRaw: String?, onUpdateRaw: String?): String {
476+
val parts = mutableListOf<String>()
477+
onDeleteRaw?.let { mapReferentialAction(it) }?.let { parts += "onDelete = ReferenceOption.$it" }
478+
onUpdateRaw?.let { mapReferentialAction(it) }?.let { parts += "onUpdate = ReferenceOption.$it" }
479+
return if (parts.isEmpty()) "" else ", " + parts.joinToString(", ")
478480
}
479481

480482
// === identity.reference FK decoration ====================================
@@ -484,24 +486,37 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
484486
* names that field. The codegen appends `.references(targetTable.id[, refSuffix])`
485487
* to the field's column initializer (rather than emitting a separate FK row,
486488
* which would duplicate the column).
489+
*
490+
* A {@code null} {@link #targetTable} indicates a soft reference (`@enforce: false`):
491+
* the entry still occupies the field-name key so dedup against an inferred FK works,
492+
* but the column emission pass skips the `.references(...)` decoration.
487493
*/
488494
private data class RefDecoration(
489-
/** Target table object name (e.g. {@code "ProgramTable"}). */
490-
val targetTable: String,
495+
/** Target table object name (e.g. {@code "ProgramTable"}); null for soft refs. */
496+
val targetTable: String?,
491497
/** Suffix portion after the target column — either "" or {@code ", onDelete = ..., onUpdate = ..."}. */
492498
val refSuffix: String,
493-
/** True when {@code refSuffix} mentions a ReferenceOption (drives the import). */
494-
val hasReferenceOption: Boolean,
495-
)
499+
) {
500+
/** True when {@link #refSuffix} mentions a ReferenceOption (drives the import). */
501+
val hasReferenceOption: Boolean get() = refSuffix.isNotEmpty()
502+
/** True when this decoration emits a `.references(...)` call (enforced + resolvable). */
503+
val emitsReference: Boolean get() = targetTable != null
504+
}
496505

497506
/**
498507
* Pass 1a: walk every entity's `identity.reference` children and build a
499508
* map of {@code entity.name -> (fieldName -> RefDecoration)} so the column
500509
* emission pass can decorate the matching field column inline.
501510
*
502-
* Single-field references only for v1; multi-field composite FKs ({@code @fields: "a,b"})
503-
* are deferred — they require Exposed's compound-FK API and are uncommon in practice.
504-
* The decoration silently skips such references (a future enhancement will warn).
511+
* Single-field references only for v1; multi-field composite FKs
512+
* ({@code @fields: "a,b"}) are deferred — they require Exposed's compound-FK
513+
* API and are uncommon in practice. Skipped composites are logged as a WARN
514+
* so a user authoring `@fields: ["a", "b"]` isn't silently ignored.
515+
*
516+
* Soft references ({@code @enforce: false}) still register an entry (with
517+
* {@code targetTable = null}) so the FK-dedup pass can suppress a redundant
518+
* inferred FK to the same column — but the column emission pass skips
519+
* decoration so no physical constraint is generated.
505520
*/
506521
private fun buildIdentityReferenceDecorations(
507522
loader: MetaDataLoader,
@@ -511,39 +526,32 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
511526
if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue
512527
for (child in entity.children) {
513528
if (child !is ReferenceIdentity) continue
514-
// Skip references that won't be physically enforced.
515-
if (!child.isEnforced) continue
516529
val fields = child.fields
517-
if (fields.size != 1) continue // composite FK deferred
530+
if (fields.size != 1) {
531+
LOG.warn(
532+
"skipping identity.reference '{}' on {} — multi-field composite FKs (@fields={}) are not yet supported by KotlinExposedTableGenerator",
533+
child.name, entity.name, fields
534+
)
535+
continue
536+
}
518537
val fieldName = fields[0]
519538
val targetEntityName = child.targetEntity ?: continue
520539
val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, targetEntityName) ?: continue
521540
// Skip when the target has no rdb source — Exposed cannot reference a non-table.
522541
if (target.children.filterIsInstance<RdbSource>().firstOrNull() == null) continue
523-
val targetTable = PackageMapping.splitFqn(target.name).second + "Table"
524-
val (suffix, hasRefOpt) = buildIdentityReferenceArgs(child)
542+
// Soft references register a null-targetTable entry so dedup-vs-inferred works,
543+
// but column emission will skip the `.references(...)` decoration.
544+
val targetTable = if (child.isEnforced)
545+
PackageMapping.splitFqn(target.name).second + "Table" else null
546+
val refSuffix = if (child.isEnforced)
547+
referentialActionSuffix(child.onDeleteRaw, child.onUpdateRaw) else ""
525548
acc.getOrPut(entity.name) { linkedMapOf() }[fieldName] =
526-
RefDecoration(targetTable, suffix, hasRefOpt)
549+
RefDecoration(targetTable, refSuffix)
527550
}
528551
}
529552
return acc
530553
}
531554

532-
/**
533-
* Lower a {@link ReferenceIdentity}'s {@code @onDelete} / {@code @onUpdate}
534-
* attrs into the suffix portion of an Exposed {@code .references(...)} call.
535-
* Parallel to {@link #buildReferenceOptionArgs} for relationship.composition.
536-
*/
537-
private fun buildIdentityReferenceArgs(ref: ReferenceIdentity): Pair<String, Boolean> {
538-
val refParts = mutableListOf<String>()
539-
val onDelete = ref.onDeleteRaw?.let { mapReferentialAction(it) }
540-
val onUpdate = ref.onUpdateRaw?.let { mapReferentialAction(it) }
541-
if (onDelete != null) refParts += "onDelete = ReferenceOption.$onDelete"
542-
if (onUpdate != null) refParts += "onUpdate = ReferenceOption.$onUpdate"
543-
val suffix = if (refParts.isEmpty()) "" else ", " + refParts.joinToString(", ")
544-
return suffix to refParts.isNotEmpty()
545-
}
546-
547555
/** Read the `@column` attr on a relationship (inheritance allowed); null when absent. */
548556
private fun readColumnAttr(rel: MetaRelationship): String? {
549557
if (!rel.hasMetaAttr(CoreDBMetaDataProvider.COLUMN, true)) return null

server/java/metadata/src/main/java/com/metaobjects/identity/ReferenceIdentity.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,15 @@ public String getOnUpdateRaw() {
142142
* Whether the reference is physically enforced by the backend.
143143
* Default true (hard FK constraint emitted). Explicit {@code @enforce: false}
144144
* marks the reference as logical-only — codegen skips physical constraints.
145+
*
146+
* The attr is registered as {@link BooleanAttribute#SUBTYPE_BOOLEAN}; the loader
147+
* coerces values to a {@link Boolean} so the attr's raw value is either {@code null}
148+
* (absent) or a {@link Boolean}. Default-true semantics: absent → true; only
149+
* explicit {@code Boolean.TRUE} keeps the reference enforced.
145150
*/
146151
public boolean isEnforced() {
147152
if (!hasMetaAttr(ATTR_ENFORCE)) return true;
148-
MetaAttribute attr = getMetaAttr(ATTR_ENFORCE);
149-
Object value = attr.getValue();
150-
if (value instanceof Boolean) return (Boolean) value;
151-
// String fallback for value coercion edge cases — explicit "false" means soft.
152-
return !"false".equalsIgnoreCase(attr.getValueAsString());
153+
return Boolean.TRUE.equals(getMetaAttr(ATTR_ENFORCE).getValue());
153154
}
154155

155156
@Override

0 commit comments

Comments
 (0)