Skip to content

Commit 1b6a9f0

Browse files
committed
feat(java omdb): close the 3 port gaps that kept bootstrap-canonical red
Cross-language persistence-conformance Java runner now 3/3 green (was 2/3 + 1 documented skip). ExpectedSchemaBuilder rewrite: - NOT-NULL inferred from `identity.primary` membership OR `@required` (matches TS/C#). Legacy `@dbNullable` still honored on top. - Unique-index emission from `identity.secondary` (uses getShortName() to get the bare identifier; getName() would return the FQN like `fitness::byTitle`). - FK emission from `identity.reference`, with @onDelete/@onUpdate paired off the matching `MetaRelationship` by @objectref. - Two-pass walk: tables/entity-map first, view bodies second (need root-level resolution for @from/@via cross-entity references). - Static helpers for clarity (isRequired/readNullable/primaryIdentityFieldNames). ViewBodyBuilder (new file): - Walks projection field origins. v1 supports passthrough (no @via) → plain base column, and aggregate (@agg/@of/@via) → correlated subquery over a to-many. Passthrough-with-via, collection, and multi-base projections return null (caller drops the view; cross-port: TS also drops). Port of `expected-views.ts` / `PostgresSchema.CreateView`. Identifiers quoted throughout for mixed-case round-trip through PG. - relationshipByShortName + referenceIdentityToward + resolveParentField helpers split out for readability. SchemaDiffer: - AddIndex + AddFk now emitted alongside CreateTable for new tables (matches TS migrate-ts diff()). Gated on the new-table path; rename-table branch unaffected (renamed tables keep their existing index/FK shapes). MigrationScenarioTests: - EXPECTED_FAILURES emptied. Kept as scaffolding for future deferrals matching C# + TS expected-failures pattern. Gates: Java metadata 634/634, om 30/30, omdb 30/30, integration 3/3. Other-port gates unchanged. Phase 2 (query scenarios via ObjectManagerDB + Expression) still pending — needs a small QuerySpec-DSL → getObjects/getObjectsCount adapter (ObjectManagerDB is method-based, not LINQ-shaped, so the adapter is bounded). Tracked in the `java-persistence-conformance-status` memory note.
1 parent 5c081f6 commit 1b6a9f0

4 files changed

Lines changed: 369 additions & 52 deletions

File tree

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

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,26 +31,13 @@
3131
final class MigrationScenarioTests {
3232

3333
/**
34-
* Scenarios deferred until the underlying Java port gap is closed. The
35-
* harness still loads + parses them; we abort with the documented reason
36-
* before running them so {@code BUILD SUCCESS} stays meaningful.
34+
* Scenarios deferred until the underlying Java port gap is closed. Empty
35+
* today — closing the three previous gaps (index/FK emission, NOT-NULL
36+
* inference, projection view-body builder) made all three migration
37+
* scenarios green. Kept as a Map so a future expected-fail can be added
38+
* with a one-line reason matching the C# and TS expected-failures patterns.
3739
*/
38-
private static final Map<String, String> EXPECTED_FAILURES = Map.of(
39-
"bootstrap-canonical-from-empty",
40-
// Three independent Java gaps surface on the canonical fixture:
41-
// 1. ExpectedSchemaBuilder.tableDescriptor returns indexes=[] / foreignKeys=[]
42-
// (`indexes: empty in v1` literal in the source), so unique-index +
43-
// FK CREATE statements never appear in the up SQL.
44-
// 2. SimpleMappingHandlerDB doesn't surface @required / identity.primary
45-
// NOT-NULL on columns from the new-metadata layer.
46-
// 3. The view-DDL pipeline emits `CREATE OR REPLACE VIEW "v_program" AS null`
47-
// because Java has no port of the TS expected-views.ts / C#
48-
// PostgresSchema.CreateView origin-walker.
49-
// Track-and-skip pattern matches `conformance-expected-failures.json`.
50-
"deferred: Java omdb migration pipeline needs index/FK emission, "
51-
+ "NOT-NULL inference from identity.primary + @required, and a "
52-
+ "projection-view body builder porting passthrough/aggregate origins"
53-
);
40+
private static final Map<String, String> EXPECTED_FAILURES = Map.of();
5441

5542
private static List<MigrationScenario> SCENARIOS;
5643

Lines changed: 173 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package com.metaobjects.manager.db.migrate;
22

3+
import com.metaobjects.MetaData;
34
import com.metaobjects.database.CoreDBMetaDataProvider;
45
import com.metaobjects.field.MetaField;
6+
import com.metaobjects.identity.MetaIdentity;
7+
import com.metaobjects.identity.PrimaryIdentity;
8+
import com.metaobjects.identity.ReferenceIdentity;
9+
import com.metaobjects.identity.SecondaryIdentity;
510
import com.metaobjects.loader.MetaDataLoader;
611
import com.metaobjects.manager.db.MappingHandler;
712
import com.metaobjects.manager.db.ObjectManagerDB;
@@ -10,24 +15,37 @@
1015
import com.metaobjects.manager.db.defs.BaseDef;
1116
import com.metaobjects.manager.db.defs.ColumnDef;
1217
import com.metaobjects.manager.db.defs.TableDef;
13-
import com.metaobjects.manager.db.defs.ViewDef;
1418
import com.metaobjects.manager.db.migrate.SchemaSnapshot.ColumnDescriptor;
19+
import com.metaobjects.manager.db.migrate.SchemaSnapshot.FkDescriptor;
20+
import com.metaobjects.manager.db.migrate.SchemaSnapshot.IndexDescriptor;
1521
import com.metaobjects.manager.db.migrate.SchemaSnapshot.TableDescriptor;
1622
import com.metaobjects.manager.db.migrate.SchemaSnapshot.ViewDescriptor;
1723
import com.metaobjects.object.MetaObject;
1824
import com.metaobjects.registry.MetaDataLoaderRegistry;
25+
import com.metaobjects.relationship.MetaRelationship;
26+
import com.metaobjects.source.MetaSource;
1927

2028
import java.util.ArrayList;
2129
import java.util.Collection;
2230
import java.util.LinkedHashMap;
2331
import java.util.List;
2432
import java.util.Map;
33+
import java.util.Set;
2534

2635
/**
27-
* Builds the EXPECTED {@link SchemaSnapshot} from metadata, reusing the same
28-
* MappingHandler-derived TableDef/ViewDef the boot-time validator uses (no re-derivation)
29-
* and the mapped type carried on ColumnDef (converted to canonical SqlType via
30-
* {@link JdbcSqlTypes}). Also harvests {@code @previousName} into {@link RenameHints}.
36+
* Builds the EXPECTED {@link SchemaSnapshot} from metadata.
37+
*
38+
* <p>Column shapes still come through the legacy {@link MappingHandler}-derived
39+
* {@link TableDef} (so existing object-mapping consumers like the Derby
40+
* fixture path keep working), but everything new-style enrichment lives here:
41+
* NOT-NULL inference from {@code identity.primary} + {@code @required},
42+
* unique-index emission from {@code identity.secondary}, FK emission from
43+
* {@code identity.reference}, and projection view bodies computed from
44+
* {@code origin.passthrough} / {@code origin.aggregate} children.</p>
45+
*
46+
* <p>Cross-port: mirrors {@code expected-schema.ts} + {@code expected-views.ts}
47+
* (TS) and {@code MetaObjects.Codegen.Migrate.ExpectedSchema} +
48+
* {@code MetaObjects.Codegen.Schema.PostgresSchema.CreateView} (C#).</p>
3149
*/
3250
public final class ExpectedSchemaBuilder {
3351

@@ -39,95 +57,203 @@ public ExpectedSchemaBuilder(ObjectManagerDB manager, MetaDataLoaderRegistry loa
3957
this.loaderRegistry = loaderRegistry;
4058
}
4159

42-
/**
43-
* Build the snapshot; fills {@code hints} as a side-effect.
44-
* Pass {@link RenameHints#empty()} if rename tracking is not needed.
45-
*/
60+
/** Build the snapshot; fills {@code hints} as a side-effect. */
4661
public SchemaSnapshot build(RenameHints hints) {
4762
MappingHandler mh = manager.getMappingHandler();
4863
Map<String, TableDescriptor> tables = new LinkedHashMap<>();
4964
Map<String, ViewDescriptor> views = new LinkedHashMap<>();
5065

66+
// Pass 1: build tables + collect entities by short-name + harvest renames.
67+
// View bodies need the name→entity map to resolve `@from`/`@via` cross-entity refs,
68+
// so the view pass runs after this loop completes.
69+
Map<String, MetaObject> entityByName = new LinkedHashMap<>();
5170
for (MetaDataLoader loader : loaders()) {
5271
for (MetaObject mc : loader.getMetaObjects()) {
53-
// Create mapping → TableDef (writable side)
72+
entityByName.putIfAbsent(mc.getShortName(), mc);
5473
BaseDef createDef = defOf(mh.getCreateMapping(mc));
5574
if (createDef instanceof TableDef t) {
5675
tables.putIfAbsent(t.getNameDef().getFullname(), tableDescriptor(t, mc));
5776
}
58-
59-
// Read mapping → ViewDef (read side, if any)
60-
BaseDef readDef = defOf(mh.getReadMapping(mc));
61-
if (readDef instanceof ViewDef v) {
62-
views.putIfAbsent(v.getNameDef().getFullname(),
63-
new ViewDescriptor(v.getNameDef().getName(), v.getNameDef().getSchema(), v.getSQL()));
64-
}
65-
6677
harvestRenameHints(mc, hints);
6778
}
6879
}
80+
81+
// Pass 2: projection view bodies derived from origin children.
82+
for (MetaObject mc : entityByName.values()) {
83+
String viewName = mc.getPrimaryRdbViewName();
84+
if (viewName == null) continue;
85+
String body = ViewBodyBuilder.buildSql(mc, entityByName);
86+
if (body == null) continue; // unresolvable projection — leave out (cross-port: TS also drops)
87+
String viewSchema = mc.findPrimaryReadOnlySource().map(MetaSource::getSchema).orElse(null);
88+
String fullName = (viewSchema == null ? "" : viewSchema + ".") + viewName;
89+
views.putIfAbsent(fullName, new ViewDescriptor(viewName, viewSchema, body));
90+
}
91+
6992
return new SchemaSnapshot(new ArrayList<>(tables.values()), new ArrayList<>(views.values()));
7093
}
7194

7295
// -------------------------------------------------------------------------
73-
// Private helpers
96+
// Table descriptor (columns + indexes + FKs + PK)
7497
// -------------------------------------------------------------------------
7598

7699
private TableDescriptor tableDescriptor(TableDef t, MetaObject mc) {
77100
List<ColumnDescriptor> cols = new ArrayList<>();
78101
List<String> pk = new ArrayList<>();
102+
Set<String> pkFieldNames = primaryIdentityFieldNames(mc);
79103

80104
for (ColumnDef cd : t.getColumns()) {
81105
if (cd.getName() == null) continue;
82106
MetaField<?> mf = fieldForColumn(mc, cd.getName());
83107
SqlType sqlType = JdbcSqlTypes.fromJdbc(cd.getSQLType(), cd.getLength());
84-
boolean nullable = mf == null || readNullable(mf); // default: nullable unless @dbNullable=false
85-
// identity is informational in v1; not surfaced here
108+
// Unmapped columns default to nullable; mapped columns are nullable unless part of the
109+
// primary identity, marked @required, or explicitly @dbNullable=false.
110+
boolean isPk = mf != null && pkFieldNames.contains(mf.getName());
111+
boolean nullable = mf == null || (!isPk && !isRequired(mf) && readNullable(mf));
86112
cols.add(new ColumnDescriptor(cd.getName(), sqlType, nullable, null));
87-
if (cd.isPrimaryKey()) pk.add(cd.getName());
113+
if (cd.isPrimaryKey() || isPk) pk.add(cd.getName());
88114
}
89115

90116
return new TableDescriptor(
91117
t.getNameDef().getName(),
92118
t.getNameDef().getSchema(),
93119
cols,
94-
List.of(), // indexes: empty in v1 (create-table stays additive)
95-
List.of(), // foreign keys: empty in v1
120+
buildIndexes(mc),
121+
buildForeignKeys(mc),
96122
pk);
97123
}
98124

99-
private boolean readNullable(MetaField<?> mf) {
100-
if (!mf.hasMetaAttr(CoreDBMetaDataProvider.DB_NULLABLE)) return true;
101-
return !"false".equals(mf.getMetaAttr(CoreDBMetaDataProvider.DB_NULLABLE).getValueAsString());
125+
/** True when {@code @required} is explicitly set to "true". */
126+
private static boolean isRequired(MetaField<?> mf) {
127+
return mf.hasMetaAttr(MetaField.ATTR_REQUIRED)
128+
&& "true".equalsIgnoreCase(mf.getMetaAttr(MetaField.ATTR_REQUIRED).getValueAsString());
129+
}
130+
131+
/** True (nullable) unless the legacy {@code @dbNullable} attr explicitly says "false". */
132+
private static boolean readNullable(MetaField<?> mf) {
133+
return !mf.hasMetaAttr(CoreDBMetaDataProvider.DB_NULLABLE)
134+
|| !"false".equals(mf.getMetaAttr(CoreDBMetaDataProvider.DB_NULLABLE).getValueAsString());
135+
}
136+
137+
private static Set<String> primaryIdentityFieldNames(MetaObject mc) {
138+
PrimaryIdentity pk = mc.getPrimaryIdentity();
139+
return pk == null ? Set.of() : Set.copyOf(pk.getFields());
140+
}
141+
142+
// -------------------------------------------------------------------------
143+
// Indexes (secondary identities)
144+
// -------------------------------------------------------------------------
145+
146+
private List<IndexDescriptor> buildIndexes(MetaObject mc) {
147+
List<IndexDescriptor> indexes = new ArrayList<>();
148+
for (SecondaryIdentity sec : mc.getSecondaryIdentities()) {
149+
List<String> cols = sec.getFields().stream().map(n -> columnFor(mc, n)).toList();
150+
if (cols.isEmpty()) continue;
151+
// Secondary identities are unique unless explicitly @unique:false (mirrors TS).
152+
boolean unique = !"false".equalsIgnoreCase(attrOrNull(sec, "unique"));
153+
// Use the short name (Java's getName() returns the FQN like "fitness::byTitle";
154+
// the cross-port assertion vocabulary expects the bare identifier).
155+
indexes.add(new IndexDescriptor(sec.getShortName(), cols, unique));
156+
}
157+
return indexes;
158+
}
159+
160+
// -------------------------------------------------------------------------
161+
// Foreign keys (reference identities)
162+
// -------------------------------------------------------------------------
163+
164+
private List<FkDescriptor> buildForeignKeys(MetaObject mc) {
165+
String owningTable = mc.getPrimaryRdbTableName();
166+
if (owningTable == null) return List.of();
167+
168+
List<FkDescriptor> fks = new ArrayList<>();
169+
for (MetaIdentity id : mc.getIdentities()) {
170+
if (!(id instanceof ReferenceIdentity ref) || !ref.isEnforced()) continue;
171+
String targetName = ref.getTargetEntity();
172+
if (targetName == null || ref.getFields().isEmpty()) continue;
173+
MetaObject target = findEntity(targetName);
174+
if (target == null) continue;
175+
String refTable = target.getPrimaryRdbTableName();
176+
if (refTable == null) continue;
177+
178+
List<String> cols = ref.getFields().stream().map(n -> columnFor(mc, n)).toList();
179+
List<String> refTargetFields = ref.getTargetFields();
180+
List<String> refCols = refTargetFields.size() > 1
181+
? refTargetFields.stream().map(n -> columnFor(target, n)).toList()
182+
: List.of(columnFor(target, resolvedTargetPkField(target, ref)));
183+
184+
String onDelete = matchingRelationshipAction(mc, targetName, true);
185+
String onUpdate = matchingRelationshipAction(mc, targetName, false);
186+
187+
String name = owningTable + "_" + cols.get(0) + "_fk";
188+
fks.add(new FkDescriptor(name, cols, refTable, refCols, onDelete, onUpdate));
189+
}
190+
return fks;
191+
}
192+
193+
private String resolvedTargetPkField(MetaObject target, ReferenceIdentity fk) {
194+
if (fk.getTargetFields().size() == 1) return fk.getTargetFields().get(0);
195+
PrimaryIdentity pk = target.getPrimaryIdentity();
196+
if (pk != null && !pk.getFields().isEmpty()) return pk.getFields().get(0);
197+
return "id";
198+
}
199+
200+
/** Pair a reference identity to its matching relationship by @objectRef, read its @onDelete/@onUpdate. */
201+
private static String matchingRelationshipAction(MetaObject owner, String targetEntity, boolean delete) {
202+
// Strip package qualifier once (handles both "Target" and "pkg::Target" forms).
203+
String bareTarget = stripPkg(targetEntity);
204+
for (MetaRelationship rel : owner.getRelationships()) {
205+
String objectRef = rel.getObjectRef();
206+
if (objectRef == null || !stripPkg(objectRef).equals(bareTarget)) continue;
207+
return delete ? rel.getOnDeleteRaw() : rel.getOnUpdateRaw();
208+
}
209+
return null;
102210
}
103211

212+
// -------------------------------------------------------------------------
213+
// Field-name / column-name helpers
214+
// -------------------------------------------------------------------------
215+
104216
private MetaField<?> fieldForColumn(MetaObject mc, String column) {
105217
for (MetaField<?> mf : mc.getMetaFields()) {
106218
if (column.equalsIgnoreCase(columnNameOf(mf))) return mf;
107219
}
108220
return null;
109221
}
110222

223+
private static String columnFor(MetaObject mc, String fieldName) {
224+
for (MetaField<?> mf : mc.getMetaFields()) {
225+
if (mf.getName().equals(fieldName)) return columnNameOf(mf);
226+
}
227+
return fieldName; // fallback to bare name (matches cross-port stale-FK behavior)
228+
}
229+
111230
/** The field's column name: {@code @column} when present, otherwise the field name. */
112231
private static String columnNameOf(MetaField<?> mf) {
113232
return mf.hasMetaAttr(CoreDBMetaDataProvider.COLUMN)
114233
? mf.getMetaAttr(CoreDBMetaDataProvider.COLUMN).getValueAsString()
115234
: mf.getName();
116235
}
117236

237+
private static String stripPkg(String name) {
238+
int i = name.lastIndexOf("::");
239+
return i < 0 ? name : name.substring(i + 2);
240+
}
241+
242+
private static String attrOrNull(MetaData md, String attr) {
243+
return md.hasMetaAttr(attr) ? md.getMetaAttr(attr).getValueAsString() : null;
244+
}
245+
246+
// -------------------------------------------------------------------------
247+
// Rename hints
248+
// -------------------------------------------------------------------------
249+
118250
private void harvestRenameHints(MetaObject mc, RenameHints hints) {
119-
// Determine the table name for this object (source-v2 ADR-0007:
120-
// primary writable source.rdb @table; nothing if no such source).
121251
String table = mc.getPrimaryRdbTableName();
122252
if (table == null) return;
123-
124-
// Table-level rename
125253
if (mc.hasMetaAttr(CoreDBMetaDataProvider.PREVIOUS_NAME)) {
126254
hints.addTableRename(table,
127255
mc.getMetaAttr(CoreDBMetaDataProvider.PREVIOUS_NAME).getValueAsString());
128256
}
129-
130-
// Column-level renames
131257
for (MetaField<?> mf : mc.getMetaFields()) {
132258
if (mf.hasMetaAttr(CoreDBMetaDataProvider.PREVIOUS_NAME)) {
133259
hints.addColumnRename(table, columnNameOf(mf),
@@ -136,6 +262,10 @@ private void harvestRenameHints(MetaObject mc, RenameHints hints) {
136262
}
137263
}
138264

265+
// -------------------------------------------------------------------------
266+
// Misc
267+
// -------------------------------------------------------------------------
268+
139269
private static BaseDef defOf(ObjectMapping m) {
140270
return (m instanceof ObjectMappingDB db) ? db.getDBDef() : null;
141271
}
@@ -145,4 +275,14 @@ private Collection<MetaDataLoader> loaders() {
145275
? loaderRegistry.getDataLoaders()
146276
: com.metaobjects.util.MetaDataUtil.getAllMetaDataLoaders(this);
147277
}
278+
279+
private MetaObject findEntity(String name) {
280+
String bare = stripPkg(name);
281+
for (MetaDataLoader loader : loaders()) {
282+
for (MetaObject mc : loader.getMetaObjects()) {
283+
if (mc.getShortName().equals(bare)) return mc;
284+
}
285+
}
286+
return null;
287+
}
148288
}

server/java/omdb/src/main/java/com/metaobjects/manager/db/migrate/SchemaDiffer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ public DiffResult diff(SchemaSnapshot expected, SchemaSnapshot actual, AllowOpti
2727
diffColumns(e, prevTable, hints, changes); // then converge columns of the renamed table
2828
} else {
2929
changes.add(new Change.CreateTable(e)); // new table: columns ride with CREATE TABLE
30+
// Indexes + FKs are separate DDL statements; emit alongside the CREATE TABLE
31+
// so they're applied at the same point in the pipeline (matches TS migrate-ts).
32+
for (IndexDescriptor idx : e.indexes())
33+
changes.add(new Change.AddIndex(e.name(), e.schema(), idx));
34+
for (FkDescriptor fk : e.foreignKeys())
35+
changes.add(new Change.AddFk(e.name(), e.schema(), fk));
3036
}
3137
} else {
3238
diffColumns(e, a, hints, changes);

0 commit comments

Comments
 (0)