|
| 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