XQuery

Extensibility

Extending XQuery with custom functions, modules, external functions, and .NET integration

#Extensibility

Real projects outgrow the standard library quickly. You need domain-specific functions, integration with external systems, and custom processing logic. XQuery's extensibility model lets you build reusable libraries, declare functions whose implementation lives in the host environment, and integrate deeply with .NET applications.

For C# developers: the motivation matches C# extension methods, MEF/plugin systems, and IServiceProvider dependency injection. The language does what your application needs, not just what the specification committee anticipated.

#Contents


#Why Extensibility Matters

Standard XQuery gives you rich data querying and transformation. But consider what a real application needs:

  • Send an email when an order exceeds a threshold.

  • Call a REST API to enrich customer data with external information.

  • Validate a document against business rules that change monthly.

  • Format output using company-specific templates.

  • Log query activity to an audit trail.

  • Generate PDF invoices from XML order data.

None of these are in the XQuery specification. Extensibility bridges the gap between what XQuery provides and what your application requires.

For C# developers: C# solves the same problem with extension methods, DI, and plugins.

csharp
// C# solves the same problem with extension methods, DI, and plugins
public static class OrderExtensions
{
    // Domain-specific logic added to existing types
    public static decimal CalculateTax(this Order order, string state) { /* ... */ }
    public static bool RequiresApproval(this Order order) => order.Total > 10_000m;
}
// Dependency injection β€” runtime-provided implementations
services.AddScoped<IEmailService, SmtpEmailService>();
services.AddScoped<IPaymentGateway, StripeGateway>();
                                    

XQuery achieves the same extensibility through user-defined functions, library modules, external functions, and host-environment integration.


#Custom Functions in C#: the XQueryFunction Model

PhoenixmlDb's core extension point for custom functions is the abstract class XQueryFunction, in the PhoenixmlDb.XQuery namespace. Subclass it to add a function the engine can call from any query.

XQueryFunction exposes four members you implement:

Member

Purpose

QName Name

The function's qualified name, as it appears in a query

XdmSequenceType ReturnType

The static return type of the function

IReadOnlyList<FunctionParameterDef> Parameters

The declared parameters, in order

ValueTask<object?> InvokeAsync(IReadOnlyList<object?> arguments, ExecutionContext context)

The C# method body where the custom logic runs

Here is a custom function that looks up an exchange rate:

csharp
using PhoenixmlDb.Core;
using PhoenixmlDb.XQuery;
public sealed class ExchangeRateFunction : XQueryFunction
{
    public override QName Name => new(NamespaceId.None, "get-exchange-rate");
    public override XdmSequenceType ReturnType => XdmSequenceType.Decimal;
    public override IReadOnlyList<FunctionParameterDef> Parameters =>
    [
        new() { Name = new QName(NamespaceId.None, "from"), Type = XdmSequenceType.String },
        new() { Name = new QName(NamespaceId.None, "to"), Type = XdmSequenceType.String },
    ];
    public override ValueTask<object?> InvokeAsync(
        IReadOnlyList<object?> arguments,
        ExecutionContext context)
    {
        var from = (string)arguments[0]!;
        var to = (string)arguments[1]!;
        // The custom logic runs here β€” call an external API, a cache, or a
        // rate table, then return the result.
        decimal rate = LookUpExchangeRate(from, to);
        return ValueTask.FromResult<object?>(rate);
    }
}
                                      

Register an instance with the engine's FunctionLibrary before running a query:

csharp
FunctionLibrary library = FunctionLibrary.Standard.Copy();
library.Register(new ExchangeRateFunction());
                                      

Register adds the function under its Name and arity, so the query resolves calls to get-exchange-rate($from, $to) against your InvokeAsync implementation. Every custom function you write follows this same shape: subclass XQueryFunction, describe its signature through the four members, and register it with FunctionLibrary.Register.

For C# developers: XQueryFunction plays the role of a strongly-typed delegate registration. Name, ReturnType, and Parameters are the equivalent of a method signature. InvokeAsync is the method body. FunctionLibrary.Register is the equivalent of adding a service to an IServiceCollection.


#User-Defined Functions

This section provides a brief recap. For comprehensive coverage, see Functions and Modules.

Functions are the basic unit of reuse in XQuery:

xquery
declare function local:calculate-tax(
  $amount as xs:decimal,
  $state as xs:string
) as xs:decimal {
  let $rate := switch ($state)
    case "CA" return 0.0725
    case "NY" return 0.08
    case "TX" return 0.0625
    default return 0.05
  return round($amount * $rate, 2)
};
local:calculate-tax(199.99, "CA")
(: Result: 14.50 :)
                                        

Functions can be recursive, accept and return functions (higher-order), and work with any XQuery type β€” sequences, maps, arrays, and XML nodes.


#Library Modules

Library modules package functions into reusable units that any query can import. They are the XQuery equivalent of C# class libraries or NuGet packages.

#Creating a Module

A library module file starts with a module namespace declaration and contains only function and variable declarations β€” no body expression:

xquery
(: file: lib/string-utils.xqm :)
module namespace str = "http://example.com/string-utils";
declare function str:title-case($text as xs:string) as xs:string {
  string-join(
    for $word in tokenize($text, "\s+")
    return concat(
      upper-case(substring($word, 1, 1)),
      lower-case(substring($word, 2))
    ),
    " "
  )
};
declare function str:slug($text as xs:string) as xs:string {
  replace(lower-case(normalize-space($text)), "[^a-z0-9]+", "-")
};
declare function str:truncate(
  $text as xs:string,
  $max as xs:integer
) as xs:string {
  if (string-length($text) <= $max) then $text
  else concat(substring($text, 1, $max - 3), "...")
};
                                          

C# parallel:

csharp
// A class library project: StringUtils.cs
namespace Example.StringUtils;
public static class StringHelpers
{
    public static string TitleCase(string text) =>
        CultureInfo.CurrentCulture.TextInfo.ToTitleCase(text.ToLower());
    public static string Slug(string text) =>
        Regex.Replace(text.ToLower().Trim(), @"[^a-z0-9]+", "-");
    public static string Truncate(string text, int max) =>
        text.Length <= max ? text : text[..(max - 3)] + "...";
}
                                          

#Importing a Module

xquery
import module namespace str = "http://example.com/string-utils" at "lib/string-utils.xqm";
str:title-case("hello world")  (: Result: "Hello World" :)
str:slug("My Blog Post!")       (: Result: "my-blog-post-" :)
                                          

#Module Location Resolution

The at clause specifies where the processor should find the module file. This can be:

  • A relative path β€” resolved against the base URI of the importing module:

    xquery
    import module namespace util = "http://example.com/util" at "lib/util.xqm";
                                                  
  • An absolute path β€” a full file path or URL:

    xquery
    import module namespace util = "http://example.com/util"
      at "file:///opt/xquery-libs/util.xqm";
                                                  
  • Omitted β€” the processor resolves the module by namespace URI using a module repository or catalog:

    xquery
    import module namespace util = "http://example.com/util";
    (: The processor knows where to find this namespace :)
                                                  

#Versioning with Namespaces

Use namespace URIs to version your modules:

xquery
(: Version 1 :)
module namespace api-v1 = "http://example.com/api/v1";
(: Version 2 β€” new features, breaking changes :)
module namespace api-v2 = "http://example.com/api/v2";
                                          

Callers import the version they need:

xquery
(: Legacy queries use v1 :)
import module namespace api = "http://example.com/api/v1" at "api-v1.xqm";
(: New queries use v2 :)
import module namespace api = "http://example.com/api/v2" at "api-v2.xqm";
                                          

#Building a Module Library

A realistic project might organize modules like this:

lib/
  string-utils.xqm      (: string manipulation :)
  date-utils.xqm         (: date formatting and parsing :)
  validation.xqm         (: business rule validation :)
  formatting.xqm         (: output formatting :)
  db/
    queries.xqm          (: common database queries :)
    indexes.xqm          (: index management :)
  api/
    rest-client.xqm      (: REST API helpers :)
    auth.xqm             (: authentication functions :)
                                          
xquery
(: A main module importing several library modules :)
import module namespace str = "http://example.com/strings" at "lib/string-utils.xqm";
import module namespace dt = "http://example.com/dates" at "lib/date-utils.xqm";
import module namespace val = "http://example.com/validation" at "lib/validation.xqm";
import module namespace db = "http://example.com/db" at "lib/db/queries.xqm";
                                          

#External Functions

External functions are declared in XQuery but implemented by the host environment. The XQuery file specifies the function signature; the host (your .NET application) provides the actual code.

#Declaring External Functions

xquery
(: Declare an external function β€” no body, just a signature :)
declare namespace app = "http://example.com/app";
declare function app:send-email(
  $to as xs:string,
  $subject as xs:string,
  $body as xs:string
) as xs:boolean external;
declare function app:log(
  $level as xs:string,
  $message as xs:string
) external;
declare function app:get-config(
  $key as xs:string
) as xs:string? external;
                                            

The external keyword tells the processor that the function body is not in XQuery β€” it will be provided at runtime by the host.

#Using External Functions

Once declared, external functions are called like any other function:

xquery
declare namespace app = "http://example.com/app";
declare function app:send-email($to as xs:string, $subject as xs:string, $body as xs:string) as xs:boolean external;
declare function app:log($level as xs:string, $message as xs:string) external;
for $order in //order[total > 10000]
let $customer := //customer[@id = $order/customer-id]
let $email-sent := app:send-email(
  $customer/email,
  concat("Large order #", $order/@id, " requires approval"),
  concat("Order total: $", $order/total)
)
return (
  app:log("INFO", concat("Notification sent for order ", $order/@id, ": ", $email-sent)),
  <processed order-id="{ $order/@id }" notified="{ $email-sent }"/>
)
                                            

C# parallel:

csharp
// External functions are like interface methods β€” declared but not implemented
// The host provides the implementation, similar to dependency injection
public interface INotificationService
{
    bool SendEmail(string to, string subject, string body);
    void Log(string level, string message);
    string? GetConfig(string key);
}
// At runtime, a concrete implementation is injected
services.AddScoped<INotificationService, SmtpNotificationService>();
                                            

#.NET Integration with PhoenixmlDb

PhoenixmlDb provides a .NET API for running XQuery from C# applications. This is how you connect the XQuery world to the .NET world.

#Setting External Variables from C#

Pass data from your .NET application into an XQuery query:

csharp
using PhoenixmlDb.XQuery.Execution;
var engine = new QueryEngine();
string xquery = File.ReadAllText("reports/user-orders.xq");
var compiled = engine.Compile(xquery);
using var context = engine.CreateContext();
context.SetExternalVariable("user-id", "U12345");
context.SetExternalVariable("page", 1);
context.SetExternalVariable("page-size", 25);
context.SetExternalVariable("include-archived", false);
context.SetExternalVariable("report-date", DateTime.Now.ToString("yyyy-MM-dd"));
var results = new List<object?>();
await foreach (var item in compiled.ExecutionPlan!.ExecuteAsync(context))
{
    results.Add(item);
}
                                              

The XQuery file declares matching external variables:

xquery
declare variable $user-id as xs:string external;
declare variable $page as xs:integer external;
declare variable $page-size as xs:integer external := 25;
declare variable $include-archived as xs:boolean external := false();
declare variable $report-date as xs:date external;
for $order in collection("orders")/order[customer-id = $user-id]
where if ($include-archived) then true() else $order/status != "archived"
order by xs:date($order/date) descending
return $order
                                              

#Passing XML Documents

XQueryFacade binds a single XML input to the context item and to $input in one call:

csharp
using PhoenixmlDb.XQuery;
var facade = new XQueryFacade();
string customersXml = File.ReadAllText("data/customers.xml");
string xquery = @"
    declare variable $input external;
    for $c in $input//customer
    where $c/status = 'active'
    return $c/name/text()
";
string result = await facade.EvaluateAsync(xquery, customersXml);
                                              

#Registering Extension Functions from .NET

Register C# methods as XQuery functions by subclassing XQueryFunction, the same pattern shown in Custom Functions in C#: the XQueryFunction Model above. Each class implements Name, ReturnType, Parameters, and InvokeAsync; the class body is elided below since it repeats that shape:

csharp
using PhoenixmlDb.XQuery;
using PhoenixmlDb.XQuery.Execution;
using PhoenixmlDb.XQuery.Functions;
// Each follows the XQueryFunction pattern: Name, ReturnType, Parameters, InvokeAsync.
public sealed class GetExchangeRateFunction : XQueryFunction { /* ... */ }
public sealed class SendEmailFunction : XQueryFunction { /* ... */ }
public sealed class GetConfigFunction : XQueryFunction { /* ... */ }
var library = FunctionLibrary.Standard.Copy();
library.Register(new GetExchangeRateFunction());
library.Register(new SendEmailFunction());
library.Register(new GetConfigFunction());
var engine = new QueryEngine(functions: library);
                                              

Because these functions register under NamespaceId.None, the query calls them unprefixed, the same way it calls a local: function:

xquery
let $rate := get-exchange-rate("USD", "EUR")
let $threshold := xs:decimal(get-config("order-alert-threshold"))
for $order in //order[total * $rate > $threshold]
return send-email(
  $order/customer/email,
  concat("Large order alert: #", $order/@id),
  concat("Total (EUR): ", round($order/total * $rate, 2))
)
                                              

#Reading Query Results Back into .NET Types

XQueryFacade detects declare option output:method in the query and serializes accordingly, so a query that produces JSON hands back a JSON string you can deserialize with System.Text.Json:

csharp
using PhoenixmlDb.XQuery;
using System.Text.Json;
var facade = new XQueryFacade();
string xquery = @"
    declare namespace output = ""http://www.w3.org/2010/xslt-xquery-serialization"";
    declare option output:method ""json"";
    array {
      for $p in collection('products')/product
      where $p/price > 100
      order by $p/price descending
      return
        map {
          'id': string($p/@id),
          'name': $p/name/text(),
          'price': number($p/price),
          'category': $p/category/text()
        }
    }
";
string json = await facade.EvaluateAsync(xquery);
var products = JsonSerializer.Deserialize<List<Product>>(json);
// Or work with XML results directly
string xmlQuery = @"
    <products>{
      for $p in collection('products')/product
      where $p/price > 100
      return $p
    }</products>
";
string xmlResult = await facade.EvaluateAsync(xmlQuery);
var xdoc = XDocument.Parse(xmlResult);
                                              

#Using the XQuery Engine Programmatically

A complete example showing the engine lifecycle:

csharp
using System.Text;
using PhoenixmlDb.XQuery.Execution;
using PhoenixmlDb.XQuery.Functions;
public class OrderReportService
{
    private readonly QueryEngine _engine;
    private readonly IConfiguration _config;
    public OrderReportService(IConfiguration config)
    {
        _config = config;
        // Register extension functions once, then build the engine around them.
        // Each class follows the XQueryFunction pattern shown earlier on this page.
        var library = FunctionLibrary.Standard.Copy();
        library.Register(new FormatCurrencyFunction());
        library.Register(new CurrentUserFunction());
        _engine = new QueryEngine(functions: library);
    }
    public async Task<string> GenerateReportAsync(
        string department, DateTime startDate, DateTime endDate)
    {
        string query = await File.ReadAllTextAsync("queries/department-report.xq");
        var compiled = _engine.Compile(query);
        using var context = _engine.CreateContext();
        context.SetExternalVariable("department", department);
        context.SetExternalVariable("start-date", startDate.ToString("yyyy-MM-dd"));
        context.SetExternalVariable("end-date", endDate.ToString("yyyy-MM-dd"));
        context.SetExternalVariable("report-title",
            $"{department} Report: {startDate:MMM d} - {endDate:MMM d, yyyy}");
        var sb = new StringBuilder();
        await foreach (var item in compiled.ExecutionPlan!.ExecuteAsync(context))
        {
            sb.Append(item);
        }
        return sb.ToString();
    }
}
                                              

#Extension Namespaces

When creating extension functions, follow namespace conventions to avoid collisions and communicate intent:

xquery
(: Application-specific extensions :)
declare namespace app = "http://yourcompany.com/app";
(: Domain-specific extensions :)
declare namespace crm = "http://yourcompany.com/crm";
declare namespace inv = "http://yourcompany.com/inventory";
(: Infrastructure extensions :)
declare namespace log = "http://yourcompany.com/logging";
declare namespace cache = "http://yourcompany.com/cache";
                                                

Conventions:

  • Use your organization's domain in the namespace URI.

  • Group related functions under the same namespace.

  • Use meaningful prefixes that are short but descriptive.

  • Document your extension namespaces for other developers.

C# parallel:

csharp
// Same principle as C# namespace conventions
namespace YourCompany.App { /* ... */ }
namespace YourCompany.CRM { /* ... */ }
namespace YourCompany.Infrastructure.Logging { /* ... */ }
                                                

#Database Integration

PhoenixmlDb stores XML documents in collections. XQuery accesses them through doc() and collection():

#Accessing Documents

xquery
(: Load a single document by URI :)
let $config := doc("config.xml")
(: Query all documents in a collection :)
for $customer in collection("customers")/customer
where $customer/status = "active"
return $customer
(: Query across multiple collections :)
for $order in collection("orders")/order
let $customer := collection("customers")/customer[@id = $order/customer-id]
return
  <order-summary>
    <order-id>{ string($order/@id) }</order-id>
    <customer-name>{ $customer/name/text() }</customer-name>
    <total>{ $order/total/text() }</total>
  </order-summary>
                                                  

#Managing Collections from .NET

csharp
var db = new PhoenixmlDatabase("connection-string");
// Store documents
await db.StoreDocumentAsync("customers", "C001.xml", customerXml);
await db.StoreDocumentAsync("orders", "O5001.xml", orderXml);
// Run a query against stored collections
var engine = db.CreateQueryEngine();
string xquery = @"
    for $c in collection('customers')/customer
    where $c/total-orders > 10
    order by $c/total-orders descending
    return $c
";
var topCustomers = new List<object?>();
await foreach (var item in engine.ExecuteAsync(xquery))
{
    topCustomers.Add(item);
}
                                                  

#Combining with Other .NET Libraries

XQuery results flow naturally into the rest of the .NET ecosystem.

#XQuery Results to System.Text.Json

csharp
// Execute XQuery that returns maps (JSON-friendly)
string xquery = @"
    array {
      for $p in collection('products')/product
      return map {
        'id': string($p/@id),
        'name': $p/name/text(),
        'price': number($p/price)
      }
    }
";
var result = new List<object?>();
await foreach (var item in engine.ExecuteAsync(xquery))
{
    result.Add(item);
}
// Serialize to JSON
string json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
    WriteIndented = true
});
                                                    

#XQuery Results to HttpClient

csharp
// Build an API request payload from XQuery results
string payloadXquery = @"
    <request>
      <orders>{
        for $o in collection('orders')/order[status = 'pending']
        return
          <order id='{ $o/@id }'>
            <total>{ $o/total/text() }</total>
          </order>
      }</orders>
    </request>
";
string payload = "";
await foreach (var item in engine.ExecuteAsync(payloadXquery))
{
    payload += item;
}
var httpClient = new HttpClient();
var content = new StringContent(payload, Encoding.UTF8, "application/xml");
var response = await httpClient.PostAsync("https://api.example.com/process", content);
                                                    

#XQuery with Entity Framework

csharp
// Use XQuery to transform data that came from a relational database
var orders = await context.Orders
    .Where(o => o.Status == "shipped")
    .Select(o => new { o.Id, o.CustomerName, o.Total, o.ShipDate })
    .ToListAsync();
// Convert to XML for XQuery processing
var xml = new XElement("orders",
    orders.Select(o => new XElement("order",
        new XAttribute("id", o.Id),
        new XElement("customer", o.CustomerName),
        new XElement("total", o.Total),
        new XElement("ship-date", o.ShipDate?.ToString("yyyy-MM-dd") ?? "")
    )));
string xquery = @"
    declare variable $input external;
    let $orders := parse-xml($input)/orders
    <shipping-report generated='{ current-dateTime() }'>
    {
      for $order in $orders/order
      group by $date := $order/ship-date
      order by $date descending
      return
        <day date='{ $date }' count='{ count($order) }'
             total='{ sum($order/total) }'/>
    }
    </shipping-report>
";
var compiled = engine.Compile(xquery);
using var context = engine.CreateContext();
context.SetExternalVariable("input", xml.ToString());
string report = "";
await foreach (var item in compiled.ExecutionPlan!.ExecuteAsync(context))
{
    report += item;
}
                                                    

#Practical Patterns

#Custom Validation Functions

Build domain-specific validation as a library module, then call it from queries or from .NET:

xquery
(: file: lib/validation.xqm :)
module namespace val = "http://example.com/validation";
declare function val:validate-order($order as element(order)) as element(errors)? {
  let $errors := (
    if (not($order/customer-id))
    then <error field="customer-id">Customer ID is required</error>
    else (),
    if (not($order/items/item))
    then <error field="items">Order must have at least one item</error>
    else (),
    if (xs:decimal($order/total) <= 0)
    then <error field="total">Total must be positive</error>
    else (),
    for $item in $order/items/item
    where xs:integer($item/quantity) <= 0
    return <error field="item-{ $item/@sku }">
      Quantity must be positive for SKU { string($item/@sku) }
    </error>
  )
  return
    if (exists($errors))
    then <errors>{ $errors }</errors>
    else ()
};
                                                      
xquery
(: Main query using validation :)
import module namespace val = "http://example.com/validation" at "lib/validation.xqm";
for $order in collection("pending-orders")/order
let $errors := val:validate-order($order)
return
  if ($errors)
  then <rejected order-id="{ $order/@id }">{ $errors }</rejected>
  else <accepted order-id="{ $order/@id }"/>
                                                      

#Data Enrichment β€” Calling External APIs

Use external functions to enrich XML data with information from external services:

xquery
declare namespace geo = "http://example.com/geo";
declare function geo:geocode($address as xs:string) as element(location)? external;
declare function geo:distance($lat1 as xs:decimal, $lon1 as xs:decimal,
                               $lat2 as xs:decimal, $lon2 as xs:decimal) as xs:decimal external;
let $warehouse := geo:geocode("123 Warehouse Blvd, Chicago, IL")
for $customer in collection("customers")/customer
let $location := geo:geocode($customer/address/text())
let $dist := geo:distance(
  $warehouse/lat, $warehouse/lon,
  $location/lat, $location/lon
)
order by $dist
return
  <customer name="{ $customer/name/text() }"
            distance-miles="{ round($dist, 1) }"
            shipping-zone="{ if ($dist < 100) then 'local'
                             else if ($dist < 500) then 'regional'
                             else 'national' }"/>
                                                      

The .NET application registers the geo:geocode and geo:distance functions using its own geocoding service.

#Report Generation

xquery
declare namespace fmt = "http://example.com/format";
declare function fmt:currency($amount as xs:decimal) as xs:string external;
declare variable $report-date as xs:date external;
<report date="{ $report-date }">
  <summary>
  {
    let $orders := collection("orders")/order[xs:date(date) = $report-date]
    return (
      <total-orders>{ count($orders) }</total-orders>,
      <total-revenue>{ fmt:currency(sum($orders/total)) }</total-revenue>,
      <average-order>{ fmt:currency(avg($orders/total)) }</average-order>
    )
  }
  </summary>
  <by-category>
  {
    for $order in collection("orders")/order[xs:date(date) = $report-date]
    group by $cat := $order/category
    order by sum($order/total) descending
    return
      <category name="{ $cat }"
                orders="{ count($order) }"
                revenue="{ fmt:currency(sum($order/total)) }"/>
  }
  </by-category>
</report>
                                                      

#ETL Pipeline β€” XQuery with .NET Processing

Combine XQuery transformation with .NET I/O and business logic:

csharp
using System.Text;
using PhoenixmlDb.XQuery.Execution;
public class EtlPipeline
{
    private readonly QueryEngine _engine = new();
    private async Task<string> RunAsync(string queryPath, params (string Name, object? Value)[] variables)
    {
        string xquery = await File.ReadAllTextAsync(queryPath);
        var compiled = _engine.Compile(xquery);
        using var context = _engine.CreateContext();
        foreach (var (name, value) in variables)
        {
            context.SetExternalVariable(name, value);
        }
        var sb = new StringBuilder();
        await foreach (var item in compiled.ExecutionPlan!.ExecuteAsync(context))
        {
            sb.Append(item);
        }
        return sb.ToString();
    }
    public async Task RunDailyImportAsync()
    {
        // 1. Extract β€” load source data
        string sourceXml = await File.ReadAllTextAsync("/data/daily-feed.xml");
        // 2. Transform β€” XQuery handles the heavy lifting
        string transformed = await RunAsync("etl/transform.xq",
            ("source", sourceXml),
            ("import-date", DateTime.Now.ToString("yyyy-MM-dd")));
        // 3. Validate β€” XQuery validation module
        string validation = await RunAsync("etl/validate.xq", ("data", transformed));
        if (validation.Contains("<errors>"))
        {
            await File.WriteAllTextAsync(
                $"/logs/validation-errors-{DateTime.Now:yyyyMMdd}.xml",
                validation);
            throw new InvalidDataException("Validation failed. See error log.");
        }
        // 4. Load β€” store in PhoenixmlDb
        var db = new PhoenixmlDatabase("connection-string");
        var docs = XDocument.Parse(transformed).Descendants("record");
        foreach (var doc in docs)
        {
            string id = doc.Attribute("id")?.Value ?? Guid.NewGuid().ToString();
            await db.StoreDocumentAsync("imported-records", $"{id}.xml", doc.ToString());
        }
    }
}
                                                      

The transformation query:

xquery
(: file: etl/transform.xq :)
declare variable $source external;
declare variable $import-date as xs:date external;
let $feed := parse-xml($source)/feed
return
  <records imported="{ $import-date }">
  {
    for $item in $feed/item
    where $item/status != "deleted"
    return
      <record id="{ $item/@id }">
        <name>{ normalize-space($item/name) }</name>
        <category>{ lower-case($item/type) }</category>
        <price>{ round(xs:decimal($item/price), 2) }</price>
        <imported-on>{ $import-date }</imported-on>
      </record>
  }
  </records>