From e9cfdbb082bdc4c262541286dddc507db1d41777 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 24 Apr 2026 23:27:39 -0400 Subject: [PATCH 1/2] [feature] Support map atomization in XQuery 4.0 In XQuery 3.1, maps are function items and cannot be atomized (FOTY0013). In XQuery 4.0, maps are a distinct type whose atomization returns the concatenation of the atomized values of their entries. This change adds version-gated map atomization so that ~103 XQTS QT4 tests that fail with FOTY0013 can pass when combined with the XQ4 parser support (v2/xq4-parser-extensions). - AbstractMapType: override atomize(), add isXq4Atomizable() and atomizeValues() for XQ4 semantics - Atomize: expand XQ4 maps in static atomize(Sequence), paralleling ArrayType.flatten() for arrays - ConcatExpr: allow XQ4 maps in string concatenation instead of unconditionally throwing FOTY0013 XQ 3.1 behavior is unchanged: maps still raise FOTY0013. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/org/exist/xquery/Atomize.java | 17 +++++- .../java/org/exist/xquery/ConcatExpr.java | 14 ++++- .../xquery/functions/map/AbstractMapType.java | 55 +++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/Atomize.java b/exist-core/src/main/java/org/exist/xquery/Atomize.java index 19b4c9670ca..7ff5ef58a37 100644 --- a/exist-core/src/main/java/org/exist/xquery/Atomize.java +++ b/exist-core/src/main/java/org/exist/xquery/Atomize.java @@ -23,6 +23,7 @@ import org.exist.dom.persistent.DocumentSet; import org.exist.xquery.functions.array.ArrayType; +import org.exist.xquery.functions.map.AbstractMapType; import org.exist.xquery.util.ExpressionDumper; import org.exist.xquery.value.Item; import org.exist.xquery.value.Sequence; @@ -75,15 +76,25 @@ public static Sequence atomize(Sequence input) throws XPathException { if (input.isEmpty()) {return Sequence.EMPTY_SEQUENCE;} input = ArrayType.flatten(input); - if (input.hasOne()) {return - input.itemAt(0).atomize(); + if (input.hasOne()) { + final Item single = input.itemAt(0); + // XQ4: maps are atomizable — expand to their values before atomizing + if (single instanceof AbstractMapType && ((AbstractMapType) single).isXq4Atomizable()) { + return ((AbstractMapType) single).atomizeValues(); + } + return single.atomize(); } Item next; final ValueSequence result = new ValueSequence(); for(final SequenceIterator i = input.iterate(); i.hasNext(); ) { next = i.nextItem(); - result.add(next.atomize()); + // XQ4: maps are atomizable — expand to their values before atomizing + if (next instanceof AbstractMapType && ((AbstractMapType) next).isXq4Atomizable()) { + result.addAll(((AbstractMapType) next).atomizeValues()); + } else { + result.add(next.atomize()); + } } return result; } diff --git a/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java b/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java index fd19c50e2ef..6e509fa150d 100644 --- a/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java +++ b/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java @@ -21,6 +21,7 @@ */ package org.exist.xquery; +import org.exist.xquery.functions.map.AbstractMapType; import org.exist.xquery.util.Error; import org.exist.xquery.value.*; @@ -65,8 +66,17 @@ public Sequence eval(Sequence contextSequence, Item contextItem) final Sequence seq = step.eval(contextSequence, contextItem); for (final SequenceIterator i = seq.iterate(); i.hasNext(); ) { final Item item = i.nextItem(); - if (Type.subTypeOf(item.getType(), Type.FUNCTION)) - {throw new XPathException(this, ErrorCodes.FOTY0013, "Got a function item as operand in string concatenation");} + if (Type.subTypeOf(item.getType(), Type.FUNCTION)) { + // XQ4: maps are no longer function items for atomization purposes + if (item instanceof AbstractMapType && ((AbstractMapType) item).isXq4Atomizable()) { + final Sequence atomized = ((AbstractMapType) item).atomizeValues(); + for (final SequenceIterator ai = atomized.iterate(); ai.hasNext(); ) { + concat.append(ai.nextItem().getStringValue()); + } + continue; + } + throw new XPathException(this, ErrorCodes.FOTY0013, "Got a function item as operand in string concatenation"); + } concat.append(item.getStringValue()); } } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/map/AbstractMapType.java b/exist-core/src/main/java/org/exist/xquery/functions/map/AbstractMapType.java index 0ea9d160dbf..b571b60444a 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/map/AbstractMapType.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/map/AbstractMapType.java @@ -223,6 +223,61 @@ protected static boolean sameKey(@Nullable final Collator collator, final Atomic return false; } + /** + * Returns {@code true} if this map can be atomized under the current XQuery version. + * In XQuery 4.0, maps are no longer function items and can be atomized + * (returning the atomized values of their entries). + * + * @return true if the map is atomizable (XQuery 4.0+) + */ + public boolean isXq4Atomizable() { + return context != null && context.getXQueryVersion() >= 40; + } + + /** + * Atomize all values in this map, returning them as a flat sequence of atomic values. + * This implements the XQuery 4.0 semantics where atomizing a map returns the + * concatenation of the atomized values of its entries. + * + * @return a sequence of atomic values from all map entry values + * @throws XPathException if any value cannot be atomized + */ + public Sequence atomizeValues() throws XPathException { + if (size() == 0) { + return Sequence.EMPTY_SEQUENCE; + } + final ValueSequence result = new ValueSequence(); + for (final IEntry entry : this) { + final Sequence value = entry.value(); + for (final SequenceIterator vi = value.iterate(); vi.hasNext(); ) { + result.add(vi.nextItem().atomize()); + } + } + return result; + } + + @Override + public AtomicValue atomize() throws XPathException { + if (isXq4Atomizable()) { + if (size() == 0) { + // follows the ArrayType pattern for empty containers + return null; + } + final Sequence atomized = atomizeValues(); + if (atomized.isEmpty()) { + return null; + } + if (atomized.getItemCount() > 1) { + throw new XPathException(getExpression(), ErrorCodes.XPTY0004, + "Atomization of a map with multiple values requires a sequence context"); + } + return (AtomicValue) atomized.itemAt(0); + } + // XQuery 3.1 and earlier: maps are function items and cannot be atomized + throw new XPathException(getExpression(), ErrorCodes.FOTY0013, + "A function item other than an array cannot be atomized"); + } + @Override public String toString() { final StringBuilder buf = new StringBuilder("map {"); From b7c258cf94823c4e39fce4408d4fe0a713797c14 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 25 Apr 2026 17:35:49 -0400 Subject: [PATCH 2/2] [refactor] Address review feedback from @reinhapa Use Java 16+ pattern-matching instanceof to eliminate redundant casts in Atomize.java and ConcatExpr.java. Co-Authored-By: Claude Opus 4.6 (1M context) --- exist-core/src/main/java/org/exist/xquery/Atomize.java | 8 ++++---- exist-core/src/main/java/org/exist/xquery/ConcatExpr.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/Atomize.java b/exist-core/src/main/java/org/exist/xquery/Atomize.java index 7ff5ef58a37..d9e553c810b 100644 --- a/exist-core/src/main/java/org/exist/xquery/Atomize.java +++ b/exist-core/src/main/java/org/exist/xquery/Atomize.java @@ -79,8 +79,8 @@ public static Sequence atomize(Sequence input) throws XPathException { if (input.hasOne()) { final Item single = input.itemAt(0); // XQ4: maps are atomizable — expand to their values before atomizing - if (single instanceof AbstractMapType && ((AbstractMapType) single).isXq4Atomizable()) { - return ((AbstractMapType) single).atomizeValues(); + if (single instanceof AbstractMapType mapType && mapType.isXq4Atomizable()) { + return mapType.atomizeValues(); } return single.atomize(); } @@ -90,8 +90,8 @@ public static Sequence atomize(Sequence input) throws XPathException { for(final SequenceIterator i = input.iterate(); i.hasNext(); ) { next = i.nextItem(); // XQ4: maps are atomizable — expand to their values before atomizing - if (next instanceof AbstractMapType && ((AbstractMapType) next).isXq4Atomizable()) { - result.addAll(((AbstractMapType) next).atomizeValues()); + if (next instanceof AbstractMapType mapType && mapType.isXq4Atomizable()) { + result.addAll(mapType.atomizeValues()); } else { result.add(next.atomize()); } diff --git a/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java b/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java index 6e509fa150d..f8f9b9d3a3e 100644 --- a/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java +++ b/exist-core/src/main/java/org/exist/xquery/ConcatExpr.java @@ -68,8 +68,8 @@ public Sequence eval(Sequence contextSequence, Item contextItem) final Item item = i.nextItem(); if (Type.subTypeOf(item.getType(), Type.FUNCTION)) { // XQ4: maps are no longer function items for atomization purposes - if (item instanceof AbstractMapType && ((AbstractMapType) item).isXq4Atomizable()) { - final Sequence atomized = ((AbstractMapType) item).atomizeValues(); + if (item instanceof AbstractMapType mapType && mapType.isXq4Atomizable()) { + final Sequence atomized = mapType.atomizeValues(); for (final SequenceIterator ai = atomized.iterate(); ai.hasNext(); ) { concat.append(ai.nextItem().getStringValue()); }