Skip to content

Commit 5c523bd

Browse files
committed
merge: C# meta migrate — Postgres schema DDL [P2d]
2 parents e2cfc7a + f7210dd commit 5c523bd

5 files changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using MetaObjects.Cli;
2+
using Xunit;
3+
4+
namespace MetaObjects.Cli.Tests;
5+
6+
/// <summary>End-to-end `meta migrate`: a metadata dir -> a Postgres schema .sql file.</summary>
7+
public sealed class MigrateCommandTests : IDisposable
8+
{
9+
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-migrate-" + Guid.NewGuid().ToString("N"));
10+
private string MetaDir => Path.Combine(_tmp, "metaobjects");
11+
private string OutFile => Path.Combine(_tmp, "schema.sql");
12+
13+
private const string Metadata = """
14+
{ "metadata.root": { "package": "acme", "children": [
15+
{ "object.entity": { "name": "Subscriber", "children": [
16+
{ "source.dbTable": { "@name": "subscribers" } },
17+
{ "field.long": { "name": "id" } },
18+
{ "field.string": { "name": "email", "@required": true } },
19+
{ "identity.primary": { "@fields": "id" } }
20+
]}}
21+
]}}
22+
""";
23+
24+
public MigrateCommandTests()
25+
{
26+
Directory.CreateDirectory(MetaDir);
27+
File.WriteAllText(Path.Combine(MetaDir, "meta.acme.json"), Metadata);
28+
}
29+
30+
public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }
31+
32+
[Fact]
33+
public void Migrate_writes_postgres_ddl()
34+
{
35+
var outcome = MigrateCommand.Run(MetaDir, OutFile);
36+
Assert.True(outcome.Ok, string.Join("; ", outcome.LoadErrors));
37+
Assert.True(File.Exists(OutFile));
38+
var sql = File.ReadAllText(OutFile);
39+
Assert.Contains("CREATE TABLE subscribers (", sql);
40+
Assert.Contains("email text NOT NULL", sql);
41+
Assert.Contains("PRIMARY KEY (id)", sql);
42+
}
43+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// `meta migrate` — emit Postgres schema DDL (CREATE TABLE + CREATE VIEW) from
2+
// metadata. (Schema diff against a live database is a later increment; this emits
3+
// the create-schema DDL deterministically from the model.)
4+
5+
using MetaObjects.Codegen.Schema;
6+
using MetaObjects.Loader;
7+
8+
namespace MetaObjects.Cli;
9+
10+
/// <summary>The migrate command's pure logic (no console I/O), so it is testable.</summary>
11+
public static class MigrateCommand
12+
{
13+
public sealed record Outcome(IReadOnlyList<string> LoadErrors, string? Sql, IReadOnlyList<string> Warnings)
14+
{
15+
public bool Ok => LoadErrors.Count == 0 && Sql is not null;
16+
}
17+
18+
public static Outcome Run(string metadataDir, string outFile)
19+
{
20+
var load = new FileMetaDataLoader().LoadDirectory(metadataDir);
21+
var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
22+
if (loadErrors.Count > 0)
23+
return new Outcome(loadErrors, null, []);
24+
25+
var warnings = new List<string>();
26+
var sql = PostgresSchema.BuildSchema(load.Root, warnings.Add);
27+
File.WriteAllText(outFile, sql);
28+
return new Outcome(loadErrors, sql, warnings);
29+
}
30+
}

server/csharp/MetaObjects.Cli/Program.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,46 @@
99
"usage: meta <command> [options]\n" +
1010
" commands:\n" +
1111
" gen <metadataDir> --out <dir> --namespace <ns> generate EF Core code from metadata\n" +
12+
" migrate <metadataDir> --out <file.sql> emit Postgres schema DDL from metadata\n" +
1213
" verify <metadataDir> --templates <root> drift-check templates against their payloads");
1314
return 2;
1415
}
1516

1617
return args[0] switch
1718
{
1819
"gen" => RunGen(args[1..]),
20+
"migrate" => RunMigrate(args[1..]),
1921
"verify" => RunVerify(args[1..]),
2022
_ => Unknown(args[0]),
2123
};
2224

25+
static int RunMigrate(string[] rest)
26+
{
27+
string? metadataDir = null;
28+
string? outFile = null;
29+
for (int i = 0; i < rest.Length; i++)
30+
{
31+
if (rest[i] == "--out" && i + 1 < rest.Length) outFile = rest[++i];
32+
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
33+
}
34+
if (metadataDir is null || outFile is null)
35+
{
36+
Console.Error.WriteLine("usage: meta migrate <metadataDir> --out <file.sql>");
37+
return 2;
38+
}
39+
40+
var outcome = MigrateCommand.Run(metadataDir, outFile);
41+
if (!outcome.Ok)
42+
{
43+
foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($" load error: {e}");
44+
Console.Error.WriteLine("meta migrate: FAILED (metadata did not load cleanly)");
45+
return 1;
46+
}
47+
foreach (var w in outcome.Warnings) Console.Error.WriteLine($" warning: {w}");
48+
Console.WriteLine($"meta migrate: wrote {outFile}");
49+
return 0;
50+
}
51+
2352
static int RunGen(string[] rest)
2453
{
2554
string? metadataDir = null;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using MetaObjects.Codegen.Schema;
2+
using MetaObjects.Loader;
3+
using MetaObjects.Meta;
4+
using Xunit;
5+
6+
namespace MetaObjects.Codegen.Tests;
7+
8+
public class PostgresSchemaTests
9+
{
10+
// Week + Program (tables); ProgramView (passthrough projection from Program);
11+
// ProgramStat (aggregate projection -> needs join/aggregate SQL -> TODO).
12+
private const string Model = """
13+
{ "metadata.root": { "package": "acme", "children": [
14+
{ "object.entity": { "name": "Week", "children": [
15+
{ "source.dbTable": { "@name": "weeks" } },
16+
{ "field.long": { "name": "id" } },
17+
{ "identity.primary": { "@fields": "id" } }
18+
]}},
19+
{ "object.entity": { "name": "Program", "children": [
20+
{ "source.dbTable": { "@name": "programs" } },
21+
{ "field.long": { "name": "id" } },
22+
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
23+
{ "relationship.aggregation": { "name": "weeks", "@objectRef": "Week", "@cardinality": "many" } },
24+
{ "identity.primary": { "@fields": "id" } },
25+
{ "identity.secondary": { "name": "byTitle", "@fields": "title", "@unique": true } }
26+
]}},
27+
{ "object.value": { "name": "ProgramView", "children": [
28+
{ "source.dbView": { "@name": "v_program" } },
29+
{ "field.long": { "name": "id", "children": [ { "origin.passthrough": { "@from": "Program.id" } } ] } },
30+
{ "field.string": { "name": "title", "children": [ { "origin.passthrough": { "@from": "Program.title" } } ] } }
31+
]}},
32+
{ "object.value": { "name": "ProgramStat", "children": [
33+
{ "source.dbView": { "@name": "v_program_stat" } },
34+
{ "field.int": { "name": "weekCount", "children": [
35+
{ "origin.aggregate": { "@agg": "count", "@of": "Week.id", "@via": "Program.weeks" } }
36+
]}}
37+
]}}
38+
]}}
39+
""";
40+
41+
private static MetaRoot Load()
42+
{
43+
var r = new MetaDataLoader().Load([new InMemorySource(Model, id: "schema.json")]);
44+
Assert.Empty(r.Errors);
45+
return r.Root;
46+
}
47+
48+
[Fact]
49+
public void CreateTable_emits_columns_pk_notnull_and_unique_index()
50+
{
51+
var warns = new List<string>();
52+
var sql = PostgresSchema.BuildSchema(Load(), warns.Add);
53+
54+
Assert.Contains("CREATE TABLE programs (", sql);
55+
Assert.Contains("id bigint NOT NULL", sql);
56+
Assert.Contains("title varchar(200) NOT NULL", sql);
57+
Assert.Contains("PRIMARY KEY (id)", sql);
58+
Assert.Contains("CREATE UNIQUE INDEX programs_byTitle_uniq ON programs (title);", sql);
59+
Assert.Contains("CREATE TABLE weeks (", sql);
60+
}
61+
62+
[Fact]
63+
public void CreateView_emits_select_for_passthrough_projection()
64+
{
65+
var sql = PostgresSchema.BuildSchema(Load());
66+
Assert.Contains("CREATE VIEW v_program AS", sql);
67+
Assert.Contains("id AS id", sql);
68+
Assert.Contains("title AS title", sql);
69+
Assert.Contains("FROM programs;", sql); // base entity Program -> its table "programs"
70+
}
71+
72+
[Fact]
73+
public void Aggregate_projection_is_flagged_todo_not_wrong_sql()
74+
{
75+
var warns = new List<string>();
76+
var sql = PostgresSchema.BuildSchema(Load(), warns.Add);
77+
Assert.Contains("-- TODO CREATE VIEW v_program_stat", sql);
78+
Assert.Contains(warns, w => w.Contains("v_program_stat"));
79+
}
80+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// PostgresSchema — emits Postgres DDL from metadata: CREATE TABLE per entity
2+
// (columns/PK/NOT NULL/UNIQUE) and CREATE VIEW per read-only projection.
3+
//
4+
// Column + table names mirror the EF Core generators ([Table]/[Column] use the
5+
// dbTable/dbColumn override or the raw name), so the generated entities map onto
6+
// this schema. View SELECTs are derived from field origins for the common case
7+
// (passthrough from a single base, no relationship hop); aggregate / @via /
8+
// collection origins are flagged as a TODO rather than emitting incorrect SQL.
9+
10+
using System.Text;
11+
using MetaObjects.Meta;
12+
using static MetaObjects.Core.Field.FieldConstants;
13+
using static MetaObjects.Shared.BaseTypes;
14+
15+
namespace MetaObjects.Codegen.Schema;
16+
17+
/// <summary>Postgres DDL generation from a loaded model.</summary>
18+
public static class PostgresSchema
19+
{
20+
/// <summary>Field subtype -> Postgres column type (varchar(n) when @maxLength is set).</summary>
21+
public static string PgType(MetaField field) => field.SubType switch
22+
{
23+
FIELD_SUBTYPE_STRING or FIELD_SUBTYPE_CLASS =>
24+
field.MaxLength is long n ? $"varchar({n})" : "text",
25+
FIELD_SUBTYPE_INT => "integer",
26+
FIELD_SUBTYPE_SHORT => "smallint",
27+
FIELD_SUBTYPE_BYTE => "smallint",
28+
FIELD_SUBTYPE_LONG => "bigint",
29+
FIELD_SUBTYPE_CURRENCY => "bigint", // integer minor units
30+
FIELD_SUBTYPE_DOUBLE => "double precision",
31+
FIELD_SUBTYPE_FLOAT => "real",
32+
FIELD_SUBTYPE_DECIMAL => field.Precision is long p
33+
? $"numeric({p}, {field.Scale ?? 0})" : "numeric",
34+
FIELD_SUBTYPE_BOOLEAN => "boolean",
35+
FIELD_SUBTYPE_DATE => "date",
36+
FIELD_SUBTYPE_TIME => "time",
37+
FIELD_SUBTYPE_TIMESTAMP => "timestamp",
38+
_ => "text",
39+
};
40+
41+
private static string Col(MetaField f) => f.DbColumn ?? f.Name;
42+
43+
/// <summary>CREATE TABLE for a writable entity (columns + PK + NOT NULL + UNIQUE).</summary>
44+
public static string CreateTable(MetaObject entity)
45+
{
46+
var table = entity.DbTable ?? entity.Name;
47+
var pk = entity.PrimaryIdentity();
48+
var pkCols = (pk?.Fields ?? []).ToHashSet(StringComparer.Ordinal);
49+
50+
var lines = new List<string>();
51+
foreach (var f in entity.Fields().Where(f => CSharpNaming.ScalarFor(f.SubType) is not null))
52+
{
53+
var notNull = CSharpNaming.IsRequired(entity, f) ? " NOT NULL" : string.Empty;
54+
lines.Add($" {Col(f)} {PgType(f)}{notNull}");
55+
}
56+
if (pk is not null && pk.Fields.Count > 0)
57+
{
58+
var cols = entity.Fields().Where(f => pkCols.Contains(f.Name)).Select(Col);
59+
lines.Add($" PRIMARY KEY ({string.Join(", ", cols)})");
60+
}
61+
62+
var sb = new StringBuilder();
63+
sb.AppendLine($"CREATE TABLE {table} (");
64+
sb.AppendLine(string.Join(",\n", lines));
65+
sb.AppendLine(");");
66+
67+
// UNIQUE indexes from secondary identities marked unique.
68+
foreach (var sec in entity.SecondaryIdentities().Where(i => i.Unique))
69+
{
70+
var cols = entity.Fields().Where(f => sec.Fields.Contains(f.Name)).Select(Col);
71+
sb.AppendLine($"CREATE UNIQUE INDEX {table}_{sec.Name}_uniq ON {table} ({string.Join(", ", cols)});");
72+
}
73+
return sb.ToString();
74+
}
75+
76+
/// <summary>CREATE VIEW for a projection; best-effort SELECT from passthrough origins.</summary>
77+
public static string CreateView(MetaObject projection, MetaRoot root, Action<string> warn)
78+
{
79+
var view = projection.DbView!;
80+
var cols = new List<string>();
81+
string? baseEntity = null;
82+
bool complex = false;
83+
84+
foreach (var f in projection.Fields().Where(f => CSharpNaming.ScalarFor(f.SubType) is not null))
85+
{
86+
var origin = f.OwnChildren().FirstOrDefault(c => c.Type == TYPE_ORIGIN);
87+
if (origin is MetaPassthroughOrigin pt && pt.Via is null && pt.From is { } from && from.Contains('.'))
88+
{
89+
var dot = from.IndexOf('.');
90+
var ent = from[..dot];
91+
var srcCol = from[(dot + 1)..];
92+
baseEntity ??= ent;
93+
if (ent != baseEntity) { complex = true; break; }
94+
cols.Add($" {srcCol} AS {Col(f)}");
95+
}
96+
else
97+
{
98+
complex = true; // aggregate / @via / collection / no origin -> needs join/agg SQL
99+
break;
100+
}
101+
}
102+
103+
if (complex || baseEntity is null || cols.Count == 0)
104+
{
105+
warn($"meta migrate: view \"{view}\" needs aggregate/relationship-path SQL — emitted as TODO.");
106+
return $"-- TODO CREATE VIEW {view}: derive SELECT from aggregate/@via/collection origins\n" +
107+
$"-- (passthrough-from-single-base views are generated; this projection needs join/aggregate SQL).\n";
108+
}
109+
110+
var baseTable = root.FindObject(baseEntity)?.DbTable ?? baseEntity;
111+
return $"CREATE VIEW {view} AS\nSELECT\n{string.Join(",\n", cols)}\nFROM {baseTable};\n";
112+
}
113+
114+
/// <summary>Full schema DDL: tables for writable entities, then views for projections.</summary>
115+
public static string BuildSchema(MetaRoot root, Action<string>? warn = null)
116+
{
117+
var warnFn = warn ?? (_ => { });
118+
var sb = new StringBuilder();
119+
sb.AppendLine("-- Generated by MetaObjects meta migrate (Postgres). Do not edit by hand.");
120+
sb.AppendLine();
121+
122+
foreach (var e in root.Objects().Where(o => o.IsEntity() && !o.IsReadOnlyProjection())
123+
.OrderBy(o => o.Name, StringComparer.Ordinal))
124+
{
125+
sb.AppendLine(CreateTable(e));
126+
}
127+
foreach (var p in root.Objects().Where(o => o.IsReadOnlyProjection())
128+
.OrderBy(o => o.Name, StringComparer.Ordinal))
129+
{
130+
sb.AppendLine(CreateView(p, root, warnFn));
131+
}
132+
return sb.ToString();
133+
}
134+
}

0 commit comments

Comments
 (0)