Skip to content

Commit cba4107

Browse files
dmealingclaude
andcommitted
feat(omdb): FR-017 Unit6 — Java runtime M:N resolver (hetero/self-join/symmetric)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a14efbe commit cba4107

7 files changed

Lines changed: 299 additions & 14 deletions

File tree

server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,25 @@ public static String canonicalRowsJson(List<Map<String, Object>> rows) {
159159
throw new RuntimeException(e);
160160
}
161161
}
162+
163+
/**
164+
* Canonical JSON for an ORDER-INDEPENDENT row set ({@code op:relate} — M:N
165+
* navigation). Mirrors the TS runner's {@code canonicalRowSet}: each row is
166+
* normalized + serialized, then the per-row JSON strings are sorted so the
167+
* comparison is port-agnostic regardless of the order the resolver returns
168+
* the related rows. ({@code op:list} keeps its order — the scenario pins it
169+
* via {@code sort:}.)
170+
*/
171+
public static String canonicalRowSet(List<Map<String, Object>> rows) {
172+
List<String> each = new ArrayList<>(rows.size());
173+
for (Map<String, Object> row : rows) {
174+
try {
175+
each.add(new String(MAPPER.writeValueAsBytes(normalizeRow(row)), StandardCharsets.UTF_8));
176+
} catch (Exception e) {
177+
throw new RuntimeException(e);
178+
}
179+
}
180+
each.sort(null);
181+
return "[" + String.join(",", each) + "]";
182+
}
162183
}

server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package com.metaobjects.integration;
22

33
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.metaobjects.MetaRoot;
45
import com.metaobjects.database.CoreDBMetaDataProvider;
56
import com.metaobjects.field.MetaField;
67
import com.metaobjects.integration.Scenarios.QuerySpec;
78
import com.metaobjects.integration.Scenarios.SortSpec;
89
import com.metaobjects.manager.ObjectConnection;
910
import com.metaobjects.manager.QueryOptions;
11+
import com.metaobjects.manager.db.M2MResolver;
1012
import com.metaobjects.manager.db.ObjectManagerDB;
1113
import com.metaobjects.manager.exp.Expression;
1214
import com.metaobjects.manager.exp.Range;
1315
import com.metaobjects.manager.exp.SortOrder;
1416
import com.metaobjects.object.MetaObject;
1517
import com.metaobjects.object.value.ValueObject;
18+
import com.metaobjects.relationship.MetaRelationship;
1619

1720
import java.sql.Types;
1821
import java.time.ZoneOffset;
@@ -38,13 +41,24 @@ private ObjectManagerDbAdapter() {}
3841
/**
3942
* Execute one {@link QuerySpec} against an open connection. Returns:
4043
* <ul>
41-
* <li>{@code op:list} → {@code List<Map<String,Object>>} of normalized rows</li>
42-
* <li>{@code op:get} → {@code Map<String,Object>} or {@code null}</li>
43-
* <li>{@code op:count} → {@code Long}</li>
44+
* <li>{@code op:list} → {@code List<Map<String,Object>>} of normalized rows</li>
45+
* <li>{@code op:get} → {@code Map<String,Object>} or {@code null}</li>
46+
* <li>{@code op:count} → {@code Long}</li>
47+
* <li>{@code op:relate} → {@code List<Map<String,Object>>} of related target
48+
* rows (order-independent — see {@link Normalization}); traverses an M:N
49+
* relationship via {@link M2MResolver}.</li>
4450
* </ul>
51+
*
52+
* @param root the loaded model root (needed by op:relate to find the junction +
53+
* target entities); other ops ignore it.
4554
*/
4655
static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc, QuerySpec spec,
47-
Map<String, Integer> columnSqlTypes) throws Exception {
56+
Map<String, Integer> columnSqlTypes, MetaRoot root,
57+
ColumnTypeProbe columnTypeProbe) throws Exception {
58+
if ("relate".equals(spec.op())) {
59+
return executeRelate(omdb, conn, mc, spec, root, columnTypeProbe);
60+
}
61+
4862
Expression filter = buildFilter(spec.by(), spec.filter());
4963

5064
if ("count".equals(spec.op())) {
@@ -64,6 +78,66 @@ static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc
6478
return rows; // op:list
6579
}
6680

81+
/** Probes the actual SQL column types for a target entity's relation, on demand. */
82+
@FunctionalInterface
83+
interface ColumnTypeProbe {
84+
Map<String, Integer> probe(MetaObject mc);
85+
}
86+
87+
/**
88+
* op:relate — traverse an M:N relationship from a single source object to its
89+
* related target rows. Loads the source by {@code by:{...}}, locates the named
90+
* {@code relation}, delegates the junction traversal + target load to the
91+
* generic {@link M2MResolver}, then normalizes the target rows. The {@code relate}
92+
* verb is order-independent (the runner sorts both sides before comparing).
93+
*/
94+
private static Object executeRelate(ObjectManagerDB omdb, ObjectConnection conn, MetaObject sourceMeta,
95+
QuerySpec spec, MetaRoot root, ColumnTypeProbe columnTypeProbe) throws Exception {
96+
// 1. Load the single source object identified by `by`.
97+
Expression byFilter = buildFilter(spec.by(), null);
98+
QueryOptions opts = new QueryOptions();
99+
if (byFilter != null) opts.setExpression(byFilter);
100+
Collection<?> sources = omdb.getObjects(conn, sourceMeta, opts);
101+
if (sources.isEmpty()) return List.of(); // unknown source → no related rows
102+
103+
Object source = sources.iterator().next();
104+
105+
// 2. Find the named M:N relationship on the source entity.
106+
MetaRelationship rel = findRelationship(sourceMeta, spec.relation());
107+
if (rel == null) {
108+
throw new AssertionError("op:relate / " + spec.name() + ": no relationship named '"
109+
+ spec.relation() + "' on entity '" + sourceMeta.getShortName() + "'");
110+
}
111+
112+
// 3. Resolve the related targets via the generic OMDB M:N resolver.
113+
Collection<?> related = new M2MResolver(omdb).resolve(conn, source, sourceMeta, rel, root);
114+
115+
// 4. Normalize the target rows (keyed by the TARGET entity's metadata).
116+
MetaObject targetMeta = mustGetEntity(root, rel.getObjectRef());
117+
Map<String, Integer> targetColumnSqlTypes = columnTypeProbe.probe(targetMeta);
118+
List<Map<String, Object>> rows = new ArrayList<>(related.size());
119+
for (Object o : related) rows.add(toRowMap(targetMeta, o, targetColumnSqlTypes));
120+
return rows;
121+
}
122+
123+
private static MetaRelationship findRelationship(MetaObject mc, String relationName) {
124+
if (relationName == null) return null;
125+
for (MetaRelationship rel : mc.getRelationships()) {
126+
if (relationName.equals(rel.getShortName())) return rel;
127+
}
128+
return null;
129+
}
130+
131+
private static MetaObject mustGetEntity(MetaRoot root, String name) {
132+
String bare = name;
133+
int idx = name.lastIndexOf(com.metaobjects.MetaData.PKG_SEPARATOR);
134+
if (idx >= 0) bare = name.substring(idx + com.metaobjects.MetaData.PKG_SEPARATOR.length());
135+
for (MetaObject mc : root.getChildren(MetaObject.class, false)) {
136+
if (bare.equals(mc.getShortName())) return mc;
137+
}
138+
throw new IllegalStateException("Entity '" + name + "' not found in model root");
139+
}
140+
67141
// -----------------------------------------------------------------------
68142
// Filter translation
69143
// -----------------------------------------------------------------------

server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioRunner.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,13 @@ public static void run(QueryScenario scenario, PostgresContainer pg, Path canoni
7777
": no MetaObject named '" + spec.entity() + "' in canonical loader");
7878
Map<String, Integer> columnSqlTypes = columnTypeCache.computeIfAbsent(
7979
mc, m -> probeColumnSqlTypes(pg, m));
80-
Object actual = ObjectManagerDbAdapter.execute(omdb, oc, mc, spec, columnSqlTypes);
80+
// op:relate normalizes the TARGET entity's rows, so the adapter needs
81+
// to probe column types for an entity other than `mc`; hand it a probe
82+
// that hits the same per-scenario cache.
83+
ObjectManagerDbAdapter.ColumnTypeProbe probe =
84+
m -> columnTypeCache.computeIfAbsent(m, x -> probeColumnSqlTypes(pg, x));
85+
Object actual = ObjectManagerDbAdapter.execute(
86+
omdb, oc, mc, spec, columnSqlTypes, loader.getRoot(), probe);
8187
assertResult(scenario.sourcePath(), spec, actual);
8288
}
8389
} finally {
@@ -147,6 +153,11 @@ private static String canonicalizeExpected(Object expect, String op) {
147153
: Long.parseLong(String.valueOf(expect));
148154
return Long.toString(n);
149155
}
156+
// op:relate is an ORDER-INDEPENDENT set (M:N navigation) — sort both sides.
157+
if ("relate".equals(op)) {
158+
if (expect == null) return "[]";
159+
return Normalization.canonicalRowSet((List<Map<String, Object>>) expect);
160+
}
150161
if ("get".equals(op)) {
151162
if (expect == null) return "null";
152163
return Normalization.canonicalRowsJson(List.of((Map<String, Object>) expect))
@@ -161,6 +172,10 @@ private static String canonicalizeExpected(Object expect, String op) {
161172
@SuppressWarnings("unchecked")
162173
private static String canonicalizeActual(Object actual, String op) {
163174
if ("count".equals(op)) return Long.toString(actual instanceof Number n ? n.longValue() : 0L);
175+
if ("relate".equals(op)) {
176+
if (actual == null) return "[]";
177+
return Normalization.canonicalRowSet((List<Map<String, Object>>) actual);
178+
}
164179
if (actual == null) return "null";
165180
if ("get".equals(op)) {
166181
return Normalization.canonicalRowsJson(List.of((Map<String, Object>) actual))

server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioTests.java

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,21 @@ static void beforeAll() {
7070
// conflicting bindings, so we only set when none is set or the bindings
7171
// already include ours).
7272
ObjectClassRegistry reg = new ObjectClassRegistry();
73-
reg.register(() -> Map.of(
74-
"fitness::Program", ValueObject.class,
75-
"fitness::Week", ValueObject.class,
76-
"fitness::Measurement", ValueObject.class,
77-
"fitness::Asset", ValueObject.class,
78-
"fitness::ProgramView", ValueObject.class,
79-
"fitness::ProgramStat", ValueObject.class
80-
));
73+
Map<String, Class<?>> bindings = new java.util.HashMap<>();
74+
bindings.put("fitness::Program", ValueObject.class);
75+
bindings.put("fitness::Week", ValueObject.class);
76+
bindings.put("fitness::Measurement", ValueObject.class);
77+
bindings.put("fitness::Asset", ValueObject.class);
78+
bindings.put("fitness::ProgramView", ValueObject.class);
79+
bindings.put("fitness::ProgramStat", ValueObject.class);
80+
// FR-017 M:N corpus entities (hetero + self-join junctions + targets).
81+
bindings.put("fitness::Post", ValueObject.class);
82+
bindings.put("fitness::Tag", ValueObject.class);
83+
bindings.put("fitness::PostTag", ValueObject.class);
84+
bindings.put("fitness::Person", ValueObject.class);
85+
bindings.put("fitness::Follow", ValueObject.class);
86+
bindings.put("fitness::Friendship", ValueObject.class);
87+
reg.register(() -> bindings);
8188
ObjectClassRegistry.setGlobal(reg);
8289
}
8390

server/java/integration-tests/src/test/java/com/metaobjects/integration/ScenarioLoader.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ private static QuerySpec parseQuerySpec(Map<String, Object> q) {
9494
sorts,
9595
asInt(q.get("limit")),
9696
asInt(q.get("offset")),
97+
(String) q.get("relation"),
9798
q.get("expect"));
9899
}
99100

server/java/integration-tests/src/test/java/com/metaobjects/integration/Scenarios.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ public record SortSpec(String field, String dir) {}
1414

1515
public record QuerySpec(
1616
String name,
17-
String op, // list | get | count
17+
String op, // list | get | count | relate
1818
String entity,
1919
Map<String, Object> by,
2020
Map<String, Object> filter,
2121
List<SortSpec> sort,
2222
Integer limit,
2323
Integer offset,
24+
String relation, // op:relate — the M:N relationship name to traverse
2425
Object expect
2526
) {}
2627

0 commit comments

Comments
 (0)