Skip to content

Commit d14c226

Browse files
dmealingclaude
andcommitted
merge: C# table DDL — FK constraints + @storage object columns [FR-003]
CreateTable now emits FOREIGN KEY constraints (from enforced identity.reference) and @storage object-field columns (flattened -> prefixed columns; else jsonb), mirroring the TS migrate expected-schema. Full C# solution green (277 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 86e972e + 56a9184 commit d14c226

3 files changed

Lines changed: 141 additions & 8 deletions

File tree

server/csharp/MetaObjects.Codegen.Tests/PostgresSchemaTests.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ namespace MetaObjects.Codegen.Tests;
77

88
public class PostgresSchemaTests
99
{
10-
// Week + Program (tables). Week.fkProgram is the FK (identity.reference) and
11-
// Week.program is the to-one navigation back to Program.
10+
// Week + Program + Tag (tables). Week.fkProgram is the FK (identity.reference)
11+
// and Week.program is the to-one navigation back to Program; Tag.fkProgramLogical
12+
// is a logical-only (@enforce: false) reference. Program.homeAddress is a
13+
// @storage flattened object field; Program.config is a default (jsonb) object.
1214
// ProgramView — passthrough projection (plain columns).
1315
// ProgramStat — aggregate (count over Program.weeks -> correlated subquery).
1416
// WeekDetail — passthrough-@via (forwards Program.title via Week.program).
@@ -23,10 +25,23 @@ public class PostgresSchemaTests
2325
{ "identity.reference": { "name": "fkProgram", "@fields": "programId", "@references": "Program" } },
2426
{ "relationship.association": { "name": "program", "@objectRef": "Program", "@cardinality": "one" } }
2527
]}},
28+
{ "object.entity": { "name": "Tag", "children": [
29+
{ "source.dbTable": { "@name": "tags" } },
30+
{ "field.long": { "name": "id" } },
31+
{ "field.long": { "name": "programId" } },
32+
{ "identity.primary": { "@fields": "id" } },
33+
{ "identity.reference": { "name": "fkProgramLogical", "@fields": "programId", "@references": "Program", "@enforce": false } }
34+
]}},
35+
{ "object.value": { "name": "Address", "children": [
36+
{ "field.string": { "name": "street", "@required": true, "@maxLength": 120 } },
37+
{ "field.string": { "name": "city", "@maxLength": 80 } }
38+
]}},
2639
{ "object.entity": { "name": "Program", "children": [
2740
{ "source.dbTable": { "@name": "programs" } },
2841
{ "field.long": { "name": "id" } },
2942
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
43+
{ "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } },
44+
{ "field.object": { "name": "config", "@objectRef": "Address" } },
3045
{ "relationship.aggregation": { "name": "weeks", "@objectRef": "Week", "@cardinality": "many" } },
3146
{ "identity.primary": { "@fields": "id" } },
3247
{ "identity.secondary": { "name": "byTitle", "@fields": "title", "@unique": true } }
@@ -80,6 +95,44 @@ public void CreateTable_emits_columns_pk_notnull_and_unique_index()
8095
Assert.Contains("CREATE TABLE weeks (", sql);
8196
}
8297

98+
[Fact]
99+
public void CreateTable_emits_foreign_key_for_enforced_reference()
100+
{
101+
var sql = PostgresSchema.BuildSchema(Load());
102+
// Week.fkProgram (@enforce default true) -> a physical FK to programs.id.
103+
Assert.Contains(
104+
"CONSTRAINT weeks_programId_fk FOREIGN KEY (programId) REFERENCES programs (id)",
105+
sql);
106+
}
107+
108+
[Fact]
109+
public void CreateTable_omits_foreign_key_for_logical_only_reference()
110+
{
111+
var sql = PostgresSchema.BuildSchema(Load());
112+
Assert.Contains("CREATE TABLE tags (", sql);
113+
// Tag.fkProgramLogical is @enforce: false -> no physical FK constraint.
114+
Assert.DoesNotContain("CONSTRAINT tags_", sql);
115+
}
116+
117+
[Fact]
118+
public void Flattened_object_field_expands_to_prefixed_columns()
119+
{
120+
var sql = PostgresSchema.BuildSchema(Load());
121+
// Program.homeAddress @storage flattened -> Address fields as "homeAddress_*";
122+
// the parent object emits no column of its own.
123+
Assert.Contains("homeAddress_street varchar(120) NOT NULL", sql);
124+
Assert.Contains("homeAddress_city varchar(80)", sql);
125+
Assert.DoesNotContain("homeAddress jsonb", sql);
126+
}
127+
128+
[Fact]
129+
public void Default_object_field_collapses_to_jsonb_column()
130+
{
131+
var sql = PostgresSchema.BuildSchema(Load());
132+
// Program.config has no @storage -> single jsonb column (back-compat default).
133+
Assert.Contains("config jsonb", sql);
134+
}
135+
83136
[Fact]
84137
public void CreateView_emits_select_for_passthrough_projection()
85138
{

server/csharp/MetaObjects.Codegen/Schema/PostgresSchema.cs

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,41 @@ public static class PostgresSchema
4949

5050
private static string Col(MetaField f) => f.DbColumn ?? f.Name;
5151

52-
/// <summary>CREATE TABLE for a writable entity (columns + PK + NOT NULL + UNIQUE).</summary>
53-
public static string CreateTable(MetaObject entity)
52+
/// <summary>
53+
/// CREATE TABLE for a writable entity: scalar columns + object-field storage
54+
/// (@storage flattened → prefixed columns; else a single jsonb column) + PK +
55+
/// NOT NULL + UNIQUE indexes + FOREIGN KEY constraints (from enforced
56+
/// identity.reference children).
57+
/// </summary>
58+
public static string CreateTable(MetaObject entity, MetaRoot root)
5459
{
5560
var table = entity.DbTable ?? entity.Name;
5661
var pk = entity.PrimaryIdentity();
5762
var pkCols = (pk?.Fields ?? []).ToHashSet(StringComparer.Ordinal);
5863

5964
var lines = new List<string>();
60-
foreach (var f in entity.Fields().Where(f => CSharpNaming.ScalarFor(f.SubType) is not null))
65+
foreach (var f in entity.Fields())
6166
{
62-
var notNull = CSharpNaming.IsRequired(entity, f) ? " NOT NULL" : string.Empty;
63-
lines.Add($" {Col(f)} {PgType(f)}{notNull}");
67+
if (CSharpNaming.ScalarFor(f.SubType) is not null)
68+
{
69+
var notNull = CSharpNaming.IsRequired(entity, f) ? " NOT NULL" : string.Empty;
70+
lines.Add($" {Col(f)} {PgType(f)}{notNull}");
71+
}
72+
else if (f.SubType == FIELD_SUBTYPE_OBJECT)
73+
{
74+
lines.AddRange(ObjectFieldColumns(entity, f, root));
75+
}
6476
}
6577
if (pk is not null && pk.Fields.Count > 0)
6678
{
6779
var cols = entity.Fields().Where(f => pkCols.Contains(f.Name)).Select(Col);
6880
lines.Add($" PRIMARY KEY ({string.Join(", ", cols)})");
6981
}
82+
// FOREIGN KEY constraints from enforced identity.reference children.
83+
foreach (var fk in entity.ReferenceIdentities().Where(r => r.Enforce))
84+
{
85+
if (ForeignKeyClause(entity, fk, root) is { } clause) lines.Add(clause);
86+
}
7087

7188
var sb = new StringBuilder();
7289
sb.AppendLine($"CREATE TABLE {table} (");
@@ -82,6 +99,55 @@ public static string CreateTable(MetaObject entity)
8299
return sb.ToString();
83100
}
84101

102+
// Columns for an object-typed field. @storage flattened expands each nested
103+
// scalar field as "{parentCol}_{nestedCol}" (the parent emits no column of its
104+
// own); every other storage (jsonb / subdocument / absent) collapses to one
105+
// jsonb column — a relational target can't materialize a document store inline.
106+
private static IEnumerable<string> ObjectFieldColumns(MetaObject entity, MetaField f, MetaRoot root)
107+
{
108+
if (f.Storage == STORAGE_FLATTENED && f.ObjectRef is { } oref &&
109+
root.FindObject(StripPkg(oref)) is { } nested)
110+
{
111+
var prefix = Col(f) + "_";
112+
foreach (var nf in nested.Fields().Where(n => CSharpNaming.ScalarFor(n.SubType) is not null))
113+
{
114+
var notNull = CSharpNaming.IsRequired(nested, nf) ? " NOT NULL" : string.Empty;
115+
yield return $" {prefix}{Col(nf)} {PgType(nf)}{notNull}";
116+
}
117+
yield break;
118+
}
119+
var topNotNull = CSharpNaming.IsRequired(entity, f) ? " NOT NULL" : string.Empty;
120+
yield return $" {Col(f)} jsonb{topNotNull}";
121+
}
122+
123+
// A table-level FOREIGN KEY constraint for an enforced reference identity.
124+
// FK columns keep the reference's declared @fields order; target columns are
125+
// the explicit dotted @references fields (multi-col) or the target's primary
126+
// identity (single-col / bare form), falling back to "id".
127+
private static string? ForeignKeyClause(MetaObject entity, MetaReferenceIdentity fk, MetaRoot root)
128+
{
129+
if (fk.TargetEntity is not { } targetName || fk.Fields.Count == 0) return null;
130+
var target = root.FindObject(StripPkg(targetName));
131+
if (target is null) return null;
132+
133+
var fkCols = fk.Fields.Select(js => ResolveColumn(entity, js)).ToList();
134+
var refCols = fk.TargetFields.Count > 1
135+
? fk.TargetFields.Select(tf => ResolveColumn(target, tf)).ToList()
136+
: [ResolveColumn(target, ResolvedTargetPkField(target, fk))];
137+
138+
var name = $"{entity.DbTable ?? entity.Name}_{fkCols[0]}_fk";
139+
var refTable = target.DbTable ?? target.Name;
140+
return $" CONSTRAINT {name} FOREIGN KEY ({string.Join(", ", fkCols)}) " +
141+
$"REFERENCES {refTable} ({string.Join(", ", refCols)})";
142+
}
143+
144+
// The single target column a reference resolves to: the lone explicit @references
145+
// field, else the target's primary-identity first field, else "id".
146+
private static string ResolvedTargetPkField(MetaObject target, MetaReferenceIdentity fk) =>
147+
fk.TargetFields.Count == 1
148+
? fk.TargetFields[0]
149+
: target.PrimaryIdentity()?.Fields.FirstOrDefault() ?? "id";
150+
85151
/// <summary>
86152
/// CREATE VIEW for a projection. Derives the SELECT from field origins:
87153
/// passthrough (single base) → a plain column; aggregate (@agg/@of/@via) → a
@@ -242,7 +308,7 @@ public static string BuildSchema(MetaRoot root, Action<string>? warn = null)
242308
foreach (var e in root.Objects().Where(o => o.IsEntity() && !o.IsReadOnlyProjection())
243309
.OrderBy(o => o.Name, StringComparer.Ordinal))
244310
{
245-
sb.AppendLine(CreateTable(e));
311+
sb.AppendLine(CreateTable(e, root));
246312
}
247313
foreach (var p in root.Objects().Where(o => o.IsReadOnlyProjection())
248314
.OrderBy(o => o.Name, StringComparer.Ordinal))

server/csharp/MetaObjects/Meta/MetaField.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ public string? ObjectRef
2525
}
2626
}
2727

28+
/// <summary>
29+
/// Storage strategy for an object-typed field (the <c>@storage</c> attr):
30+
/// <c>flattened</c> / <c>jsonb</c> / <c>subdocument</c>, or null when absent
31+
/// (a relational target treats absent as single-jsonb-column, per back-compat).
32+
/// </summary>
33+
public string? Storage
34+
{
35+
get
36+
{
37+
var v = OwnAttr(FIELD_ATTR_STORAGE);
38+
return v is string s ? s : null;
39+
}
40+
}
41+
2842
/// <summary>Column name override (the <c>@dbColumn</c> attr).</summary>
2943
public string? DbColumn
3044
{

0 commit comments

Comments
 (0)