|
| 1 | +using MetaObjects.Codegen.Migrate; |
| 2 | +using MetaObjects.Loader; |
| 3 | +using MetaObjects.Meta; |
| 4 | +using Xunit; |
| 5 | + |
| 6 | +namespace MetaObjects.Codegen.Tests.Migrate; |
| 7 | + |
| 8 | +// Source-v2 (ADR-0007) metadata → SchemaSnapshot. The model below intentionally |
| 9 | +// exercises: primary writable source.rdb, schema-qualified source, @column override, |
| 10 | +// composite PK in @fields order, secondary unique identity, identity.reference with |
| 11 | +// @enforce true (FK emitted, OnDelete from the matching aggregation relationship) |
| 12 | +// and @enforce false (FK suppressed), a pure projection (read-only source — should |
| 13 | +// be skipped), and @storage flattened + jsonb object fields. |
| 14 | +public class ExpectedSchemaTests |
| 15 | +{ |
| 16 | + private const string Model = """ |
| 17 | + { "metadata.root": { "package": "acme", "children": [ |
| 18 | + { "object.value": { "name": "Address", "children": [ |
| 19 | + { "field.string": { "name": "street", "@required": true, "@maxLength": 120 } }, |
| 20 | + { "field.string": { "name": "city", "@maxLength": 80 } } |
| 21 | + ]}}, |
| 22 | + { "object.entity": { "name": "Program", "children": [ |
| 23 | + { "source.rdb": { "@table": "programs", "@schema": "billing" } }, |
| 24 | + { "field.long": { "name": "id" } }, |
| 25 | + { "field.string": { "name": "title", "@required": true, "@maxLength": 200 } }, |
| 26 | + { "field.currency": { "name": "priceCents" } }, |
| 27 | + { "field.enum": { "name": "status", "@values": ["draft", "active", "archived"] } }, |
| 28 | + { "field.string": { "name": "tags", "isArray": true } }, |
| 29 | + { "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } }, |
| 30 | + { "field.object": { "name": "config", "@objectRef": "Address" } }, |
| 31 | + { "relationship.aggregation": { "name": "weeks", "@objectRef": "Week", "@cardinality": "many" } }, |
| 32 | + { "identity.primary": { "@fields": "id", "@generation": "increment" } }, |
| 33 | + { "identity.secondary": { "name": "byTitle", "@fields": "title", "@unique": true } } |
| 34 | + ]}}, |
| 35 | + { "object.entity": { "name": "Bad", "children": [ |
| 36 | + { "source.rdb": { "@table": "bad" } }, |
| 37 | + { "field.long": { "name": "id" } }, |
| 38 | + { "field.long": { "name": "addressId" } }, |
| 39 | + { "identity.primary": { "@fields": "id", "@generation": "increment" } }, |
| 40 | + { "identity.reference": { "name": "fkAddress", "@fields": "addressId", "@references": "Address" } } |
| 41 | + ]}}, |
| 42 | + { "object.entity": { "name": "Week", "children": [ |
| 43 | + { "source.rdb": { "@table": "weeks" } }, |
| 44 | + { "field.long": { "name": "id" } }, |
| 45 | + { "field.long": { "name": "programId" } }, |
| 46 | + { "field.string": { "name": "label", "@column": "week_label" } }, |
| 47 | + { "identity.primary": { "@fields": "id", "@generation": "increment" } }, |
| 48 | + { "identity.reference": { "name": "fkProgram", "@fields": "programId", "@references": "Program" } }, |
| 49 | + { "relationship.composition": { "name": "program", "@objectRef": "Program", "@cardinality": "one" } } |
| 50 | + ]}}, |
| 51 | + { "object.entity": { "name": "Tag", "children": [ |
| 52 | + { "source.rdb": { "@table": "tags" } }, |
| 53 | + { "field.long": { "name": "id" } }, |
| 54 | + { "field.long": { "name": "programId" } }, |
| 55 | + { "identity.primary": { "@fields": "id", "@generation": "increment" } }, |
| 56 | + { "identity.reference": { "name": "fkProgramLogical", "@fields": "programId", "@references": "Program", "@enforce": false } } |
| 57 | + ]}}, |
| 58 | + { "object.entity": { "name": "Link", "children": [ |
| 59 | + { "source.rdb": { "@table": "links" } }, |
| 60 | + { "field.long": { "name": "a" } }, |
| 61 | + { "field.long": { "name": "b" } }, |
| 62 | + { "identity.primary": { "@fields": ["b", "a"] } } |
| 63 | + ]}}, |
| 64 | + { "object.value": { "name": "ProgramView", "children": [ |
| 65 | + { "source.rdb": { "@kind": "view", "@table": "v_program" } }, |
| 66 | + { "field.long": { "name": "id", "children": [ { "origin.passthrough": { "@from": "Program.id" } } ] } }, |
| 67 | + { "field.string": { "name": "title", "children": [ { "origin.passthrough": { "@from": "Program.title" } } ] } } |
| 68 | + ]}} |
| 69 | + ]}} |
| 70 | + """; |
| 71 | + |
| 72 | + private static SchemaSnapshot Load() |
| 73 | + { |
| 74 | + var r = new MetaDataLoader().Load([new InMemorySource(Model, id: "exp.json")]); |
| 75 | + Assert.Empty(r.Errors); |
| 76 | + return ExpectedSchema.Build(r.Root); |
| 77 | + } |
| 78 | + |
| 79 | + [Fact] |
| 80 | + public void Skips_value_objects_and_pure_projections() |
| 81 | + { |
| 82 | + var snap = Load(); |
| 83 | + // Address (value), ProgramView (read-only projection — only a read-only source). |
| 84 | + Assert.DoesNotContain(snap.Tables, t => t.Name is "Address" or "v_program"); |
| 85 | + // Only writable entities — alphabetical by metadata name: Bad, Link, Program, Tag, Week. |
| 86 | + Assert.Equal(["bad", "links", "programs", "tags", "weeks"], snap.Tables.Select(t => t.Name).ToArray()); |
| 87 | + } |
| 88 | + |
| 89 | + [Fact] |
| 90 | + public void Source_table_and_schema_threaded_through() |
| 91 | + { |
| 92 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 93 | + Assert.Equal("billing", program.Schema); // from source.rdb @schema |
| 94 | + var week = Load().Tables.Single(t => t.Name == "weeks"); |
| 95 | + Assert.Null(week.Schema); // no @schema → null (engine treats as "public") |
| 96 | + } |
| 97 | + |
| 98 | + [Fact] |
| 99 | + public void Pk_column_is_notnull_with_identity_and_in_declared_field_order() |
| 100 | + { |
| 101 | + var link = Load().Tables.Single(t => t.Name == "links"); |
| 102 | + // Composite PK declared as @fields ["b","a"] — must preserve that order. |
| 103 | + Assert.Equal(["b", "a"], link.PrimaryKey.ToArray()); |
| 104 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 105 | + var idCol = program.Columns.Single(c => c.Name == "id"); |
| 106 | + Assert.False(idCol.Nullable); |
| 107 | + Assert.Equal(IdentityKind.Increment, idCol.Identity); |
| 108 | + } |
| 109 | + |
| 110 | + [Fact] |
| 111 | + public void Column_override_uses_at_column_attr() |
| 112 | + { |
| 113 | + var week = Load().Tables.Single(t => t.Name == "weeks"); |
| 114 | + Assert.Contains(week.Columns, c => c.Name == "week_label"); // override applied |
| 115 | + Assert.DoesNotContain(week.Columns, c => c.Name == "label"); // raw name suppressed |
| 116 | + } |
| 117 | + |
| 118 | + [Fact] |
| 119 | + public void Scalar_types_map_to_canonical_sqltypes() |
| 120 | + { |
| 121 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 122 | + Assert.Equal(new SqlType.Text(200), program.Columns.Single(c => c.Name == "title").SqlType); |
| 123 | + // currency → integer minor units (bits=64), per the cross-language wire contract. |
| 124 | + Assert.Equal(new SqlType.Integer(64), program.Columns.Single(c => c.Name == "priceCents").SqlType); |
| 125 | + Assert.Equal(new SqlType.Integer(64), program.Columns.Single(c => c.Name == "id").SqlType); |
| 126 | + } |
| 127 | + |
| 128 | + [Fact] |
| 129 | + public void Secondary_identity_becomes_unique_index_with_bare_identity_name() |
| 130 | + { |
| 131 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 132 | + var idx = Assert.Single(program.Indexes); |
| 133 | + Assert.Equal("byTitle", idx.Name); // bare identity name (matches TS canonical; not "{table}_{name}_uniq") |
| 134 | + Assert.True(idx.Unique); |
| 135 | + Assert.Equal(["title"], idx.Columns.ToArray()); |
| 136 | + } |
| 137 | + |
| 138 | + [Fact] |
| 139 | + public void Enforced_reference_emits_fk_with_referential_actions_from_relationship() |
| 140 | + { |
| 141 | + var week = Load().Tables.Single(t => t.Name == "weeks"); |
| 142 | + var fk = Assert.Single(week.ForeignKeys); |
| 143 | + Assert.Equal("weeks_programId_fk", fk.Name); |
| 144 | + Assert.Equal(["programId"], fk.Columns.ToArray()); |
| 145 | + Assert.Equal("programs", fk.RefTable); |
| 146 | + Assert.Equal(["id"], fk.RefColumns.ToArray()); // bare @references "Program" → target PK |
| 147 | + // Week.program is a composition; per-subtype default is cascade for both. |
| 148 | + Assert.Equal(FkAction.Cascade, fk.OnDelete); |
| 149 | + Assert.Equal(FkAction.Cascade, fk.OnUpdate); |
| 150 | + } |
| 151 | + |
| 152 | + [Fact] |
| 153 | + public void Logical_only_reference_emits_no_fk() |
| 154 | + { |
| 155 | + var tag = Load().Tables.Single(t => t.Name == "tags"); |
| 156 | + Assert.Empty(tag.ForeignKeys); // @enforce: false → no FK |
| 157 | + } |
| 158 | + |
| 159 | + [Fact] |
| 160 | + public void Storage_flattened_expands_to_prefixed_columns() |
| 161 | + { |
| 162 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 163 | + Assert.Contains(program.Columns, c => c.Name == "homeAddress_street" && c.SqlType is SqlType.Text { MaxLength: 120 } && !c.Nullable); |
| 164 | + Assert.Contains(program.Columns, c => c.Name == "homeAddress_city" && c.SqlType is SqlType.Text { MaxLength: 80 } && c.Nullable); |
| 165 | + // The parent object field itself contributes no column. |
| 166 | + Assert.DoesNotContain(program.Columns, c => c.Name == "homeAddress"); |
| 167 | + } |
| 168 | + |
| 169 | + [Fact] |
| 170 | + public void Storage_absent_collapses_to_single_json_column() |
| 171 | + { |
| 172 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 173 | + var config = program.Columns.Single(c => c.Name == "config"); |
| 174 | + Assert.IsType<SqlType.Json>(config.SqlType); |
| 175 | + Assert.True(config.Nullable); // field is optional |
| 176 | + } |
| 177 | + |
| 178 | + [Fact] |
| 179 | + public void Snapshot_feeds_diff_engine_for_an_empty_actual_db() |
| 180 | + { |
| 181 | + // Round-trip sanity: an empty actual DB → diff produces a create-table per |
| 182 | + // expected table, each with its indexes and FKs. |
| 183 | + var expected = Load(); |
| 184 | + var result = SchemaDiff.Diff(expected, new SchemaSnapshot([])); |
| 185 | + Assert.Empty(result.Blocked); |
| 186 | + var createTables = result.Changes.OfType<Change.CreateTable>() |
| 187 | + .Select(c => c.Table.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); |
| 188 | + Assert.Equal(["bad", "links", "programs", "tags", "weeks"], createTables); |
| 189 | + // The single secondary index + the single enforced FK that survives the |
| 190 | + // writable-target gate (Week→Program) arrive as separate change records. |
| 191 | + Assert.Single(result.Changes.OfType<Change.AddIndex>()); |
| 192 | + Assert.Single(result.Changes.OfType<Change.AddFk>()); |
| 193 | + } |
| 194 | + |
| 195 | + [Fact] |
| 196 | + public void Enum_field_emits_text_column_matching_PostgresSchema() |
| 197 | + { |
| 198 | + // PostgresSchema.PgType maps FIELD_SUBTYPE_ENUM -> "text"; the snapshot must agree, |
| 199 | + // else a post-gen migrate would propose a destructive drop of the actual column. |
| 200 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 201 | + var status = program.Columns.Single(c => c.Name == "status"); |
| 202 | + Assert.IsType<SqlType.Text>(status.SqlType); |
| 203 | + } |
| 204 | + |
| 205 | + [Fact] |
| 206 | + public void IsArray_scalar_field_collapses_to_json_column() |
| 207 | + { |
| 208 | + // PostgresSchema renders f.IsArray as "jsonb" regardless of underlying subtype. |
| 209 | + var program = Load().Tables.Single(t => t.Name == "programs"); |
| 210 | + var tags = program.Columns.Single(c => c.Name == "tags"); |
| 211 | + Assert.IsType<SqlType.Json>(tags.SqlType); |
| 212 | + } |
| 213 | + |
| 214 | + [Fact] |
| 215 | + public void Fk_targeting_non_writable_entity_is_dropped() |
| 216 | + { |
| 217 | + var snap = Load(); |
| 218 | + var bad = snap.Tables.Single(t => t.Name == "bad"); |
| 219 | + // Bad declares identity.reference @references "Address" (a value object). Address |
| 220 | + // isn't a writable entity, so the FK would dangle in REFERENCES — drop it instead. |
| 221 | + Assert.Empty(bad.ForeignKeys); |
| 222 | + // The FK column itself is still emitted (it's a real metadata field); only the |
| 223 | + // physical constraint is suppressed. |
| 224 | + Assert.Contains(bad.Columns, c => c.Name == "addressId"); |
| 225 | + } |
| 226 | +} |
0 commit comments