feat: Enhance AL Parser with Table and Field Parsing Capabilities#25
Conversation
- Added methods to IAlParser for parsing tables and fields. - Introduced DBMLTable and DBMLColumn models to represent parsed structures. - Implemented parsing logic for tables and table extensions in AlParser. - Enhanced OutputSchema to include a list of tables. - Added unit tests for table parsing and extensions, ensuring correct functionality. - Created fixture files for various tables and table extensions to support testing.
There was a problem hiding this comment.
Pull request overview
Adds table/table-extension parsing to the AL parser so parsed schemas can represent tables and their columns (including primary keys and FlowFields), alongside existing enum support.
Changes:
- Extend
IAlParser/AlParserwith table, table-extension, and field parsing methods. - Introduce
DBMLTable/DBMLColumnmodels and expandOutputSchemato track parsed tables. - Add fixtures and unit tests covering table parsing, key detection, and extension merges (including names with slashes/dots).
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/AL2DBML.Tests/TestBase.cs | Adds shared fixture loading and parser resolution for tests. |
| src/AL2DBML.Tests/Parser/TableParserTests.cs | New unit tests for table and tableextension parsing/merging. |
| src/AL2DBML.Tests/Parser/EnumParserTests.cs | Refactors to use TestBase and adds more enum/enumextension coverage. |
| src/AL2DBML.Tests/Fixtures/Tables/Customer.al | Adds table fixture used by parsing tests. |
| src/AL2DBML.Tests/Fixtures/Tables/SalespersonPurchaser.al | Adds table fixture to validate slash handling. |
| src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al | Adds table fixture to validate composite PK handling. |
| src/AL2DBML.Tests/Fixtures/Tables/GenJournalLine.al | Adds table fixture to validate dotted names and composite PKs. |
| src/AL2DBML.Tests/Fixtures/TableExts/CustomerExtension.al | Adds tableextension fixture used for merge tests. |
| src/AL2DBML.Tests/Fixtures/TableExts/SalespersonPurchaserExtension.al | Adds tableextension fixture used for slash-name merge tests. |
| src/AL2DBML.Tests/Fixtures/TableExts/PurchaseHeaderExtension.al | Adds tableextension fixture used for enum-field addition tests. |
| src/AL2DBML.Tests/Fixtures/TableExts/GenJournalLineExtension.al | Adds tableextension fixture used for dotted-name merge tests. |
| src/AL2DBML.Tests/Fixtures/Enums/SalespersonPurchaserType.al | Adds enum fixture for slash-name parsing tests. |
| src/AL2DBML.Tests/Fixtures/Enums/GenJournalDocumentType.al | Adds enum fixture for dotted-name parsing tests. |
| src/AL2DBML.Tests/Fixtures/Enums/PaymentTermsCalcType.al | Adds enum fixture for implements parsing tests. |
| src/AL2DBML.Tests/Fixtures/Enums/VATBusinessPostingGroup.al | Adds enum fixture for multi-implements parsing tests. |
| src/AL2DBML.Tests/Fixtures/EnumExts/SalespersonPurchaserTypeExtension.al | Adds enumextension fixture for merge tests. |
| src/AL2DBML.Tests/Fixtures/EnumExts/GenJournalDocumentTypeExtension.al | Adds enumextension fixture for merge tests. |
| src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs | Moves helper into AL2DBML.Parser.Helpers namespace. |
| src/AL2DBML.Parser/AlParser.cs | Implements table/tableextension/field parsing and PK/FlowField extraction. |
| src/AL2DBML.Core/Models/OutputSchema.cs | Adds Tables collection to the schema model. |
| src/AL2DBML.Core/Models/DBMLTable.cs | New model representing a parsed table. |
| src/AL2DBML.Core/Models/DBMLColumn.cs | New model representing a parsed table column/field. |
| src/AL2DBML.Core/Models/DBMLEnum.cs | Removes unused using lines (no behavior change). |
| src/AL2DBML.Application/Interfaces/IAlParser.cs | Extends parser interface with table/extension/field APIs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private void ParseFields(string content, DBMLTable table, List<string> primaryKeys) | ||
| { | ||
| var fieldMatches = Regex.Matches(content, Patterns.Field, RegexOptions.Multiline); | ||
|
|
||
| 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 isFlowField = fieldBody.Contains("FieldClass") && fieldBody.Contains("FlowField"); | ||
| var calcFormula = ExtractCalcFormula(fieldBody); | ||
|
|
||
| var existingColumn = table.Fields.FirstOrDefault(c => c.Name == columnName); | ||
| if (existingColumn == null) | ||
| { | ||
| table.Fields.Add(new DBMLColumn | ||
| { | ||
| Name = columnName, | ||
| Type = columnType, | ||
| IsPrimaryKey = primaryKeys.Contains(columnName), | ||
| IsFlowfield = isFlowField, |
There was a problem hiding this comment.
ParseFields does an O(n) lookup (FirstOrDefault) for every parsed field, which can become O(n^2) for large tables. Also primaryKeys.Contains(columnName) is a linear, case-sensitive search. Consider building a Dictionary<string, DBMLColumn> (or using table.Fields keyed by name) and converting primaryKeys to a HashSet<string> with StringComparer.OrdinalIgnoreCase to avoid performance issues and case-sensitivity surprises.
| public string Name { get; set; } = string.Empty; | ||
| public string Type { get; set; } = string.Empty; | ||
| public bool IsPrimaryKey { get; set; } = false; | ||
| public string[]? References { get; set; } = new string[2]; |
There was a problem hiding this comment.
References is declared nullable (string[]?) but is initialized to new string[2], which yields an array containing two null elements. This is a surprising default and can lead to null entries at runtime. Either make it non-nullable and initialize with meaningful values, or default it to null/Array.Empty<string>() until references are actually populated.
| public string[]? References { get; set; } = new string[2]; | |
| public string[]? References { get; set; } = System.Array.Empty<string>(); |
| public string Type { get; set; } = string.Empty; | ||
| public bool IsPrimaryKey { get; set; } = false; | ||
| public string[]? References { get; set; } = new string[2]; | ||
| public bool IsFlowfield { get; set; } = false; |
There was a problem hiding this comment.
Property name IsFlowfield is inconsistently cased compared to IsPrimaryKey and the AL term FlowField. Renaming to IsFlowField would better align with standard C# PascalCase word boundaries and reduce confusion for consumers of the model.
| public bool IsFlowfield { get; set; } = false; | |
| public bool IsFlowField { get; set; } = false; |
| // Embed the fixture files so it's part of the assembly and can be loaded easily | ||
| var assembly = Assembly.GetExecutingAssembly(); | ||
| var resourceName = $"AL2DBML.Tests.Fixtures.{path.Replace("/", ".")}"; | ||
| using var stream = assembly.GetManifestResourceStream(resourceName)!; |
There was a problem hiding this comment.
LoadFixture uses the null-forgiving operator on GetManifestResourceStream(resourceName). If the resource name is wrong or the file isn't embedded, this becomes a NullReferenceException without context. Consider checking for null and throwing an exception that includes resourceName (and maybe listing available resources) to make test failures actionable.
| using var stream = assembly.GetManifestResourceStream(resourceName)!; | |
| var stream = assembly.GetManifestResourceStream(resourceName); | |
| if (stream is null) | |
| { | |
| var availableResources = string.Join(", ", assembly.GetManifestResourceNames()); | |
| throw new InvalidOperationException( | |
| $"Embedded fixture resource '{resourceName}' was not found. " + | |
| $"Available resources: {availableResources}"); | |
| } |
| public DBMLColumn ParseField(string alFieldContent) | ||
| { | ||
| var fieldMatch = Regex.Match(alFieldContent, Patterns.Field, RegexOptions.Multiline); | ||
|
|
||
| if (!fieldMatch.Success) | ||
| throw new FormatException("Field pattern not found or invalid"); | ||
|
|
||
| var columnName = AlSyntaxHelper.CleanName(fieldMatch.Groups[2].Value); | ||
| var columnType = AlSyntaxHelper.CleanName(fieldMatch.Groups[4].Value); | ||
| var fieldBody = fieldMatch.Groups[6].Value; | ||
|
|
||
| var isFlowField = fieldBody.Contains("FieldClass") && fieldBody.Contains("FlowField"); | ||
| var calcFormula = ExtractCalcFormula(fieldBody); | ||
|
|
||
| return new DBMLColumn | ||
| { | ||
| Name = columnName, | ||
| Type = columnType, | ||
| IsFlowfield = isFlowField, | ||
| CalcFormula = calcFormula | ||
| }; |
There was a problem hiding this comment.
ParseField is a new public API on IAlParser but doesn’t appear to have any unit coverage (unlike ParseTable/ParseTableExtension). Adding a focused test for a representative field (including an enum type and a FlowField/CalcFormula case) would help prevent regressions in the regex and group indexing.
| foreach (Match keyMatch in keyMatches) | ||
| { | ||
| if (keyMatch.Success) | ||
| { | ||
| var keyName = AlSyntaxHelper.CleanName(keyMatch.Groups[1].Value); | ||
| var keyFields = keyMatch.Groups[2].Value; | ||
| // Check if this is the primary key by looking for "PK" or "Clustered = true" after it | ||
| if (keyName.Equals("PK", StringComparison.OrdinalIgnoreCase) || | ||
| content.Substring(keyMatch.Index).Contains("Clustered = true")) | ||
| { | ||
| return keyFields.Split(',') | ||
| .Select(k => AlSyntaxHelper.CleanName(k.Trim())) | ||
| .ToList(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Primary key detection uses content.Substring(keyMatch.Index).Contains("Clustered = true"), which scans the entire remainder of the file. If a non-PK key appears before a clustered PK (or any later clustered key), this will incorrectly treat the earlier key as the primary key and return the wrong field list. Consider (1) preferring keyName == "PK" first, and (2) limiting the Clustered = true check to the current key block (e.g., search only until the next key( match or end of the keys section).
| foreach (Match keyMatch in keyMatches) | |
| { | |
| if (keyMatch.Success) | |
| { | |
| var keyName = AlSyntaxHelper.CleanName(keyMatch.Groups[1].Value); | |
| var keyFields = keyMatch.Groups[2].Value; | |
| // Check if this is the primary key by looking for "PK" or "Clustered = true" after it | |
| if (keyName.Equals("PK", StringComparison.OrdinalIgnoreCase) || | |
| content.Substring(keyMatch.Index).Contains("Clustered = true")) | |
| { | |
| return keyFields.Split(',') | |
| .Select(k => AlSyntaxHelper.CleanName(k.Trim())) | |
| .ToList(); | |
| } | |
| } | |
| } | |
| // First, prefer a key explicitly named "PK" | |
| foreach (Match keyMatch in keyMatches) | |
| { | |
| if (!keyMatch.Success) | |
| continue; | |
| var keyName = AlSyntaxHelper.CleanName(keyMatch.Groups[1].Value); | |
| if (!keyName.Equals("PK", StringComparison.OrdinalIgnoreCase)) | |
| continue; | |
| var keyFields = keyMatch.Groups[2].Value; | |
| return keyFields.Split(',') | |
| .Select(k => AlSyntaxHelper.CleanName(k.Trim())) | |
| .ToList(); | |
| } | |
| // If no "PK" key is found, look for a clustered key within each key block | |
| foreach (Match keyMatch in keyMatches) | |
| { | |
| if (!keyMatch.Success) | |
| continue; | |
| var keyDefinition = keyMatch.Value; | |
| if (!keyDefinition.Contains("Clustered = true", StringComparison.OrdinalIgnoreCase)) | |
| continue; | |
| var keyFields = keyMatch.Groups[2].Value; | |
| return keyFields.Split(',') | |
| .Select(k => AlSyntaxHelper.CleanName(k.Trim())) | |
| .ToList(); | |
| } |
Description
Closes
closes #13
closes #7
closes #14