Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/AL2DBML.Application/Interfaces/IAlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ public interface IAlParser
AlFileType DetectFileType(string alFileContent);
DBMLEnum ParseEnum(string alEnumFileContent);
DBMLEnum ParseEnumExtension(string alEnumExtensionFileContent);
DBMLTable ParseTable(string alTableFileContent);
DBMLTable ParseTableExtension(string alTableExtensionFileContent);
DBMLColumn ParseField(string alFieldContent);
}
11 changes: 11 additions & 0 deletions src/AL2DBML.Core/Models/DBMLColumn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace AL2DBML.Core.Models;

public class DBMLColumn
{
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];

Copilot AI Mar 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
public string[]? References { get; set; } = new string[2];
public string[]? References { get; set; } = System.Array.Empty<string>();

Copilot uses AI. Check for mistakes.
public bool IsFlowfield { get; set; } = false;

Copilot AI Mar 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
public bool IsFlowfield { get; set; } = false;
public bool IsFlowField { get; set; } = false;

Copilot uses AI. Check for mistakes.
public string CalcFormula { get; set; } = string.Empty;
}
2 changes: 0 additions & 2 deletions src/AL2DBML.Core/Models/DBMLEnum.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System;

namespace AL2DBML.Core.Models;

public class DBMLEnum
Expand Down
7 changes: 7 additions & 0 deletions src/AL2DBML.Core/Models/DBMLTable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace AL2DBML.Core.Models;

public class DBMLTable
{
public string Name { get; set; } = string.Empty;
public List<DBMLColumn> Fields { get; set; } = [];
}
3 changes: 1 addition & 2 deletions src/AL2DBML.Core/Models/OutputSchema.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
using System;

namespace AL2DBML.Core.Models;

public class OutputSchema
{
public List<DBMLEnum> Enums { get; set; } = [];
public List<DBMLTable> Tables { get; set; } = [];
}
121 changes: 121 additions & 0 deletions src/AL2DBML.Parser/AlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using AL2DBML.Application.Interfaces;
using AL2DBML.Core.Enums;
using AL2DBML.Core.Models;
using AL2DBML.Parser.Helpers;

namespace AL2DBML.Parser;

Expand All @@ -14,6 +15,8 @@ private static class Patterns
public const string EnumValue = @"^\s*value\(\s*\d+;\s*(""[^""]+""|\w+)";
public const string Table = @"^\s*table\s+\d+\s+(""[^""]+""|\w+)";
public const string TableExtension = @"^\s*tableextension\s+\d+\s+(""[^""]+""|\w+)\s+extends\s+(""[^""]+""|\w+)";
public const string Field = @"^\s*field\s*\(\s*(\d+);\s*(""[^""]+""|\w+)\s*;\s*(Enum)?\s*(""[^""]+""|\w+(\[\d+\])?)\s*\)\s*{([^{}]*)}";
public const string Key = @"^\s*key\s*\(\s*(""[^""]+""|\w+)\s*;\s*([^)]*)\s*\)";
}
private readonly OutputSchema _outputSchema = new();

Expand Down Expand Up @@ -53,4 +56,122 @@ private DBMLEnum GetOrCreateEnum(string name, out bool isNew)
isNew = existing is null;
return existing ?? new DBMLEnum { Name = name, Values = [] };
}

public DBMLTable ParseTable(string alTableFileContent)
{
var name = AlSyntaxHelper.ExtractMatch(alTableFileContent, Patterns.Table);
var dbmlTable = GetOrCreateTable(name, out bool isNew);

var primaryKeys = GetPrimaryKeys(alTableFileContent);
ParseFields(alTableFileContent, dbmlTable, primaryKeys);

if (isNew) _outputSchema.Tables.Add(dbmlTable);
return dbmlTable;
}

public DBMLTable ParseTableExtension(string alTableExtensionFileContent)
{
var tableName = AlSyntaxHelper.ExtractMatch(alTableExtensionFileContent, Patterns.TableExtension, 2);
var dbmlTable = GetOrCreateTable(tableName, out bool isNew);

var primaryKeys = GetPrimaryKeys(alTableExtensionFileContent);
ParseFields(alTableExtensionFileContent, dbmlTable, primaryKeys);

if (isNew) _outputSchema.Tables.Add(dbmlTable);
return dbmlTable;
}

private List<string> GetPrimaryKeys(string content)
{
var keyMatches = Regex.Matches(content, Patterns.Key, RegexOptions.Multiline);

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();
}
}
}
Comment on lines +88 to +103

Copilot AI Mar 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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();
}

Copilot uses AI. Check for mistakes.
return [];
}

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,
Comment on lines +107 to +128

Copilot AI Mar 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
CalcFormula = calcFormula
});
}
else
{
existingColumn.Type = columnType;
existingColumn.IsPrimaryKey = primaryKeys.Contains(columnName);
existingColumn.IsFlowfield = isFlowField;
existingColumn.CalcFormula = calcFormula;
}
}
}

private string ExtractCalcFormula(string fieldBody)
{
var match = Regex.Match(fieldBody, @"CalcFormula\s*=\s*([^;]+)");
return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
}

private DBMLTable GetOrCreateTable(string name, out bool isNew)
{
var existing = _outputSchema.Tables.FirstOrDefault(t => t.Name == name);
isNew = existing is null;
return existing ?? new DBMLTable { Name = name, Fields = [] };
}

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
};
Comment on lines +155 to +175

Copilot AI Mar 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
}
2 changes: 2 additions & 0 deletions src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Text.RegularExpressions;

namespace AL2DBML.Parser.Helpers;

internal class AlSyntaxHelper
{
public static string CleanName(string name)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
enumextension 50003 "Gen. Journal Document Type Ext" extends "Gen. Journal Document Type"
{
value(10; "Finance Charge Memo")
{
Caption = 'Finance Charge Memo';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
enumextension 50002 "Salesperson/Purchaser Type Ext" extends "Salesperson/Purchaser Type"
{
value(10; "Agency")
{
Caption = 'Agency';
}
value(11; "Freelance")
{
Caption = 'Freelance';
}
}
21 changes: 21 additions & 0 deletions src/AL2DBML.Tests/Fixtures/Enums/GenJournalDocumentType.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
enum 50002 "Gen. Journal Document Type"
{
Caption = 'Gen. Journal Document Type';

value(0; " ")
{
Caption = ' ';
}
value(1; "Payment")
{
Caption = 'Payment';
}
value(2; "Invoice")
{
Caption = 'Invoice';
}
value(3; "Credit Memo")
{
Caption = 'Credit Memo';
}
}
17 changes: 17 additions & 0 deletions src/AL2DBML.Tests/Fixtures/Enums/PaymentTermsCalcType.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
enum 50003 "Payment Terms Calc. Type" implements "IPaymentCalc"
{
Caption = 'Payment Terms Calc. Type';

value(0; " ")
{
Caption = ' ';
}
value(1; "Fixed Day")
{
Caption = 'Fixed Day';
}
value(2; "Current Month")
{
Caption = 'Current Month';
}
}
17 changes: 17 additions & 0 deletions src/AL2DBML.Tests/Fixtures/Enums/SalespersonPurchaserType.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
enum 50001 "Salesperson/Purchaser Type"
{
Caption = 'Salesperson/Purchaser Type';

value(0; " ")
{
Caption = ' ';
}
value(1; "Internal")
{
Caption = 'Internal';
}
value(2; "External")
{
Caption = 'External';
}
}
21 changes: 21 additions & 0 deletions src/AL2DBML.Tests/Fixtures/Enums/VATBusinessPostingGroup.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
enum 50004 "VAT Business Posting Group" implements "IVATPostable", "IPostingGroup"
{
Caption = 'VAT Business Posting Group';

value(0; " ")
{
Caption = ' ';
}
value(1; "Domestic")
{
Caption = 'Domestic';
}
value(2; "EU")
{
Caption = 'EU';
}
value(3; "Export")
{
Caption = 'Export';
}
}
40 changes: 40 additions & 0 deletions src/AL2DBML.Tests/Fixtures/TableExts/CustomerExtension.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
tableextension 50000 "Customer Extension" extends "Customer"
{
fields
{
field(50001; "Customer Category"; Code[20])
{
Caption = 'Customer Category';
ToolTip = 'Specifies the customer category for segmentation';
}
field(50002; "Tax ID"; Text[20])
{
Caption = 'Tax ID';
ToolTip = 'Specifies the customer tax identification number';
}
field(50003; "Preferred Contact Method"; Option)
{
Caption = 'Preferred Contact Method';
OptionMembers = Email,Phone,Mail,Other;
ToolTip = 'Specifies the preferred method to contact the customer';
}
field(50004; "InvoiceAmount"; Decimal)
{
Caption = 'Invoice Amount';
FieldClass = FlowField;
CalcFormula = sum("Customer Ledger Entry"."Amount" where("Customer No." = field("No."), "Document Type" = const(Invoice)));
}
}

trigger OnAfterInsert()
begin
if "Customer Category" = '' then
"Customer Category" := 'GENERAL';
end;

local procedure ValidateTaxID()
begin
if "Tax ID" <> '' and StrLen("Tax ID") < 5 then
Error('Tax ID must be at least 5 characters');
end;
}
10 changes: 10 additions & 0 deletions src/AL2DBML.Tests/Fixtures/TableExts/GenJournalLineExtension.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
tableextension 50003 "Gen. Journal Line Extension" extends "Gen. Journal Line"
{
fields
{
field(50001; "Project Code"; Code[20])
{
Caption = 'Project Code';
}
}
}
14 changes: 14 additions & 0 deletions src/AL2DBML.Tests/Fixtures/TableExts/PurchaseHeaderExtension.al
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
tableextension 50002 "Purchase Header Extension" extends "Purchase Header"
{
fields
{
field(50001; "Custom Reference No."; Code[30])
{
Caption = 'Custom Reference No.';
}
field(50002; "Approval Status"; Enum "Approval Status")
{
Caption = 'Approval Status';
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
tableextension 50001 "Salesperson/Purchaser Extension" extends "Salesperson/Purchaser"
{
fields
{
field(50001; "Phone No."; Text[30])
{
Caption = 'Phone No.';
}
field(50002; "Global Dimension 1 Code"; Code[20])
{
Caption = 'Global Dimension 1 Code';
}
}
}
Loading
Loading