On this page

Importing customers from text

A useful import accepts good records and reports bad ones with their row numbers. It needs an explicit file format, stable duplicate detection and the same validation rules as interactive entry.

This example accepts lines of the form Code|Name|Email. The code is two ASCII uppercase letters followed by four digits. Fields cannot contain the separator. This deliberately simple format has no quoting or escaped delimiters; use an existing CSV parser when those capabilities are required.

AB0042|Ana Rivera|ana@example.com
AB0043|Leon Chen|leon@example.com
AB0042|Duplicate Customer|other@example.com
invalid row

The report and importer

The Customer and CustomerValidator declarations are from validation. The import creates fresh customer identifiers; the input code is an import-local duplicate key, not a persisted Customer.Id.

import Global.System
import Global.System.Collections.Generic
import Global.System.IO
import Global.System.Linq

public class ImportReport
    public BatchName as String = ""
    public Accepted as List <of Customer> = new List <of Customer>()
    public Rejected as List <of String> = new List <of String>()
end class

public class CustomerImporter
    private readonly validator as CustomerValidator

    public constructor(customerValidator as CustomerValidator)
        require customerValidator is not nothing
        validator = customerValidator
    end constructor

    public action Read(path as String)
        returns ImportReport
        require path is not whitespace
        let report = new ImportReport()
        let filename = Path.GetFileName(path)
        if filename is like "customers-{{let batch = *}}.txt" then
            report.BatchName = batch
        else
            throw new ArgumentException("Expected a customers-<batch>.txt filename")
        end if

        let seen = new HashSet <of String>(StringComparer.OrdinalIgnoreCase)
        let rowNumber = 0
        for_each line in File.ReadLines(path)
            rowNumber += 1
            if line is whitespace then continue

            if line is match_case_sensitive "^(?<code>[A-Z]{2}[0-9]{4})[|](?<name>[^|]+)[|](?<email>[^|]+)$" then
                let customer = new Customer()
                customer.Id = Guid.NewGuid()
                customer.Name = name.Trim()
                customer.Email = email.Trim()
                let validation = validator.Validate(customer)

                if not validation.IsValid then
                    for_each failure in validation.Errors
                        report.Rejected.Add("Row {{rowNumber}}: {{failure.PropertyName}} — {{failure.ErrorMessage}}")
                    end for_each
                elseif not seen.Add(code) then
                    report.Rejected.Add("Row {{rowNumber}}: duplicate code {{code}}")
                else
                    customer.IsActive = true
                    report.Accepted.Add(customer)
                end if
            else
                report.Rejected.Add("Row {{rowNumber}}: expected Code|Name|Email")
            end if
        end for_each
        return report
    end action

    public action NamesForReview(report as ImportReport)
        returns List <of String>
        require report is not nothing
        return report.Accepted.OrderBy(.Name).Select(.Name).ToList()
    end action
end class

The filename wildcard introduces batch only within its successful branch. The fixed regex introduces code, name and email within the record's successful branch. A dynamic regex would not introduce unknown locals into the source scope.

The explicit is match_case_sensitive predicate enforces the uppercase code format. Ordinary is match ignores case. HashSet nevertheless uses an explicit ordinal case-insensitive comparer for duplicate keys. Source identifier casing and data comparison are separate choices; DirectSharp's case-insensitive names do not automatically change every imported .NET collection's equality policy.

File.ReadLines enumerates the file lazily using the normal .NET API. for_each uses and disposes its enumerator. File-open and read failures propagate; they are not converted into malformed-row messages. This prevents a broken or partially read file from being reported as a successfully completed import.

The seen set receives a code only after validation succeeds. An invalid first record therefore does not prevent a later corrected record with the same code from being accepted. Each rejected validation can add several messages; Rejected.Count counts diagnostics, not necessarily rejected records.

NamesForReview demonstrates normal LINQ extension calls. OrderBy and Select use typed selectors, and ToList materializes the result. The default .NET String ordering used by OrderBy is culture-sensitive; supply an explicit comparer when exported order must be invariant across machines.

Reporting and persistence

public class ImportExample
    public static action Run(path as String)
        let importer = new CustomerImporter(new CustomerValidator())
        let report = importer.Read(path)
        Global.System.Console.WriteLine("Batch {{report.BatchName}}: {{report.Accepted.Count}} accepted")
        for_each issue in report.Rejected
            Global.System.Console.WriteLine(issue)
        end for_each
        for_each name in importer.NamesForReview(report)
            Global.System.Console.WriteLine("Ready: {{name}}")
        end for_each
    end action
end class

Activation here is the sample's import policy. A production onboarding process may instead retain inactive customers until consent or verification is complete. Persistence is deliberately a following step: choose whether an import is transactional or permits partial commits before calling CustomerRepository.AddAsync.

Test blank lines, invalid format, trimmed fields, duplicate codes, multiple validation errors and unreadable files. Treat record diagnostics and infrastructure failures as distinct outcomes.