From a92f02a1cb9291f1cc014ef4659426783204391a Mon Sep 17 00:00:00 2001 From: OGR-67 Date: Thu, 19 Mar 2026 21:27:24 +0100 Subject: [PATCH] feat: Enhance AL Parser with Table and Field Parsing Capabilities - 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. --- .../Interfaces/IAlParser.cs | 3 + src/AL2DBML.Core/Models/DBMLColumn.cs | 11 + src/AL2DBML.Core/Models/DBMLEnum.cs | 2 - src/AL2DBML.Core/Models/DBMLTable.cs | 7 + src/AL2DBML.Core/Models/OutputSchema.cs | 3 +- src/AL2DBML.Parser/AlParser.cs | 121 +++++++++ src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs | 2 + .../GenJournalDocumentTypeExtension.al | 7 + .../SalespersonPurchaserTypeExtension.al | 11 + .../Fixtures/Enums/GenJournalDocumentType.al | 21 ++ .../Fixtures/Enums/PaymentTermsCalcType.al | 17 ++ .../Enums/SalespersonPurchaserType.al | 17 ++ .../Fixtures/Enums/VATBusinessPostingGroup.al | 21 ++ .../Fixtures/TableExts/CustomerExtension.al | 40 +++ .../TableExts/GenJournalLineExtension.al | 10 + .../TableExts/PurchaseHeaderExtension.al | 14 ++ .../SalespersonPurchaserExtension.al | 14 ++ src/AL2DBML.Tests/Fixtures/Tables/Customer.al | 175 +++++++++++++ .../Fixtures/Tables/GenJournalLine.al | 49 ++++ .../Fixtures/Tables/PurchaseHeader.al | 52 ++++ .../Fixtures/Tables/SalespersonPurchaser.al | 40 +++ src/AL2DBML.Tests/Parser/EnumParserTests.cs | 129 ++++++++-- src/AL2DBML.Tests/Parser/TableParserTests.cs | 229 ++++++++++++++++++ src/AL2DBML.Tests/TestBase.cs | 20 ++ 24 files changed, 994 insertions(+), 21 deletions(-) create mode 100644 src/AL2DBML.Core/Models/DBMLColumn.cs create mode 100644 src/AL2DBML.Core/Models/DBMLTable.cs create mode 100644 src/AL2DBML.Tests/Fixtures/EnumExts/GenJournalDocumentTypeExtension.al create mode 100644 src/AL2DBML.Tests/Fixtures/EnumExts/SalespersonPurchaserTypeExtension.al create mode 100644 src/AL2DBML.Tests/Fixtures/Enums/GenJournalDocumentType.al create mode 100644 src/AL2DBML.Tests/Fixtures/Enums/PaymentTermsCalcType.al create mode 100644 src/AL2DBML.Tests/Fixtures/Enums/SalespersonPurchaserType.al create mode 100644 src/AL2DBML.Tests/Fixtures/Enums/VATBusinessPostingGroup.al create mode 100644 src/AL2DBML.Tests/Fixtures/TableExts/CustomerExtension.al create mode 100644 src/AL2DBML.Tests/Fixtures/TableExts/GenJournalLineExtension.al create mode 100644 src/AL2DBML.Tests/Fixtures/TableExts/PurchaseHeaderExtension.al create mode 100644 src/AL2DBML.Tests/Fixtures/TableExts/SalespersonPurchaserExtension.al create mode 100644 src/AL2DBML.Tests/Fixtures/Tables/Customer.al create mode 100644 src/AL2DBML.Tests/Fixtures/Tables/GenJournalLine.al create mode 100644 src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al create mode 100644 src/AL2DBML.Tests/Fixtures/Tables/SalespersonPurchaser.al create mode 100644 src/AL2DBML.Tests/Parser/TableParserTests.cs diff --git a/src/AL2DBML.Application/Interfaces/IAlParser.cs b/src/AL2DBML.Application/Interfaces/IAlParser.cs index 8b81380..987a6cd 100644 --- a/src/AL2DBML.Application/Interfaces/IAlParser.cs +++ b/src/AL2DBML.Application/Interfaces/IAlParser.cs @@ -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); } diff --git a/src/AL2DBML.Core/Models/DBMLColumn.cs b/src/AL2DBML.Core/Models/DBMLColumn.cs new file mode 100644 index 0000000..57b6d9c --- /dev/null +++ b/src/AL2DBML.Core/Models/DBMLColumn.cs @@ -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]; + public bool IsFlowfield { get; set; } = false; + public string CalcFormula { get; set; } = string.Empty; +} diff --git a/src/AL2DBML.Core/Models/DBMLEnum.cs b/src/AL2DBML.Core/Models/DBMLEnum.cs index d22bc7f..6161b1b 100644 --- a/src/AL2DBML.Core/Models/DBMLEnum.cs +++ b/src/AL2DBML.Core/Models/DBMLEnum.cs @@ -1,5 +1,3 @@ -using System; - namespace AL2DBML.Core.Models; public class DBMLEnum diff --git a/src/AL2DBML.Core/Models/DBMLTable.cs b/src/AL2DBML.Core/Models/DBMLTable.cs new file mode 100644 index 0000000..58415c0 --- /dev/null +++ b/src/AL2DBML.Core/Models/DBMLTable.cs @@ -0,0 +1,7 @@ +namespace AL2DBML.Core.Models; + +public class DBMLTable +{ + public string Name { get; set; } = string.Empty; + public List Fields { get; set; } = []; +} diff --git a/src/AL2DBML.Core/Models/OutputSchema.cs b/src/AL2DBML.Core/Models/OutputSchema.cs index d1a4c70..0a5e88c 100644 --- a/src/AL2DBML.Core/Models/OutputSchema.cs +++ b/src/AL2DBML.Core/Models/OutputSchema.cs @@ -1,8 +1,7 @@ -using System; - namespace AL2DBML.Core.Models; public class OutputSchema { public List Enums { get; set; } = []; + public List Tables { get; set; } = []; } diff --git a/src/AL2DBML.Parser/AlParser.cs b/src/AL2DBML.Parser/AlParser.cs index 5fc78a0..3f04a11 100644 --- a/src/AL2DBML.Parser/AlParser.cs +++ b/src/AL2DBML.Parser/AlParser.cs @@ -2,6 +2,7 @@ using AL2DBML.Application.Interfaces; using AL2DBML.Core.Enums; using AL2DBML.Core.Models; +using AL2DBML.Parser.Helpers; namespace AL2DBML.Parser; @@ -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(); @@ -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 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(); + } + } + } + return []; + } + + private void ParseFields(string content, DBMLTable table, List 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, + 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 + }; + } } diff --git a/src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs b/src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs index e1a8e30..f93a467 100644 --- a/src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs +++ b/src/AL2DBML.Parser/Helpers/AlSyntaxHelper.cs @@ -1,5 +1,7 @@ using System.Text.RegularExpressions; +namespace AL2DBML.Parser.Helpers; + internal class AlSyntaxHelper { public static string CleanName(string name) diff --git a/src/AL2DBML.Tests/Fixtures/EnumExts/GenJournalDocumentTypeExtension.al b/src/AL2DBML.Tests/Fixtures/EnumExts/GenJournalDocumentTypeExtension.al new file mode 100644 index 0000000..5c7bf28 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/EnumExts/GenJournalDocumentTypeExtension.al @@ -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'; + } +} diff --git a/src/AL2DBML.Tests/Fixtures/EnumExts/SalespersonPurchaserTypeExtension.al b/src/AL2DBML.Tests/Fixtures/EnumExts/SalespersonPurchaserTypeExtension.al new file mode 100644 index 0000000..47e8cb6 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/EnumExts/SalespersonPurchaserTypeExtension.al @@ -0,0 +1,11 @@ +enumextension 50002 "Salesperson/Purchaser Type Ext" extends "Salesperson/Purchaser Type" +{ + value(10; "Agency") + { + Caption = 'Agency'; + } + value(11; "Freelance") + { + Caption = 'Freelance'; + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Enums/GenJournalDocumentType.al b/src/AL2DBML.Tests/Fixtures/Enums/GenJournalDocumentType.al new file mode 100644 index 0000000..cb20d24 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Enums/GenJournalDocumentType.al @@ -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'; + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Enums/PaymentTermsCalcType.al b/src/AL2DBML.Tests/Fixtures/Enums/PaymentTermsCalcType.al new file mode 100644 index 0000000..c612303 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Enums/PaymentTermsCalcType.al @@ -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'; + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Enums/SalespersonPurchaserType.al b/src/AL2DBML.Tests/Fixtures/Enums/SalespersonPurchaserType.al new file mode 100644 index 0000000..c3396b2 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Enums/SalespersonPurchaserType.al @@ -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'; + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Enums/VATBusinessPostingGroup.al b/src/AL2DBML.Tests/Fixtures/Enums/VATBusinessPostingGroup.al new file mode 100644 index 0000000..c88a2e8 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Enums/VATBusinessPostingGroup.al @@ -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'; + } +} diff --git a/src/AL2DBML.Tests/Fixtures/TableExts/CustomerExtension.al b/src/AL2DBML.Tests/Fixtures/TableExts/CustomerExtension.al new file mode 100644 index 0000000..bc6341b --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/TableExts/CustomerExtension.al @@ -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; +} diff --git a/src/AL2DBML.Tests/Fixtures/TableExts/GenJournalLineExtension.al b/src/AL2DBML.Tests/Fixtures/TableExts/GenJournalLineExtension.al new file mode 100644 index 0000000..fc16393 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/TableExts/GenJournalLineExtension.al @@ -0,0 +1,10 @@ +tableextension 50003 "Gen. Journal Line Extension" extends "Gen. Journal Line" +{ + fields + { + field(50001; "Project Code"; Code[20]) + { + Caption = 'Project Code'; + } + } +} diff --git a/src/AL2DBML.Tests/Fixtures/TableExts/PurchaseHeaderExtension.al b/src/AL2DBML.Tests/Fixtures/TableExts/PurchaseHeaderExtension.al new file mode 100644 index 0000000..be923c7 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/TableExts/PurchaseHeaderExtension.al @@ -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'; + } + } +} diff --git a/src/AL2DBML.Tests/Fixtures/TableExts/SalespersonPurchaserExtension.al b/src/AL2DBML.Tests/Fixtures/TableExts/SalespersonPurchaserExtension.al new file mode 100644 index 0000000..4d6ed14 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/TableExts/SalespersonPurchaserExtension.al @@ -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'; + } + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Tables/Customer.al b/src/AL2DBML.Tests/Fixtures/Tables/Customer.al new file mode 100644 index 0000000..3e1ce20 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Tables/Customer.al @@ -0,0 +1,175 @@ +table 18 "Customer" +{ + Caption = 'Customer'; + LookupPageID = "Customer List"; + DrillDownPageID = "Customer List"; + DataClassification = ToBeClassified; + + fields + { + field(1; "No."; Code[20]) + { + Caption = 'No.'; + ToolTip = 'Specifies the unique customer identifier'; + } + field(2; "Name"; Text[100]) + { + Caption = 'Name'; + ToolTip = 'Specifies the customer company name'; + } + field(3; "Search Name"; Code[100]) + { + Caption = 'Search Name'; + ToolTip = 'Specifies an alternate name for searching'; + } + field(4; "Name 2"; Text[100]) + { + Caption = 'Name 2'; + ToolTip = 'Specifies an additional name line'; + } + field(5; "Address"; Text[100]) + { + Caption = 'Address'; + ToolTip = 'Specifies the street address'; + } + field(6; "Address 2"; Text[100]) + { + Caption = 'Address 2'; + ToolTip = 'Specifies an additional address line'; + } + field(7; "City"; Text[30]) + { + Caption = 'City'; + ToolTip = 'Specifies the city name'; + } + field(8; "Post Code"; Code[20]) + { + Caption = 'Post Code'; + ToolTip = 'Specifies the postal code'; + } + field(9; "Country/Region Code"; Code[10]) + { + Caption = 'Country/Region Code'; + ToolTip = 'Specifies the country or region'; + } + field(10; "Phone No."; Text[20]) + { + Caption = 'Phone No.'; + ToolTip = 'Specifies the customer telephone number'; + } + field(11; "E-Mail"; Text[100]) + { + Caption = 'E-Mail'; + ToolTip = 'Specifies the customer email address'; + } + field(12; "Contact"; Text[100]) + { + Caption = 'Contact'; + ToolTip = 'Specifies the name of the contact person'; + } + field(13; "Balance"; Decimal) + { + Caption = 'Balance'; + ToolTip = 'Specifies the customer balance'; + FieldClass = FlowField; + CalcFormula = sum("Cust. Ledger Entry"."Amount" where("Customer No." = field("No."))); + } + field(14; "Credit Limit (LCY)"; Decimal) + { + Caption = 'Credit Limit (LCY)'; + ToolTip = 'Specifies the maximum credit amount for the customer'; + } + field(15; "Customer Posting Group"; Code[20]) + { + Caption = 'Customer Posting Group'; + ToolTip = 'Specifies the posting group for general ledger'; + } + field(16; "Currency Code"; Code[10]) + { + Caption = 'Currency Code'; + ToolTip = 'Specifies the currency for customer transactions'; + } + field(17; "Blocked"; Enum "Customer Blocked") + { + Caption = 'Blocked'; + ToolTip = 'Specifies if the customer is blocked'; + } + field(18; "Created Date"; Date) + { + Caption = 'Created Date'; + ToolTip = 'Specifies when the customer record was created'; + Editable = false; + } + field(19; "Last Modified Date"; DateTime) + { + Caption = 'Last Modified Date'; + ToolTip = 'Specifies when the customer was last modified'; + Editable = false; + } + } + + keys + { + key(PK; "No.") + { + Clustered = true; + } + key(SK1; "Search Name") + { + } + key(SK2; "Post Code") + { + } + key(SK3; "E-Mail") + { + } + } + + trigger OnInsert() + begin + "Created Date" := Today(); + ValidateCustomerData(); + end; + + trigger OnModify() + begin + "Last Modified Date" := CurrentDateTime(); + ValidateCustomerData(); + UpdateSearchName(); + end; + + trigger OnDelete() + begin + ValidateCustomerHasNoTransactions(); + end; + + trigger OnRename() + begin + "Last Modified Date" := CurrentDateTime(); + end; + + local procedure ValidateCustomerData() + begin + if "Name" = '' then + Error('Customer name cannot be empty'); + + if "Credit Limit (LCY)" < 0 then + Error('Credit limit cannot be negative'); + end; + + local procedure ValidateCustomerHasNoTransactions() + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + CustLedgerEntry.SetRange("Customer No.", "No."); + if not CustLedgerEntry.IsEmpty() then + Error('Cannot delete customer with existing transactions'); + end; + + local procedure UpdateSearchName() + begin + "Search Name" := "Name"; + if "Name 2" <> '' then + "Search Name" += ' ' + "Name 2"; + end; +} diff --git a/src/AL2DBML.Tests/Fixtures/Tables/GenJournalLine.al b/src/AL2DBML.Tests/Fixtures/Tables/GenJournalLine.al new file mode 100644 index 0000000..fa37203 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Tables/GenJournalLine.al @@ -0,0 +1,49 @@ +table 81 "Gen. Journal Line" +{ + Caption = 'Gen. Journal Line'; + DataClassification = ToBeClassified; + + fields + { + field(1; "Journal Template Name"; Code[10]) + { + Caption = 'Journal Template Name'; + } + field(2; "Line No."; Integer) + { + Caption = 'Line No.'; + } + field(3; "Account Type"; Enum "Gen. Journal Account Type") + { + Caption = 'Account Type'; + } + field(4; "Account No."; Code[20]) + { + Caption = 'Account No.'; + } + field(5; "Document Type"; Enum "Gen. Journal Document Type") + { + Caption = 'Document Type'; + } + field(6; "Amount"; Decimal) + { + Caption = 'Amount'; + } + field(7; "VAT Amount"; Decimal) + { + Caption = 'VAT Amount'; + } + field(8; "Bal. Account No."; Code[20]) + { + Caption = 'Bal. Account No.'; + } + } + + keys + { + key(PK; "Journal Template Name", "Line No.") + { + Clustered = true; + } + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al b/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al new file mode 100644 index 0000000..6a96664 --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Tables/PurchaseHeader.al @@ -0,0 +1,52 @@ +table 38 "Purchase Header" +{ + Caption = 'Purchase Header'; + DataClassification = ToBeClassified; + + fields + { + field(1; "Document Type"; Enum "Purchase Document Type") + { + Caption = 'Document Type'; + } + field(2; "No."; Code[20]) + { + Caption = 'No.'; + } + field(3; "Buy-from Vendor No."; Code[20]) + { + Caption = 'Buy-from Vendor No.'; + } + field(4; "Pay-to Vendor No."; Code[20]) + { + Caption = 'Pay-to Vendor No.'; + } + field(5; "Amount"; Decimal) + { + Caption = 'Amount'; + } + field(6; "Amount Including VAT"; Decimal) + { + Caption = 'Amount Including VAT'; + } + field(7; "Salesperson/Purchaser Code"; Code[10]) + { + Caption = 'Salesperson/Purchaser Code'; + } + field(8; "Outstanding Amount (LCY)"; Decimal) + { + Caption = 'Outstanding Amount (LCY)'; + } + } + + keys + { + key(PK; "Document Type", "No.") + { + Clustered = true; + } + key(SK1; "Buy-from Vendor No.") + { + } + } +} diff --git a/src/AL2DBML.Tests/Fixtures/Tables/SalespersonPurchaser.al b/src/AL2DBML.Tests/Fixtures/Tables/SalespersonPurchaser.al new file mode 100644 index 0000000..bb473ca --- /dev/null +++ b/src/AL2DBML.Tests/Fixtures/Tables/SalespersonPurchaser.al @@ -0,0 +1,40 @@ +table 13 "Salesperson/Purchaser" +{ + Caption = 'Salesperson/Purchaser'; + DataClassification = ToBeClassified; + + fields + { + field(1; "Code"; Code[10]) + { + Caption = 'Code'; + } + field(2; "Name"; Text[50]) + { + Caption = 'Name'; + } + field(3; "Commission %"; Decimal) + { + Caption = 'Commission %'; + } + field(4; "E-Mail"; Text[80]) + { + Caption = 'E-Mail'; + } + field(5; "Salesperson/Purchaser Type"; Enum "Salesperson/Purchaser Type") + { + Caption = 'Salesperson/Purchaser Type'; + } + } + + keys + { + key(PK; "Code") + { + Clustered = true; + } + key(SK1; "Name") + { + } + } +} diff --git a/src/AL2DBML.Tests/Parser/EnumParserTests.cs b/src/AL2DBML.Tests/Parser/EnumParserTests.cs index dca71ae..d81354f 100644 --- a/src/AL2DBML.Tests/Parser/EnumParserTests.cs +++ b/src/AL2DBML.Tests/Parser/EnumParserTests.cs @@ -1,22 +1,21 @@ -using System.Reflection; -using AL2DBML.Application.Interfaces; -using Microsoft.Extensions.DependencyInjection; - namespace AL2DBML.Tests.Parser; public class EnumParserTests : TestBase { - private readonly IAlParser _parser; - - public EnumParserTests() + [Fact] + public void DetectFileType_Enum_ReturnsEnum() { - _parser = Services.GetRequiredService(); + var al = LoadFixture("Enums/CustomerStatus.al"); + + var result = _parser.DetectFileType(al); + + Assert.Equal(Core.Enums.AlFileType.Enum, result); } [Fact] - public void DetectFileType_Enum_ReturnsEnum() + public void DetectFileType_EnumWithImplements_ReturnsEnum() { - var al = LoadFixture("Enums/CustomerStatus.al"); + var al = LoadFixture("Enums/PaymentTermsCalcType.al"); var result = _parser.DetectFileType(al); @@ -84,13 +83,109 @@ public void Parse_Enum_NoQuotesInName() Assert.Contains(result.Values, v => v == "Quote"); } - private static string LoadFixture(string path) + [Fact] + public void Parse_Enum_WithSlashInName_ReturnsCorrectName() { - // 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)!; - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); + var al = LoadFixture("Enums/SalespersonPurchaserType.al"); + + var result = _parser.ParseEnum(al); + + Assert.Equal("Salesperson/Purchaser Type", result.Name); + Assert.Equal(3, result.Values.Count); + Assert.Contains(result.Values, v => v == "Internal"); + Assert.Contains(result.Values, v => v == "External"); + } + + [Fact] + public void Parse_Enum_WithDotsInName_ReturnsCorrectName() + { + var al = LoadFixture("Enums/GenJournalDocumentType.al"); + + var result = _parser.ParseEnum(al); + + Assert.Equal("Gen. Journal Document Type", result.Name); + Assert.Equal(4, result.Values.Count); + Assert.Contains(result.Values, v => v == "Payment"); + Assert.Contains(result.Values, v => v == "Credit Memo"); + } + + [Fact] + public void Parse_Enum_WithSingleImplements_ExtractsNameOnly() + { + var al = LoadFixture("Enums/PaymentTermsCalcType.al"); + + var result = _parser.ParseEnum(al); + + Assert.Equal("Payment Terms Calc. Type", result.Name); + Assert.Equal(3, result.Values.Count); + Assert.Contains(result.Values, v => v == "Fixed Day"); + Assert.Contains(result.Values, v => v == "Current Month"); + } + + [Fact] + public void Parse_Enum_WithMultipleImplements_ExtractsNameOnly() + { + var al = LoadFixture("Enums/VATBusinessPostingGroup.al"); + + var result = _parser.ParseEnum(al); + + Assert.Equal("VAT Business Posting Group", result.Name); + Assert.Equal(4, result.Values.Count); + Assert.Contains(result.Values, v => v == "Domestic"); + Assert.Contains(result.Values, v => v == "EU"); + Assert.Contains(result.Values, v => v == "Export"); + } + + [Fact] + public void Parse_EnumExtension_WithSlashInExtendedEnumName_ReturnsCorrectName() + { + var al = LoadFixture("EnumExts/SalespersonPurchaserTypeExtension.al"); + + var result = _parser.ParseEnumExtension(al); + + Assert.Equal("Salesperson/Purchaser Type", result.Name); + Assert.Equal(2, result.Values.Count); + Assert.Contains(result.Values, v => v == "Agency"); + Assert.Contains(result.Values, v => v == "Freelance"); + } + + [Fact] + public void Parse_EnumExtension_WithDotsInExtendedEnumName_ReturnsCorrectName() + { + var al = LoadFixture("EnumExts/GenJournalDocumentTypeExtension.al"); + + var result = _parser.ParseEnumExtension(al); + + Assert.Equal("Gen. Journal Document Type", result.Name); + Assert.Single(result.Values, v => v == "Finance Charge Memo"); + } + + [Fact] + public void Parse_EnumExtension_MergesWithSlashNameEnum() + { + var enumAl = LoadFixture("Enums/SalespersonPurchaserType.al"); + var extAl = LoadFixture("EnumExts/SalespersonPurchaserTypeExtension.al"); + + var enumResult = _parser.ParseEnum(enumAl); + var extResult = _parser.ParseEnumExtension(extAl); + + Assert.Same(enumResult, extResult); + Assert.Equal(5, enumResult.Values.Count); // 3 original + 2 from extension + Assert.Contains(enumResult.Values, v => v == "Agency"); + Assert.Contains(enumResult.Values, v => v == "Freelance"); + } + + [Fact] + public void Parse_EnumExtension_MergesWithDotsNameEnum() + { + var enumAl = LoadFixture("Enums/GenJournalDocumentType.al"); + var extAl = LoadFixture("EnumExts/GenJournalDocumentTypeExtension.al"); + + var enumResult = _parser.ParseEnum(enumAl); + var extResult = _parser.ParseEnumExtension(extAl); + + Assert.Same(enumResult, extResult); + Assert.Equal(5, enumResult.Values.Count); // 4 original + 1 from extension + Assert.Contains(enumResult.Values, v => v == "Finance Charge Memo"); } } diff --git a/src/AL2DBML.Tests/Parser/TableParserTests.cs b/src/AL2DBML.Tests/Parser/TableParserTests.cs new file mode 100644 index 0000000..6a7bbd0 --- /dev/null +++ b/src/AL2DBML.Tests/Parser/TableParserTests.cs @@ -0,0 +1,229 @@ +namespace AL2DBML.Tests.Parser; + +public class TableParserTests : TestBase +{ + [Fact] + public void DetectFileType_Table_ReturnsTable() + { + var al = LoadFixture("Tables/Customer.al"); + + var result = _parser.DetectFileType(al); + + Assert.Equal(Core.Enums.AlFileType.Table, result); + } + + [Fact] + public void DetectFileType_TableWithSlashInName_ReturnsTable() + { + var al = LoadFixture("Tables/SalespersonPurchaser.al"); + + var result = _parser.DetectFileType(al); + + Assert.Equal(Core.Enums.AlFileType.Table, result); + } + + [Fact] + public void DetectFileType_TableExtensionWithSlashInName_ReturnsTableExtension() + { + var al = LoadFixture("TableExts/SalespersonPurchaserExtension.al"); + + var result = _parser.DetectFileType(al); + + Assert.Equal(Core.Enums.AlFileType.TableExtension, result); + } + + [Fact] + public void Parse_SimpleTable_ReturnsCorrectModel() + { + ResetParser(); + var al = LoadFixture("Tables/Customer.al"); + + var result = _parser.ParseTable(al); + + Assert.Equal("Customer", result.Name); + Assert.Equal(19, result.Fields.Count); + Assert.Contains(result.Fields, f => f.Name == "No."); + Assert.Contains(result.Fields, (f) => f.Name == "No." && f.IsPrimaryKey == true); + Assert.Contains(result.Fields, f => f.Name == "Search Name" && f.Type == "Code[100]" && f.IsPrimaryKey == false); + } + + [Fact] + public void Parse_TableExtension_ReturnsCorrectModel() + { + ResetParser(); + var al = LoadFixture("TableExts/CustomerExtension.al"); + + var result = _parser.ParseTableExtension(al); + + Assert.Equal("Customer", result.Name); + Assert.Equal(4, result.Fields.Count); + } + + [Fact] + public void Parse_TableExtension_ExtendsExistingTable() + { + ResetParser(); + var baseTable = _parser.ParseTable(LoadFixture("Tables/Customer.al")); + var extension = _parser.ParseTableExtension(LoadFixture("TableExts/CustomerExtension.al")); + + Assert.Same(baseTable, extension); // Should be the same instance + Assert.Equal(23, baseTable.Fields.Count); // Original 19 + 4 from + } + + [Fact] + public void Parse_Table_WithSlashInName_ReturnsCorrectName() + { + ResetParser(); + var al = LoadFixture("Tables/SalespersonPurchaser.al"); + + var result = _parser.ParseTable(al); + + Assert.Equal("Salesperson/Purchaser", result.Name); + Assert.Equal(5, result.Fields.Count); + Assert.Contains(result.Fields, f => f.Name == "Code" && f.IsPrimaryKey == true); + } + + [Fact] + public void Parse_Table_WithSlashInFieldName_ReturnsCorrectFieldName() + { + ResetParser(); + var al = LoadFixture("Tables/SalespersonPurchaser.al"); + + var result = _parser.ParseTable(al); + + Assert.Contains(result.Fields, f => f.Name == "Salesperson/Purchaser Type"); + } + + [Fact] + public void Parse_Table_WithEnumTypeField_ReturnsCorrectType() + { + ResetParser(); + var al = LoadFixture("Tables/SalespersonPurchaser.al"); + + var result = _parser.ParseTable(al); + + Assert.Contains(result.Fields, f => f.Name == "Salesperson/Purchaser Type" && f.Type == "Salesperson/Purchaser Type"); + } + + [Fact] + public void Parse_Table_WithCompositePrimaryKey_MarksBothFieldsAsPrimaryKey() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + Assert.Equal(8, result.Fields.Count); + Assert.Contains(result.Fields, f => f.Name == "Document Type" && f.IsPrimaryKey == true); + Assert.Contains(result.Fields, f => f.Name == "No." && f.IsPrimaryKey == true); + Assert.Contains(result.Fields, f => f.Name == "Amount" && f.IsPrimaryKey == false); + } + + [Fact] + public void Parse_Table_WithSlashInFieldName_PurchaseHeader() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + Assert.Contains(result.Fields, f => f.Name == "Salesperson/Purchaser Code"); + Assert.Contains(result.Fields, f => f.Name == "Buy-from Vendor No."); + Assert.Contains(result.Fields, f => f.Name == "Pay-to Vendor No."); + Assert.Contains(result.Fields, f => f.Name == "Outstanding Amount (LCY)"); + } + + [Fact] + public void Parse_Table_WithEnumTypeField_PurchaseHeader() + { + ResetParser(); + var al = LoadFixture("Tables/PurchaseHeader.al"); + + var result = _parser.ParseTable(al); + + Assert.Contains(result.Fields, f => f.Name == "Document Type" && f.Type == "Purchase Document Type"); + } + + [Fact] + public void Parse_Table_WithDotsInTableName_ReturnsCorrectName() + { + ResetParser(); + var al = LoadFixture("Tables/GenJournalLine.al"); + + var result = _parser.ParseTable(al); + + Assert.Equal("Gen. Journal Line", result.Name); + Assert.Equal(8, result.Fields.Count); + } + + [Fact] + public void Parse_Table_GenJournalLine_CompositePrimaryKey() + { + ResetParser(); + var al = LoadFixture("Tables/GenJournalLine.al"); + + var result = _parser.ParseTable(al); + + Assert.Contains(result.Fields, f => f.Name == "Journal Template Name" && f.IsPrimaryKey == true); + Assert.Contains(result.Fields, f => f.Name == "Line No." && f.IsPrimaryKey == true); + Assert.Contains(result.Fields, f => f.Name == "Amount" && f.IsPrimaryKey == false); + } + + [Fact] + public void Parse_Table_GenJournalLine_MultipleEnumTypeFields() + { + ResetParser(); + var al = LoadFixture("Tables/GenJournalLine.al"); + + var result = _parser.ParseTable(al); + + Assert.Contains(result.Fields, f => f.Name == "Account Type" && f.Type == "Gen. Journal Account Type"); + Assert.Contains(result.Fields, f => f.Name == "Document Type" && f.Type == "Gen. Journal Document Type"); + } + + [Fact] + public void Parse_TableExtension_WithSlashInExtendedTableName_ReturnsCorrectTableName() + { + ResetParser(); + var al = LoadFixture("TableExts/SalespersonPurchaserExtension.al"); + + var result = _parser.ParseTableExtension(al); + + Assert.Equal("Salesperson/Purchaser", result.Name); + Assert.Equal(2, result.Fields.Count); + } + + [Fact] + public void Parse_TableExtension_ExtendsExistingTableWithSlashName() + { + ResetParser(); + var baseTable = _parser.ParseTable(LoadFixture("Tables/SalespersonPurchaser.al")); + var extension = _parser.ParseTableExtension(LoadFixture("TableExts/SalespersonPurchaserExtension.al")); + + Assert.Same(baseTable, extension); + Assert.Equal(7, baseTable.Fields.Count); // 5 original + 2 from extension + } + + [Fact] + public void Parse_TableExtension_WithDotsInExtendedTableName() + { + ResetParser(); + var baseTable = _parser.ParseTable(LoadFixture("Tables/GenJournalLine.al")); + var extension = _parser.ParseTableExtension(LoadFixture("TableExts/GenJournalLineExtension.al")); + + Assert.Same(baseTable, extension); + Assert.Equal(9, baseTable.Fields.Count); // 8 original + 1 from extension + } + + [Fact] + public void Parse_TableExtension_PurchaseHeader_AddsEnumField() + { + ResetParser(); + var baseTable = _parser.ParseTable(LoadFixture("Tables/PurchaseHeader.al")); + var extension = _parser.ParseTableExtension(LoadFixture("TableExts/PurchaseHeaderExtension.al")); + + Assert.Same(baseTable, extension); + Assert.Equal(10, baseTable.Fields.Count); // 8 original + 2 from extension + Assert.Contains(baseTable.Fields, f => f.Name == "Approval Status" && f.Type == "Approval Status"); + } +} diff --git a/src/AL2DBML.Tests/TestBase.cs b/src/AL2DBML.Tests/TestBase.cs index d7874b9..6b7ef0c 100644 --- a/src/AL2DBML.Tests/TestBase.cs +++ b/src/AL2DBML.Tests/TestBase.cs @@ -1,14 +1,34 @@ +using System.Reflection; +using AL2DBML.Application.Interfaces; using AL2DBML.DI; using Microsoft.Extensions.DependencyInjection; public abstract class TestBase { protected IServiceProvider Services { get; } + protected IAlParser _parser { get; private set; } protected TestBase() { Services = new ServiceCollection() .AddAL2Dbml() .BuildServiceProvider(); + + _parser = Services.GetRequiredService(); + } + + protected void ResetParser() + { + _parser = Services.GetRequiredService(); + } + + protected static string LoadFixture(string path) + { + // 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)!; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); } }