11package com .metaobjects .manager .db .migrate ;
22
3+ import com .metaobjects .MetaData ;
34import com .metaobjects .database .CoreDBMetaDataProvider ;
45import 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 ;
510import com .metaobjects .loader .MetaDataLoader ;
611import com .metaobjects .manager .db .MappingHandler ;
712import com .metaobjects .manager .db .ObjectManagerDB ;
1015import com .metaobjects .manager .db .defs .BaseDef ;
1116import com .metaobjects .manager .db .defs .ColumnDef ;
1217import com .metaobjects .manager .db .defs .TableDef ;
13- import com .metaobjects .manager .db .defs .ViewDef ;
1418import 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 ;
1521import com .metaobjects .manager .db .migrate .SchemaSnapshot .TableDescriptor ;
1622import com .metaobjects .manager .db .migrate .SchemaSnapshot .ViewDescriptor ;
1723import com .metaobjects .object .MetaObject ;
1824import com .metaobjects .registry .MetaDataLoaderRegistry ;
25+ import com .metaobjects .relationship .MetaRelationship ;
26+ import com .metaobjects .source .MetaSource ;
1927
2028import java .util .ArrayList ;
2129import java .util .Collection ;
2230import java .util .LinkedHashMap ;
2331import java .util .List ;
2432import 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 */
3250public 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}
0 commit comments