diff --git a/docs/AL_PARSING.md b/docs/AL_PARSING.md index 397574d..190edbe 100644 --- a/docs/AL_PARSING.md +++ b/docs/AL_PARSING.md @@ -90,6 +90,44 @@ enumextension 50101 "My Status Ext" extends "My Status" { value(2; Pending) } Values are merged into the base enum. Duplicate values are ignored. +## TableRelation + +`TableRelation` defines a foreign key lookup to another table. It is parsed from the field body and stored in `DBMLColumn.References` as a two-element array `[tableName, fieldName]`. + +### Supported syntaxes + +| AL syntax | Result | +|---|---| +| `TableRelation = Vendor;` | `["Vendor", "UnknownField"]` | +| `TableRelation = Vendor."No.";` | `["Vendor", "No."]` | +| `TableRelation = Vendor.Code;` | `["Vendor", "Code"]` | +| `TableRelation = "Salesperson/Purchaser";` | `["Salesperson/Purchaser", "UnknownField"]` | +| `TableRelation = "Salesperson/Purchaser"."Code";` | `["Salesperson/Purchaser", "Code"]` | +| `TableRelation = "Salesperson/Purchaser".Code;` | `["Salesperson/Purchaser", "Code"]` | + +When no field is specified, the sentinel value `"UnknownField"` is used. This is resolved at post-processing time (see ARCHITECTURE.md — Schema post-processing). + +### WHERE filters + +`where` clauses are silently ignored — the table (and field, if specified) are still extracted: + +```al +TableRelation = Vendor where ("Blocked" = const(false)); // → ["Vendor", "UnknownField"] +TableRelation = Vendor."No." where ("No." = field("No.")); // → ["Vendor", "No."] +``` + +### Unsupported syntaxes + +Conditional `TableRelation` using `if/else` is not parsed — the target table depends on a runtime field value and is therefore ambiguous. The relation is silently ignored. + +```al +TableRelation = if (Type = const(Customer)) Customer else Vendor; // → no reference generated +``` + +### PK assumption + +In AL, `TableRelation` almost always points to the primary key of the target table. When a referenced field is explicit (e.g. `.Code`), it is marked as `IsPrimaryKey = true` in the generated stub or partial table. This is a heuristic — in the rare case where the referenced field is not actually the PK, the generated schema will be incorrect for that relation. + ## FlowFields AL FlowFields are calculated fields with no physical storage: diff --git a/docs/SCHEMA_POST_PROCESSING.md b/docs/SCHEMA_POST_PROCESSING.md new file mode 100644 index 0000000..a5c2877 --- /dev/null +++ b/docs/SCHEMA_POST_PROCESSING.md @@ -0,0 +1,51 @@ +# Schema Post-Processing + +After all AL files are parsed, `IDBMLWriter.WriteDBMLAsync` passes the `OutputSchema` through `ISchemaPostProcessor` before generating DBML output. The post-processor works on a **deep copy** of the schema and never mutates the original. + +## Pipeline + +The steps run in this order: + +``` +1. InferSinglePkWhenOnlyUnknownPlusOneColumn +2. RemoveEmptyTables +3. CreateStubsForMissingReferencedTables +4. AddMissingReferencedFieldsToExistingTables +5. Build singlePkByTable +6. ResolveUnknownFieldReferences +7. RemoveUnknownFieldColumns +``` + +## UnknownField convention + +When a `TableRelation` specifies no field (e.g. `TableRelation = Vendor;`), the field name is set to the sentinel value `"UnknownField"`. This signals to the post-processor that the actual target field is unknown and should be resolved from the target table's primary key if possible. + +## Step details + +**1. InferSinglePkWhenOnlyUnknownPlusOneColumn** +If a table has exactly one real field and one `UnknownField` placeholder, and no PK is declared, the real field is inferred as the primary key. This handles partially parsed tables that lack an explicit `key(PK; ...)` declaration. + +**2. RemoveEmptyTables** +Tables with no fields are removed. These are typically side effects of a `tableextension` file being parsed when the base table AL file is not in the input — the base table gets created as an empty shell and is not useful in the output. + +**3. CreateStubsForMissingReferencedTables** +For each `TableRelation` reference pointing to a table absent from the schema, a stub table is created: +- If the reference includes an explicit field name: the stub contains that field as PK. +- If only `UnknownField` references exist: the stub contains an `UnknownField` column as PK (typed from the referencing field). The relation is kept as-is in the output — the schema is technically incomplete but the reference is not broken. + +**4. AddMissingReferencedFieldsToExistingTables** +For tables that exist (e.g. from a `tableextension`) but are missing a field that is explicitly referenced by a `TableRelation`, the missing field is added as PK at position 0. This covers the case where the base table AL file is absent but the extension file was parsed. + +**5–7. Resolve and clean** +- `singlePkByTable` indexes all tables with exactly one primary key field. +- `ResolveUnknownFieldReferences` replaces `UnknownField` with the actual PK name for any table in that index. It also aligns the type of the referencing field with the PK's type. +- `RemoveUnknownFieldColumns` removes `UnknownField` placeholder columns from resolved tables, unless the column is itself the PK (stub tables where the target PK remains unknown). + +## Risks and limitations + +| Situation | Behaviour | +|---|---| +| Target table has multiple PKs | `UnknownField` is not resolved and remains in output | +| Target table absent, no explicit field in `TableRelation` | Stub created with `UnknownField [pk]`; schema is incomplete | +| Referenced field is not the actual PK (edge case) | Stub or partial table marks a non-PK field as PK; schema is incorrect for that relation | +| Conditional `TableRelation` (`if/else`) | Relation not parsed; no reference generated | diff --git a/src/AL2DBML.Parser/AlParser.cs b/src/AL2DBML.Parser/AlParser.cs index e8665b7..7cc1677 100644 --- a/src/AL2DBML.Parser/AlParser.cs +++ b/src/AL2DBML.Parser/AlParser.cs @@ -111,31 +111,19 @@ private void ParseFields(string content, DBMLTable table, List primaryKe foreach (Match fieldMatch in fieldMatches) { - var columnName = AlSyntaxHelper.CleanName(fieldMatch.Groups[2].Value); - var columnType = AlSyntaxHelper.CleanName(fieldMatch.Groups[4].Value); - var fieldBody = fieldMatch.Groups[6].Value; + var column = ParseField(fieldMatch.Value); + column.IsPrimaryKey = primaryKeys.Contains(column.Name); - var isFlowField = fieldBody.Contains("FieldClass") && fieldBody.Contains("FlowField"); - var calcFormula = ExtractCalcFormula(fieldBody); - - var existingColumn = table.Fields.FirstOrDefault(c => c.Name == columnName); + var existingColumn = table.Fields.FirstOrDefault(c => c.Name == column.Name); if (existingColumn == null) - { - table.Fields.Add(new DBMLColumn - { - Name = columnName, - Type = columnType, - IsPrimaryKey = primaryKeys.Contains(columnName), - IsFlowfield = isFlowField, - CalcFormula = calcFormula - }); - } + table.Fields.Add(column); else { - existingColumn.Type = columnType; - existingColumn.IsPrimaryKey = primaryKeys.Contains(columnName); - existingColumn.IsFlowfield = isFlowField; - existingColumn.CalcFormula = calcFormula; + existingColumn.Type = column.Type; + existingColumn.IsPrimaryKey = column.IsPrimaryKey; + existingColumn.IsFlowfield = column.IsFlowfield; + existingColumn.CalcFormula = column.CalcFormula; + existingColumn.References = column.References; } } } @@ -146,6 +134,31 @@ private string ExtractCalcFormula(string fieldBody) return match.Success ? match.Groups[1].Value.Trim() : string.Empty; } + private string[]? ExtractTableRelation(string fieldBody) + { + // Supported: + // TableRelation = Vendor; + // TableRelation = Vendor."No."; + // TableRelation = Vendor.Code; + // TableRelation = "Salesperson/Purchaser"; + // TableRelation = "Salesperson/Purchaser"."Code"; + // TableRelation = "Salesperson/Purchaser".Code; + // TableRelation = Vendor where (...); → table extracted, where clause ignored + // Not supported (returns null): + // TableRelation = if (Type = ...) Table1 else Table2; → conditional, target is ambiguous + var match = Regex.Match(fieldBody, @"TableRelation\s*=\s*(?:""([^""]+)""|(\w+))(?:\.(?:""([^""]+)""|(\w+)))?"); + if (!match.Success) return null; + + var tableName = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + if (string.Equals(tableName, "if", StringComparison.OrdinalIgnoreCase)) return null; + + var fieldName = match.Groups[3].Success ? match.Groups[3].Value + : match.Groups[4].Success ? match.Groups[4].Value + : "UnknownField"; + + return [tableName, fieldName]; + } + private DBMLTable GetOrCreateTable(string name, out bool isNew) { var existing = _outputSchema.Tables.FirstOrDefault(t => t.Name == name); @@ -166,13 +179,15 @@ public DBMLColumn ParseField(string alFieldContent) var isFlowField = fieldBody.Contains("FieldClass") && fieldBody.Contains("FlowField"); var calcFormula = ExtractCalcFormula(fieldBody); + var references = ExtractTableRelation(fieldBody); return new DBMLColumn { Name = columnName, Type = columnType, IsFlowfield = isFlowField, - CalcFormula = calcFormula + CalcFormula = calcFormula, + References = references }; } diff --git a/src/AL2DBML.Tests/Fixtures/Tables/Customer.al b/src/AL2DBML.Tests/Fixtures/Tables/Customer.al index 3e1ce20..818708a 100644 --- a/src/AL2DBML.Tests/Fixtures/Tables/Customer.al +++ b/src/AL2DBML.Tests/Fixtures/Tables/Customer.al @@ -88,6 +88,7 @@ table 18 "Customer" { Caption = 'Currency Code'; ToolTip = 'Specifies the currency for customer transactions'; + TableRelation = Currency."Code"; } field(17; "Blocked"; Enum "Customer Blocked") { diff --git a/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al b/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al index 6a96664..ab55145 100644 --- a/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al +++ b/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al @@ -16,10 +16,12 @@ table 38 "Purchase Header" field(3; "Buy-from Vendor No."; Code[20]) { Caption = 'Buy-from Vendor No.'; + TableRelation = Vendor; } field(4; "Pay-to Vendor No."; Code[20]) { Caption = 'Pay-to Vendor No.'; + TableRelation = Vendor."No."; } field(5; "Amount"; Decimal) { @@ -32,6 +34,7 @@ table 38 "Purchase Header" field(7; "Salesperson/Purchaser Code"; Code[10]) { Caption = 'Salesperson/Purchaser Code'; + TableRelation = "Salesperson/Purchaser".Code; } field(8; "Outstanding Amount (LCY)"; Decimal) { diff --git a/src/AL2DBML.Tests/Parser/TableParserTests.cs b/src/AL2DBML.Tests/Parser/TableParserTests.cs index 6a7bbd0..78d48ba 100644 --- a/src/AL2DBML.Tests/Parser/TableParserTests.cs +++ b/src/AL2DBML.Tests/Parser/TableParserTests.cs @@ -226,4 +226,110 @@ public void Parse_TableExtension_PurchaseHeader_AddsEnumField() Assert.Equal(10, baseTable.Fields.Count); // 8 original + 2 from extension Assert.Contains(baseTable.Fields, f => f.Name == "Approval Status" && f.Type == "Approval Status"); } + + [Fact] + public void Parse_Table_WithTableRelation_UnquotedTable_ReturnsTableName() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + var field = result.Fields.FirstOrDefault(f => f.Name == "Buy-from Vendor No."); + Assert.NotNull(field); + Assert.NotNull(field.References); + Assert.Equal("Vendor", field.References[0]); + } + + [Fact] + public void Parse_Table_WithTableRelation_ExplicitField_ReturnsBothTableAndFieldName() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + var field = result.Fields.FirstOrDefault(f => f.Name == "Pay-to Vendor No."); + Assert.NotNull(field); + Assert.NotNull(field.References); + Assert.Equal("Vendor", field.References[0]); + Assert.Equal("No.", field.References[1]); + } + + [Fact] + public void Parse_Table_WithTableRelation_QuotedTableAndUnquotedField_ReturnsBothTableAndFieldName() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + var field = result.Fields.FirstOrDefault(f => f.Name == "Salesperson/Purchaser Code"); + Assert.NotNull(field); + Assert.NotNull(field.References); + Assert.Equal("Salesperson/Purchaser", field.References[0]); + Assert.Equal("Code", field.References[1]); + } + + [Fact] + public void Parse_Table_WithTableRelation_QuotedTableAndField_ReturnsBothTableAndFieldName() + { + ResetParser(); + var al = LoadFixture("Tables/Customer.al"); + + var result = _parser.ParseTable(al); + + var field = result.Fields.FirstOrDefault(f => f.Name == "Currency Code"); + Assert.NotNull(field); + Assert.NotNull(field.References); + Assert.Equal("Currency", field.References[0]); + Assert.Equal("Code", field.References[1]); + } + + [Fact] + public void Parse_Field_WithTableRelationAndWhereClause_ExtractsTableIgnoresFilter() + { + ResetParser(); + var al = """ + field(1; "Vendor No."; Code[20]) + { + TableRelation = Vendor where ("Blocked" = const(false)); + } + """; + + var result = _parser.ParseField(al); + + Assert.NotNull(result.References); + Assert.Equal("Vendor", result.References[0]); + Assert.Equal("UnknownField", result.References[1]); + } + + [Fact] + public void Parse_Field_WithConditionalTableRelation_ReturnsNullReferences() + { + ResetParser(); + var al = """ + field(1; "Account No."; Code[20]) + { + TableRelation = if (Type = const(Customer)) Customer else if (Type = const(Vendor)) Vendor; + } + """; + + var result = _parser.ParseField(al); + + Assert.Null(result.References); + } + + [Fact] + public void Parse_Table_WithoutTableRelation_ReturnsNullReferences() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + var field = result.Fields.FirstOrDefault(f => f.Name == "Amount"); + Assert.NotNull(field); + Assert.Null(field.References); + } } diff --git a/src/AL2DBML.Tests/Writer/WriterTests.cs b/src/AL2DBML.Tests/Writer/WriterTests.cs index fdc6824..947ae1b 100644 --- a/src/AL2DBML.Tests/Writer/WriterTests.cs +++ b/src/AL2DBML.Tests/Writer/WriterTests.cs @@ -426,6 +426,149 @@ public async Task WriteDBMLAsync_InfersPk_WhenSingleRealFieldPlusUnknownField() Assert.DoesNotContain("UnknownField", result); } + // --- RemoveEmptyTables --- + + [Fact] + public async Task WriteDBMLAsync_EmptyTable_IsRemovedFromOutput() + { + var schema = new OutputSchema + { + Tables = + [ + new DBMLTable { Name = "Item", Fields = [] }, + new DBMLTable { Name = "Customer", Fields = [new DBMLColumn { Name = "No.", Type = "Code[20]", IsPrimaryKey = true }] } + ] + }; + + var result = await _writer.WriteDBMLAsync(schema); + + Assert.DoesNotContain("table Item", result); + Assert.Contains("table Customer", result); + } + + // --- CreateStubsForMissingReferencedTables --- + + [Fact] + public async Task WriteDBMLAsync_MissingReferencedTable_CreatesStubWithExplicitField() + { + var schema = new OutputSchema + { + Tables = + [ + new DBMLTable + { + Name = "SalesLine", + Fields = [new DBMLColumn { Name = "Currency Code", Type = "Code[10]", References = ["Currency", "Code"] }] + } + ] + }; + + var result = await _writer.WriteDBMLAsync(schema); + + Assert.Contains("table Currency {", result); + Assert.Contains("Code \"Code[10]\" [pk]", result); + } + + [Fact] + public async Task WriteDBMLAsync_MissingReferencedTable_UnknownRefResolvedAfterStubCreation() + { + var schema = new OutputSchema + { + Tables = + [ + new DBMLTable + { + Name = "OC Translation", + Fields = + [ + new DBMLColumn { Name = "Windows Language ID", Type = "Integer", References = ["Language", "Windows Language ID"] }, + new DBMLColumn { Name = "Language Code", Type = "Code[20]", References = ["Language", "UnknownField"] } + ] + } + ] + }; + + var result = await _writer.WriteDBMLAsync(schema); + + Assert.Contains("table Language {", result); + Assert.Contains("\"Windows Language ID\" Integer [pk]", result); + Assert.Contains("ref: > Language.\"Windows Language ID\"", result); + Assert.DoesNotContain("UnknownField", result); + } + + [Fact] + public async Task WriteDBMLAsync_PartialTableMissingReferencedField_FieldAddedAsPk() + { + // Table exists (from tableextension) but lacks the base PK referenced by another table + var schema = new OutputSchema + { + Tables = + [ + new DBMLTable + { + Name = "Salesperson/Purchaser", + Fields = [new DBMLColumn { Name = "Extension Field", Type = "Text[50]" }] + }, + new DBMLTable + { + Name = "Purchase Header", + Fields = [new DBMLColumn { Name = "Salesperson Code", Type = "Code[10]", References = ["Salesperson/Purchaser", "Code"] }] + } + ] + }; + + var result = await _writer.WriteDBMLAsync(schema); + + Assert.Contains("table \"Salesperson/Purchaser\"", result); + Assert.Contains("Code \"Code[10]\" [pk]", result); + Assert.Contains("ref: > \"Salesperson/Purchaser\".Code", result); + } + + [Fact] + public async Task WriteDBMLAsync_EmptyTableWithIncomingRef_IsReplacedByStub() + { + // Item exists as empty table (side effect of a tableextension) but is referenced via TableRelation + var schema = new OutputSchema + { + Tables = + [ + new DBMLTable { Name = "Item", Fields = [] }, + new DBMLTable + { + Name = "SalesLine", + Fields = [new DBMLColumn { Name = "Item No.", Type = "Code[20]", References = ["Item", "UnknownField"] }] + } + ] + }; + + var result = await _writer.WriteDBMLAsync(schema); + + Assert.Contains("table Item {", result); + Assert.Contains("UnknownField \"Code[20]\" [pk]", result); + } + + [Fact] + public async Task WriteDBMLAsync_MissingReferencedTable_OnlyUnknownRef_CreatesStubWithUnknownFieldAsPk() + { + var schema = new OutputSchema + { + Tables = + [ + new DBMLTable + { + Name = "SalesLine", + Fields = [new DBMLColumn { Name = "Item No.", Type = "Code[20]", References = ["Item", "UnknownField"] }] + } + ] + }; + + var result = await _writer.WriteDBMLAsync(schema); + + Assert.Contains("table Item {", result); + Assert.Contains("UnknownField \"Code[20]\" [pk]", result); + Assert.Contains("ref: > Item.UnknownField", result); + } + [Fact] public async Task WriteDBMLAsync_DoesNotInferPk_WhenMultipleRealFields() { diff --git a/src/DBMLWriter/SchemaPostProcessor.cs b/src/DBMLWriter/SchemaPostProcessor.cs index 94f4f9a..910edb6 100644 --- a/src/DBMLWriter/SchemaPostProcessor.cs +++ b/src/DBMLWriter/SchemaPostProcessor.cs @@ -13,6 +13,9 @@ public OutputSchema Process(OutputSchema schema) var copy = OutputSchemaHelper.DeepCopy(schema); InferSinglePkWhenOnlyUnknownPlusOneColumn(copy); + RemoveEmptyTables(copy); + CreateStubsForMissingReferencedTables(copy); + AddMissingReferencedFieldsToExistingTables(copy); var singlePkByTable = copy.Tables .Select(t => new { Table = t, Pks = t.Fields.Where(f => f.IsPrimaryKey).ToList() }) @@ -62,6 +65,60 @@ private static void ResolveUnknownFieldReferences(OutputSchema schema, Dictionar private static void RemoveUnknownFieldColumns(OutputSchema schema, Dictionary singlePkByTable) { foreach (var table in schema.Tables.Where(t => singlePkByTable.ContainsKey(t.Name))) - table.Fields.RemoveAll(f => string.Equals(f.Name, Unknown, StringComparison.OrdinalIgnoreCase)); + table.Fields.RemoveAll(f => string.Equals(f.Name, Unknown, StringComparison.OrdinalIgnoreCase) && !f.IsPrimaryKey); + } + + private static void CreateStubsForMissingReferencedTables(OutputSchema schema) + { + var existingTableNames = schema.Tables + .Select(t => t.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var referencingFieldsByTable = schema.Tables + .SelectMany(t => t.Fields) + .Where(f => f.References is { Length: 2 } refs && !string.IsNullOrEmpty(refs[0]) && !existingTableNames.Contains(refs[0])) + .GroupBy(f => f.References![0], StringComparer.OrdinalIgnoreCase); + + foreach (var group in referencingFieldsByTable) + { + var explicitFields = group + .Where(f => !string.IsNullOrEmpty(f.References![1]) && !string.Equals(f.References![1], Unknown, StringComparison.OrdinalIgnoreCase)) + .DistinctBy(f => f.References![1], StringComparer.OrdinalIgnoreCase) + .Select(f => new DBMLColumn { Name = f.References![1], Type = f.Type, IsPrimaryKey = true }) + .ToList(); + + var stubFields = explicitFields.Count > 0 + ? explicitFields + : [new DBMLColumn { Name = Unknown, Type = group.First().Type, IsPrimaryKey = true }]; + + schema.Tables.Add(new DBMLTable { Name = group.Key, Fields = stubFields }); + } + } + + private static void AddMissingReferencedFieldsToExistingTables(OutputSchema schema) + { + var tablesByName = schema.Tables.ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase); + + var explicitRefs = schema.Tables + .SelectMany(t => t.Fields) + .Where(f => f.References is { Length: 2 } refs + && !string.IsNullOrEmpty(refs[0]) + && !string.IsNullOrEmpty(refs[1]) + && !string.Equals(refs[1], Unknown, StringComparison.OrdinalIgnoreCase) + && tablesByName.ContainsKey(refs[0])); + + foreach (var field in explicitRefs) + { + var targetTable = tablesByName[field.References![0]]; + var referencedFieldName = field.References![1]; + + if (!targetTable.Fields.Any(f => string.Equals(f.Name, referencedFieldName, StringComparison.OrdinalIgnoreCase))) + targetTable.Fields.Insert(0, new DBMLColumn { Name = referencedFieldName, Type = field.Type, IsPrimaryKey = true }); + } + } + + private static void RemoveEmptyTables(OutputSchema schema) + { + schema.Tables.RemoveAll(t => t.Fields.Count == 0); } }