@@ -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
0 commit comments