|
| 1 | +package com.metaobjects.integration.kotlin |
| 2 | + |
| 3 | +import com.metaobjects.MetaData |
| 4 | +import com.metaobjects.MetaRoot |
| 5 | +import com.metaobjects.`object`.MetaObject |
| 6 | +import com.metaobjects.relationship.M2MFields |
| 7 | +import com.metaobjects.relationship.MetaRelationship |
| 8 | +import java.sql.Connection |
| 9 | + |
| 10 | +/** |
| 11 | + * Generic, metadata-driven M:N query resolver for the Kotlin/Exposed runtime. |
| 12 | + * |
| 13 | + * Kotlin port of the TS reference (`runtime-ts/src/n2m-resolver.ts`) and the Java |
| 14 | + * OMDB `M2MResolver`. Given a source entity and an M:N relationship |
| 15 | + * (`@cardinality: "many"` + `@objectRef: <target>` + `@through: <junction>`), |
| 16 | + * resolve the related target rows by traversing the junction. The junction FK |
| 17 | + * columns are NOT restated on the relationship — they are DERIVED from the |
| 18 | + * junction entity's two `identity.reference` children via the shared JVM |
| 19 | + * [M2MFields.derive] helper (the cross-port SSOT for FK direction), exactly as |
| 20 | + * the Java OMDB resolver does. |
| 21 | + * |
| 22 | + * The Exposed substrate here uses raw JDBC against the open transaction |
| 23 | + * connection rather than typed `Table` objects: the M:N corpus entities (Post / |
| 24 | + * Tag / Person / Follow / Friendship / PostTag) carry no hand-written Exposed |
| 25 | + * `Table`, and the physical table + column + PK names are fully described by the |
| 26 | + * loaded metadata — so the resolver reads them from the metadata and addresses |
| 27 | + * the DB generically (no per-entity Exposed mapping needed). |
| 28 | + * |
| 29 | + * Three modes (mirroring TS / Java): |
| 30 | + * 1. **Hetero** (source != target): query the junction `WHERE sourceFK = sourceId`, |
| 31 | + * collect `targetFK`, load the targets by PK. |
| 32 | + * 2. **Directed self-join** (`@sourceRefField`): identical traversal; |
| 33 | + * [M2MFields.derive] has already picked which junction FK is the source side, |
| 34 | + * so direction is honored. |
| 35 | + * 3. **Symmetric self-join** (`@symmetric: true`): single-row storage, union on |
| 36 | + * read — query `WHERE sourceFK = id OR targetFK = id`; for each junction row the |
| 37 | + * related id is whichever FK column is NOT the source id (a self-pair row `(a,a)` |
| 38 | + * yields the source id itself). |
| 39 | + */ |
| 40 | +object M2MResolver { |
| 41 | + |
| 42 | + /** |
| 43 | + * Resolve the related target rows for an M:N relationship. |
| 44 | + * |
| 45 | + * @param conn the open JDBC connection (from the Exposed transaction) |
| 46 | + * @param sourceMeta the source entity's metadata |
| 47 | + * @param sourceId the source row's primary-key value (from the scenario `by:`) |
| 48 | + * @param rel the M:N relationship declared on [sourceMeta] |
| 49 | + * @param root the loaded model root (to find junction + target entities) |
| 50 | + * @return the related target rows as column-name-keyed maps (empty when none) |
| 51 | + */ |
| 52 | + fun resolve( |
| 53 | + conn: Connection, |
| 54 | + sourceMeta: MetaObject, |
| 55 | + sourceId: Any?, |
| 56 | + rel: MetaRelationship, |
| 57 | + root: MetaRoot, |
| 58 | + ): List<Map<String, Any?>> { |
| 59 | + if (sourceId == null) return emptyList() |
| 60 | + |
| 61 | + val fields = M2MFields.derive(rel, sourceMeta, root) |
| 62 | + val junction = mustGetEntity(root, rel.through) |
| 63 | + val target = mustGetEntity(root, rel.objectRef) |
| 64 | + |
| 65 | + val junctionTable = tableName(junction) |
| 66 | + val sourceFk = fields.sourceField |
| 67 | + val targetFk = fields.targetField |
| 68 | + |
| 69 | + // 1. Query the junction for matching rows. |
| 70 | + // hetero / directed: sourceFK = sourceId |
| 71 | + // symmetric: sourceFK = sourceId OR targetFK = sourceId (union-on-read) |
| 72 | + val whereSql = if (rel.isSymmetric) { |
| 73 | + "\"$sourceFk\" = ? OR \"$targetFk\" = ?" |
| 74 | + } else { |
| 75 | + "\"$sourceFk\" = ?" |
| 76 | + } |
| 77 | + val params = if (rel.isSymmetric) listOf(sourceId, sourceId) else listOf(sourceId) |
| 78 | + |
| 79 | + // 2. Collect the distinct related target ids (preserve first-seen order). |
| 80 | + val targetIds = LinkedHashSet<Any?>() |
| 81 | + conn.prepareStatement( |
| 82 | + "SELECT \"$sourceFk\", \"$targetFk\" FROM \"$junctionTable\" WHERE $whereSql" |
| 83 | + ).use { ps -> |
| 84 | + params.forEachIndexed { i, v -> ps.setObject(i + 1, v) } |
| 85 | + ps.executeQuery().use { rs -> |
| 86 | + while (rs.next()) { |
| 87 | + val a = rs.getObject(sourceFk) |
| 88 | + val b = rs.getObject(targetFk) |
| 89 | + val relatedId = if (rel.isSymmetric) { |
| 90 | + // The related id is whichever FK column is NOT the source id. |
| 91 | + if (keyEquals(a, sourceId)) b else a |
| 92 | + } else { |
| 93 | + b |
| 94 | + } |
| 95 | + if (relatedId != null) targetIds.add(relatedId) |
| 96 | + } |
| 97 | + } |
| 98 | + } |
| 99 | + if (targetIds.isEmpty()) return emptyList() |
| 100 | + |
| 101 | + // 3. Load the targets by PK. |
| 102 | + val targetTable = tableName(target) |
| 103 | + val targetPk = primaryKeyField(target) |
| 104 | + val targetCols = columnNames(target) |
| 105 | + val placeholders = targetIds.joinToString(",") { "?" } |
| 106 | + val rows = ArrayList<Map<String, Any?>>(targetIds.size) |
| 107 | + conn.prepareStatement( |
| 108 | + "SELECT * FROM \"$targetTable\" WHERE \"$targetPk\" IN ($placeholders)" |
| 109 | + ).use { ps -> |
| 110 | + targetIds.forEachIndexed { i, id -> ps.setObject(i + 1, id) } |
| 111 | + ps.executeQuery().use { rs -> |
| 112 | + while (rs.next()) { |
| 113 | + val row = LinkedHashMap<String, Any?>(targetCols.size) |
| 114 | + for (col in targetCols) row[col] = rs.getObject(col) |
| 115 | + rows.add(row) |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + return rows |
| 120 | + } |
| 121 | + |
| 122 | + // --- helpers ----------------------------------------------------------- |
| 123 | + |
| 124 | + /** The physical table name of an entity (`source.rdb @table`). */ |
| 125 | + private fun tableName(mc: MetaObject): String = |
| 126 | + mc.primaryRdbTableName |
| 127 | + ?: error("Entity '${mc.shortName}' has no source.rdb @table — required for M:N resolution") |
| 128 | + |
| 129 | + /** The single primary-key field name of an entity. */ |
| 130 | + private fun primaryKeyField(mc: MetaObject): String { |
| 131 | + val pk = mc.primaryIdentity |
| 132 | + ?: error("Entity '${mc.shortName}' has no identity.primary") |
| 133 | + val fields = pk.fields |
| 134 | + require(!fields.isNullOrEmpty()) { "identity.primary on '${mc.shortName}' declares no @fields" } |
| 135 | + return fields[0] |
| 136 | + } |
| 137 | + |
| 138 | + /** Physical column names of an entity (metadata field names; the corpus declares no @column renames). */ |
| 139 | + private fun columnNames(mc: MetaObject): List<String> = mc.metaFields.map { it.name } |
| 140 | + |
| 141 | + /** |
| 142 | + * Compare two key values by string-coerced identity. The source id comes from |
| 143 | + * the scenario `by:` (e.g. an Int) while the junction FK value comes straight |
| 144 | + * off the driver (which may surface a BIGINT key as a different numeric type); |
| 145 | + * comparing by toString() bridges that the same way the TS / Java resolvers do. |
| 146 | + */ |
| 147 | + private fun keyEquals(a: Any?, b: Any?): Boolean { |
| 148 | + if (a == null || b == null) return a === b |
| 149 | + return a.toString() == b.toString() |
| 150 | + } |
| 151 | + |
| 152 | + private fun mustGetEntity(root: MetaRoot, name: String?): MetaObject { |
| 153 | + val bare = stripPackage(name) |
| 154 | + for (child in root.getChildren(MetaObject::class.java, false)) { |
| 155 | + if (bare == child.shortName) return child |
| 156 | + } |
| 157 | + error("Entity '$name' not found in model root") |
| 158 | + } |
| 159 | + |
| 160 | + private fun stripPackage(name: String?): String? { |
| 161 | + if (name == null) return null |
| 162 | + val idx = name.lastIndexOf(MetaData.PKG_SEPARATOR) |
| 163 | + return if (idx >= 0) name.substring(idx + MetaData.PKG_SEPARATOR.length) else name |
| 164 | + } |
| 165 | +} |
0 commit comments