Process large XML without an XMLPort

In this video, I show, not one, not two, but three different ways to process XML data without using an XMLPort. AL now has native XML data types and all three methods use the new data types.

https://youtu.be/x8AOo9j-ifE


In this video, Erik demonstrates how to process large XML files in Business Central AL without using an XMLPort. He walks through three different approaches to parsing and importing 30,000 XML records, comparing their readability, correctness, and performance. The video covers AL’s native XML data types, helper functions for type conversion, and even a dynamic approach using RecordRef and FieldRef.

Why Not Use an XMLPort?

XMLPorts are great when your data structure maps directly to your tables — they’re straightforward and easy to set up. But in many real-world scenarios, your XML data doesn’t neatly match your table structure, or you need more control over the parsing logic. In those cases, working directly with AL’s native XML data types gives you far more flexibility.

Compared to the old NAV days where you had to wrestle with XML DOM via OLE Automation controllers or .NET interop, Business Central provides native data types like XmlDocument, XmlElement, XmlNode, and XmlNodeList that make XML processing much more approachable.

Setting Up the Table and Page

Before diving into the import logic, Erik sets up a table to receive the XML data. The table mirrors the fields found in the XML file:

table 50145 "XML Data"
{
    Caption = 'XML Data';
    DataClassification = ToBeClassified;

    fields
    {
        field(1; FirstName; Text[50])
        {
            Caption = 'FirstName';
            DataClassification = ToBeClassified;
        }
        field(2; LastName; Text[50])
        {
            Caption = 'LastName';
            DataClassification = ToBeClassified;
        }
        field(3; Company; Text[100])
        {
            Caption = 'Company';
            DataClassification = ToBeClassified;
        }
        field(4; StreetNumber; Integer)
        {
            Caption = 'StreetNumber';
            DataClassification = ToBeClassified;
        }
        field(5; StreetName; Text[50])
        {
            Caption = 'StreetName';
            DataClassification = ToBeClassified;
        }
        field(6; Suffix; Text[10])
        {
            Caption = 'Suffix';
            DataClassification = ToBeClassified;
        }
        field(7; City; Text[50])
        {
            Caption = 'City';
            DataClassification = ToBeClassified;
        }
        field(8; State; Code[10])
        {
            Caption = 'State';
            DataClassification = ToBeClassified;
        }
        field(9; Zip; Integer)
        {
            Caption = 'Zip';
            DataClassification = ToBeClassified;
        }
        field(10; AreaCode; Code[3])
        {
            Caption = 'AreaCode';
            DataClassification = ToBeClassified;
        }
        field(11; Phone; Code[10])
        {
            Caption = 'Phone';
            DataClassification = ToBeClassified;
        }
        field(12; RowID; Integer)
        {
            Caption = 'RowID';
            DataClassification = ToBeClassified;
        }
    }
    keys
    {
        key(PK; RowID)
        {
            Clustered = true;
        }
    }
}

A list page provides visibility into the imported data, along with actions to trigger the import and clear data between test runs:

page 50145 "XML Data View"
{
    ApplicationArea = All;
    Caption = 'XML Data View';
    PageType = List;
    SourceTable = "XML Data";
    UsageCategory = Lists;

    layout
    {
        area(content)
        {
            repeater(General)
            {
                field(AreaCode; Rec.AreaCode) { ApplicationArea = All; }
                field(City; Rec.City) { ApplicationArea = All; }
                field(Company; Rec.Company) { ApplicationArea = All; }
                field(FirstName; Rec.FirstName) { ApplicationArea = All; }
                field(LastName; Rec.LastName) { ApplicationArea = All; }
                field(Phone; Rec.Phone) { ApplicationArea = All; }
                field(RowID; Rec.RowID) { ApplicationArea = All; }
                field(State; Rec.State) { ApplicationArea = All; }
                field(StreetName; Rec.StreetName) { ApplicationArea = All; }
                field(StreetNumber; Rec.StreetNumber) { ApplicationArea = All; }
                field(Suffix; Rec.Suffix) { ApplicationArea = All; }
                field(Zip; Rec.Zip) { ApplicationArea = All; }
            }
        }
    }
    actions
    {
        area(Processing)
        {
            action(Clear)
            {
                Caption = 'Clear Data';
                ApplicationArea = all;
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                PromotedOnly = true;
                trigger OnAction()
                begin
                    DeleteAll();
                end;
            }
            action(Import)
            {
                Caption = 'Import';
                ApplicationArea = all;
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                PromotedOnly = true;
                trigger OnAction()
                var
                    Import: Codeunit "XML Data";
                begin
                    Import.Import();
                end;
            }
        }
    }
}

Loading the XML Document

The first step in all three approaches is the same: upload the file and parse it into an XmlDocument. The UploadIntoStream function prompts the user to select a file and, if successful, provides an InStream. From there, XmlDocument.ReadFrom — a static function on the type itself — parses the stream into a document object:

if UploadIntoStream('Upload XML', '', '', FileName, InS) then
    if XmlDocument.ReadFrom(InS, XmlDoc) then begin
        XmlDoc.GetRoot(Root);
        Records := Root.GetChildElements('Record');
        // ... process records
    end else
        error('Cannot parse XML');

A quick XML primer: every XML document has a single root element. In this case it’s called something like xml_data. Under that root, there are many child elements all called Record. We use GetRoot to grab the root element, then GetChildElements('Record') to get an XmlNodeList of all the record entries.

Helper Functions for Type Conversion

Before looking at the three import methods, Erik creates reusable helper functions to extract typed values from XML elements. These form the “plumbing code” that you’ll use repeatedly across XML processing tasks:

local procedure GetText(e: XmlElement; Name: Text): Text
var
    FieldNode: XmlNode;
begin
    foreach FieldNode in e.GetChildElements(Name) do
        exit(FieldNode.AsXmlElement().InnerText);
end;

local procedure GetInteger(e: XmlElement; Name: Text): Integer
var
    FieldNode: XmlNode;
    value: Integer;
begin
    foreach FieldNode in e.GetChildElements(Name) do
        if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
            exit(value);
end;

local procedure GetDateTime(e: XmlElement; Name: Text): DateTime
var
    FieldNode: XmlNode;
    value: DateTime;
begin
    foreach FieldNode in e.GetChildElements(Name) do
        if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
            exit(value);
end;

The Magic Number 9

The third parameter in Evaluate is the number 9. Normally, Evaluate takes localization into account — decimal separators, date formats, etc. Passing 9 tells it to ignore all localization rules and use XML/invariant formatting rules instead. This is critical when parsing XML data, which follows standardized formatting.

The ForEach Pattern

Notice the clever use of foreach in these helpers. Since we know there’s only one child element with a given name, the foreach loop effectively acts as a “find first and return” pattern. If no child element exists with that name, the loop simply doesn’t execute, and AL’s implicit behavior returns a default value (blank string, zero, etc.) — no error handling needed.

Method 1: Classic Field-by-Field Assignment

The first approach is the most traditional and arguably the most correct from a validation perspective. For each XML record, you explicitly map fields using the helper functions, calling Insert and then Validate/Modify:

foreach Node in Records do begin
    e := Node.AsXmlElement();

    Data.Init();
    Data.RowID := GetInteger(e, 'RowID');
    Data.Insert(true);
    Data.Validate(Company, GetText(e, 'Company'));
    Data.Validate(FirstName, GetText(e, 'FirstName'));
    Data.Validate(LastName, GetText(e, 'LastName'));
    Data.Modify(true);
end;

This pattern mirrors the data validation approach Erik covered in a previous video. Each field is validated individually, triggers fire, and the record is properly modified. The downside is that each call to GetText or GetInteger performs a separate GetChildElements lookup on the XML element, which adds up across thousands of records.

Result: ~28 seconds for 30,000 records. Acceptable, but the slowest of the three methods.

Method 2: Loop Over XML Child Elements

The second approach flips the logic: instead of looking up each field individually, you iterate over all child elements of the current record once and use a case statement to assign values:

foreach Node in Records do begin
    e := Node.AsXmlElement();

    Data.Init();
    foreach fieldnode in e.GetChildElements() do begin
        field := fieldnode.AsXmlElement();
        case field.Name of
            'Company':
                Data.Company := field.InnerText;
            'RowID':
                Evaluate(Data.RowID, field.InnerText, 9);
            'FirstName':
                Data.FirstName := field.InnerText;
            'LastName':
                Data.LastName := field.InnerText;
        end;
    end;
    Data.Insert(true);
end;

The key insight is that you only call GetChildElements() once per record (without a name filter, to get all children), then distribute values based on element names. This dramatically reduces the number of XML lookups.

Result: ~8 seconds for 30,000 records — more than 3x faster than Method 1. The tradeoff is that you skip the Validate/Modify pattern, so field validation triggers won’t fire.

Method 3: Dynamic Assignment with RecordRef and FieldRef

The third approach is the most dynamic. Instead of hard-coding field mappings, it uses RecordRef and FieldRef to loop through all fields in the table and automatically match them to XML elements by name:

foreach Node in Records do begin
    e := Node.AsXmlElement();

    Ref.Open(DATABASE::"XML Data");
    Ref.Init();
    for i := 1 to Ref.FieldCount do begin
        FieldRef := Ref.FieldIndex(i);
        case FieldRef.Type of
            FieldType::Integer:
                FieldRef.Value := GetInteger(e, FieldRef.Name);
            FieldType::Text,
            FieldType::Code:
                FieldRef.Value := GetText(e, FieldRef.Name);
        end;
    end;
    Ref.Insert();
    Ref.Close();
end;

This works because the XML element names exactly match the table field names. The code loops through every field in the table, checks its type, and calls the appropriate helper function using the field’s name as the XML element name to look up. When you add a new field to the table with a matching XML element name, the import code automatically picks it up with zero changes.

A critical note about FieldRef.Value: it is extremely type-sensitive. You must assign the correct data type, or it will throw an error. That’s why the case on FieldRef.Type is essential.

Result: ~9 seconds for 30,000 records — just one second behind Method 2, but it automatically handled all 12 fields with minimal code.

The Complete Codeunit

Here’s the full codeunit with all three methods included (you’d choose one in practice):

codeunit 50145 "XML Data"
{
    procedure Import()
    var
        InS: InStream;
        FileName: Text;
        XmlDoc: XmlDocument;
        Root: XmlElement;
        Records: XmlNodeList;
        Node: XmlNode;
        e: XmlElement;
        Data: record "XML Data";
        fieldnode: XmlNode;
        field: XmlElement;
        Ref: RecordRef;
        FieldRef: FieldRef;
        i: Integer;
    begin
        if UploadIntoStream('Upload XML', '', '', FileName, InS) then
            if XmlDocument.ReadFrom(InS, XmlDoc) then begin
                XmlDoc.GetRoot(Root);
                Records := Root.GetChildElements('Record');
                foreach Node in Records do begin
                    e := Node.AsXmlElement();

                    // Method 1: Classic field-by-field with validation
                    Data.Init();
                    Data.RowID := GetInteger(e, 'RowID');
                    Data.Insert(true);
                    Data.Validate(Company, GetText(e, 'Company'));
                    Data.Validate(FirstName, GetText(e, 'FirstName'));
                    Data.Validate(LastName, GetText(e, 'LastName'));
                    Data.Modify(true);

                    // Method 2: Loop over XML child elements
                    Data.Init();
                    foreach fieldnode in e.GetChildElements() do begin
                        field := fieldnode.AsXmlElement();
                        case field.Name of
                            'Company':
                                Data.Company := field.InnerText;
                            'RowID':
                                Evaluate(Data.RowID, field.InnerText, 9);
                            'FirstName':
                                Data.FirstName := field.InnerText;
                            'LastName':
                                Data.LastName := field.InnerText;
                        end;
                    end;
                    Data.Insert(true);

                    // Method 3: Dynamic with RecordRef/FieldRef
                    Ref.Open(DATABASE::"XML Data");
                    Ref.Init();
                    for i := 1 to Ref.FieldCount do begin
                        FieldRef := Ref.FieldIndex(i);
                        case FieldRef.Type of
                            FieldType::Integer:
                                FieldRef.Value := GetInteger(e, FieldRef.Name);
                            FieldType::Text,
                            FieldType::Code:
                                FieldRef.Value := GetText(e, FieldRef.Name);
                        end;
                    end;
                    Ref.Insert();
                    Ref.Close();
                end;
            end else
                error('Cannot parse XML');
    end;

    local procedure GetText(e: XmlElement; Name: Text): Text
    var
        FieldNode: XmlNode;
    begin
        foreach FieldNode in e.GetChildElements(Name) do
            exit(FieldNode.AsXmlElement().InnerText);
    end;

    local procedure GetInteger(e: XmlElement; Name: Text): Integer
    var
        FieldNode: XmlNode;
        value: Integer;
    begin
        foreach FieldNode in e.GetChildElements(Name) do
            if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
                exit(value);
    end;

    local procedure GetDateTime(e: XmlElement; Name: Text): DateTime
    var
        FieldNode: XmlNode;
        value: DateTime;
    begin
        foreach FieldNode in e.GetChildElements(Name) do
            if evaluate(Value, FieldNode.AsXmlElement().InnerText, 9) then
                exit(value);
    end;
}

Performance Comparison

All three methods were tested with the same 30,000 record XML file (~432,000 lines of XML):

  • Method 1 (Classic with Validate/Modify): ~28 seconds — most correct with full validation triggers, but slowest due to repeated XML lookups and database round-trips (Insert + Modify per record)
  • Method 2 (Loop over XML children): ~8 seconds — fastest, as child elements are retrieved only once per record
  • Method 3 (Dynamic RecordRef/FieldRef): ~9 seconds — nearly as fast as Method 2, but with the bonus of automatically mapping all fields without explicit code per field

Key Takeaways

  • AL’s native XML types (XmlDocument, XmlElement, XmlNode, XmlNodeList) make XML processing in Business Central straightforward compared to the old NAV approach with OLE Automation.
  • Static functions on types (like XmlDocument.ReadFrom) are an AL feature that mirrors C#/.NET patterns.
  • The Evaluate magic number 9 tells the system to use XML/invariant formatting rules, ignoring localization — essential for reliable XML data conversion.
  • XmlElement vs. XmlNode: XmlElement inherits from XmlNode. In practice, use whichever the API returns — GetRoot returns an element, GetChildElements returns a node list, and you can convert between them with AsXmlElement().
  • Helper functions like GetText, GetInteger, and GetDateTime are generic plumbing code that you’ll reuse across projects — potentially good candidates for a shared library or even a contribution to the System Application.
  • Choose your method based on your needs: Method 1 when validation matters, Method 2 for performance with explicit mappings, Method 3 when your XML and table structures align and you want minimal maintenance overhead.