diff --git a/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java b/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java
index 1f32cbca2a8..fba989a6d36 100644
--- a/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java
+++ b/exist-core/src/main/java/org/exist/xquery/DynamicTypeCheck.java
@@ -82,6 +82,10 @@ private void check(Sequence result, Item item) throws XPathException {
//Retrieve the actual node
{type= ((NodeProxy) item).getNode().getNodeType();}
}
+ // XQuery 4.0: record type checking — a map can match a record type
+ if (requiredType == Type.RECORD && Type.subTypeOf(type, Type.MAP_ITEM)) {
+ return; // record type checking handled by SequenceType.checkType
+ }
if(type != requiredType && !Type.subTypeOf(type, requiredType)) {
//TODO : how to make this block more generic ? -pb
if (type == Type.UNTYPED_ATOMIC) {
diff --git a/exist-core/src/main/java/org/exist/xquery/FieldAccessor.java b/exist-core/src/main/java/org/exist/xquery/FieldAccessor.java
new file mode 100644
index 00000000000..1ce377912bd
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/FieldAccessor.java
@@ -0,0 +1,108 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery;
+
+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;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+import java.util.Objects;
+
+/**
+ * XQuery 4.0 field accessor expression: {@code $expr.fieldName}.
+ *
+ *
Syntactic sugar for {@code map:get($expr, "fieldName")}. The parser-next
+ * branch will wire the {@code .NCName} postfix syntax to this class. Until then,
+ * it can be used programmatically or via {@code fn:get} on record-typed values.
+ *
+ * If the base expression has a declared record type, the field name is
+ * validated against the record's field declarations at analysis time.
+ */
+public class FieldAccessor extends AbstractExpression {
+
+ private final Expression baseExpr;
+ private final String fieldName;
+
+ public FieldAccessor(final XQueryContext context, final Expression baseExpr, final String fieldName) {
+ super(context);
+ this.baseExpr = baseExpr;
+ this.fieldName = fieldName;
+ }
+
+ public Expression getBaseExpression() {
+ return baseExpr;
+ }
+
+ public String getFieldName() {
+ return fieldName;
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ contextInfo.setParent(this);
+ baseExpr.analyze(contextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence baseResult = baseExpr.eval(contextSequence, contextItem);
+
+ if (baseResult.isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final Item item = baseResult.itemAt(0);
+ if (!Type.subTypeOf(item.getType(), Type.MAP_ITEM)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Field accessor ." + fieldName + " requires a map, got " + Type.getTypeName(item.getType()));
+ }
+
+ final AbstractMapType map = (AbstractMapType) item;
+ final Sequence value = map.get(new StringValue(this, fieldName));
+ return Objects.requireNonNullElse(value, Sequence.EMPTY_SEQUENCE);
+ }
+
+ @Override
+ public int returnsType() {
+ return Type.ITEM;
+ }
+
+ @Override
+ public int getDependencies() {
+ return baseExpr.getDependencies();
+ }
+
+ @Override
+ public void dump(final ExpressionDumper dumper) {
+ baseExpr.dump(dumper);
+ dumper.display(".");
+ dumper.display(fieldName);
+ }
+
+ @Override
+ public String toString() {
+ return baseExpr.toString() + "." + fieldName;
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/Function.java b/exist-core/src/main/java/org/exist/xquery/Function.java
index 161cba2957b..302175ef581 100644
--- a/exist-core/src/main/java/org/exist/xquery/Function.java
+++ b/exist-core/src/main/java/org/exist/xquery/Function.java
@@ -286,6 +286,11 @@ private Expression checkArgumentType(
return new FunctionTypeCheck(context, functionParameterType, argument);
}
+ // XQuery 4.0: wrap with record type check if parameter declares a record type
+ if (argType.isRecordType() && argType.getRecordType() != null) {
+ return new RecordTypeCheck(context, argType.getRecordType(), argument);
+ }
+
return argument;
}
@@ -323,6 +328,11 @@ private Expression checkArgumentType(
return new FunctionTypeCheck(context, functionParameterType, argument);
}
+ // XQuery 4.0: wrap with record type check if parameter declares a record type
+ if (argType.isRecordType() && argType.getRecordType() != null) {
+ return new RecordTypeCheck(context, argType.getRecordType(), argument);
+ }
+
return new DynamicTypeCheck(context, argType.getPrimaryType(), argument);
}
diff --git a/exist-core/src/main/java/org/exist/xquery/RecordTypeCheck.java b/exist-core/src/main/java/org/exist/xquery/RecordTypeCheck.java
new file mode 100644
index 00000000000..220208b496c
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/RecordTypeCheck.java
@@ -0,0 +1,180 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery;
+
+import org.exist.dom.persistent.DocumentSet;
+import org.exist.xquery.functions.map.AbstractMapType;
+import org.exist.xquery.util.ExpressionDumper;
+import org.exist.xquery.value.*;
+
+/**
+ * Runtime check that a function argument matches a declared record type.
+ *
+ * When a function parameter is declared as {@code $param as record(name as xs:string, ...)},
+ * the argument expression is wrapped in a {@code RecordTypeCheck}. At runtime,
+ * the check verifies the argument is a map that matches all required fields and
+ * field types declared in the {@link RecordType}.
+ *
+ * Modeled on {@link DynamicTypeCheck} and {@link FunctionTypeCheck}.
+ */
+public class RecordTypeCheck extends AbstractExpression {
+
+ private final Expression expression;
+ private final RecordType recordType;
+
+ public RecordTypeCheck(final XQueryContext context, final RecordType recordType, final Expression expr) {
+ super(context);
+ this.recordType = recordType;
+ this.expression = expr;
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) throws XPathException {
+ contextInfo.setParent(this);
+ expression.analyze(contextInfo);
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) throws XPathException {
+ final Sequence seq = expression.eval(contextSequence, contextItem);
+
+ if (seq.isEmpty()) {
+ return seq;
+ }
+
+ for (final SequenceIterator i = seq.iterate(); i.hasNext(); ) {
+ final Item item = i.nextItem();
+ check(item);
+ }
+
+ return seq;
+ }
+
+ private void check(final Item item) throws XPathException {
+ if (!Type.subTypeOf(item.getType(), Type.MAP_ITEM)) {
+ throw new XPathException(expression, ErrorCodes.XPTY0004,
+ "Expected " + recordType + " but got " + Type.getTypeName(item.getType()));
+ }
+
+ final AbstractMapType map = (AbstractMapType) item;
+ if (!recordType.matches(map)) {
+ throw new XPathException(expression, ErrorCodes.XPTY0004,
+ "Map does not match " + recordType + ": " + describeFailure(map));
+ }
+ }
+
+ private String describeFailure(final AbstractMapType map) {
+ final StringBuilder sb = new StringBuilder();
+ for (final RecordType.FieldDeclaration field : recordType.getFieldDeclarations()) {
+ final Sequence value = map.get(new StringValue(field.getName()));
+ if ((value == null || value.isEmpty()) && !field.isOptional()) {
+ if (sb.length() > 0) sb.append("; ");
+ sb.append("missing required field '").append(field.getName()).append("'");
+ } else if (value != null && !value.isEmpty() && field.getType() != null) {
+ try {
+ if (!field.getType().checkType(value)) {
+ if (sb.length() > 0) sb.append("; ");
+ sb.append("field '").append(field.getName()).append("' has type ")
+ .append(Type.getTypeName(value.getItemType()))
+ .append(", expected ").append(field.getType());
+ }
+ } catch (final XPathException e) {
+ if (sb.length() > 0) sb.append("; ");
+ sb.append("field '").append(field.getName()).append("' type check error: ").append(e.getMessage());
+ }
+ }
+ }
+ if (sb.length() == 0) {
+ // Extensibility check failure
+ sb.append("map contains unexpected keys not declared in ").append(recordType);
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public int returnsType() {
+ return Type.MAP_ITEM;
+ }
+
+ @Override
+ public int getDependencies() {
+ return expression.getDependencies();
+ }
+
+ @Override
+ public void dump(final ExpressionDumper dumper) {
+ if (dumper.verbosity() > 1) {
+ dumper.display("record-type-check[");
+ dumper.display(recordType.toString());
+ dumper.display(", ");
+ }
+ expression.dump(dumper);
+ if (dumper.verbosity() > 1) {
+ dumper.display("]");
+ }
+ }
+
+ @Override
+ public String toString() {
+ return expression.toString();
+ }
+
+ @Override
+ public void resetState(final boolean postOptimization) {
+ super.resetState(postOptimization);
+ expression.resetState(postOptimization);
+ }
+
+ @Override
+ public void setContextDocSet(final DocumentSet contextSet) {
+ super.setContextDocSet(contextSet);
+ expression.setContextDocSet(contextSet);
+ }
+
+ @Override
+ public int getLine() {
+ return expression.getLine();
+ }
+
+ @Override
+ public int getColumn() {
+ return expression.getColumn();
+ }
+
+ @Override
+ public void accept(final ExpressionVisitor visitor) {
+ expression.accept(visitor);
+ }
+
+ @Override
+ public int getSubExpressionCount() {
+ return 1;
+ }
+
+ @Override
+ public Expression getSubExpression(final int index) {
+ if (index == 0) {
+ return expression;
+ }
+ throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + getSubExpressionCount());
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/UserDefinedFunction.java b/exist-core/src/main/java/org/exist/xquery/UserDefinedFunction.java
index a56db1a200b..50e2c6e45cb 100644
--- a/exist-core/src/main/java/org/exist/xquery/UserDefinedFunction.java
+++ b/exist-core/src/main/java/org/exist/xquery/UserDefinedFunction.java
@@ -26,6 +26,10 @@
import org.exist.xquery.util.ExpressionDumper;
import org.exist.xquery.value.Item;
import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceIterator;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.functions.map.AbstractMapType;
import java.util.ArrayList;
import java.util.List;
@@ -127,6 +131,7 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc
try {
QName varName;
LocalVariable var;
+ final SequenceType[] argumentTypes = getSignature().getArgumentTypes();
int j = 0;
for (int i = 0; i < parameters.size(); i++, j++) {
varName = parameters.get(i);
@@ -146,11 +151,29 @@ public Sequence eval(Sequence contextSequence, Item contextItem) throws XPathExc
actualCardinality = Cardinality.EXACTLY_ONE;
}
- if (!getSignature().getArgumentTypes()[j].getCardinality().isSuperCardinalityOrEqualOf(actualCardinality)) {
+ if (!argumentTypes[j].getCardinality().isSuperCardinalityOrEqualOf(actualCardinality)) {
throw new XPathException(this, ErrorCodes.XPTY0004, "Invalid cardinality for parameter $" + varName +
- ". Expected " + getSignature().getArgumentTypes()[j].getCardinality().getHumanDescription() +
+ ". Expected " + argumentTypes[j].getCardinality().getHumanDescription() +
", got " + currentArguments[j].getItemCount());
}
+
+ // XQuery 4.0: record type validation at runtime
+ final SequenceType argType = argumentTypes[j];
+ if (argType.isRecordType() && argType.getRecordType() != null && !currentArguments[j].isEmpty()) {
+ for (final SequenceIterator iter = currentArguments[j].iterate(); iter.hasNext(); ) {
+ final Item item = iter.nextItem();
+ if (Type.subTypeOf(item.getType(), Type.MAP_ITEM)) {
+ if (!argType.getRecordType().matches((AbstractMapType) item)) {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Argument $" + varName + " does not match " + argType.getRecordType());
+ }
+ } else {
+ throw new XPathException(this, ErrorCodes.XPTY0004,
+ "Argument $" + varName + " expected " + argType.getRecordType() +
+ " but got " + Type.getTypeName(item.getType()));
+ }
+ }
+ }
}
result = body.eval(null, null);
return result;
diff --git a/exist-core/src/main/java/org/exist/xquery/value/RecordType.java b/exist-core/src/main/java/org/exist/xquery/value/RecordType.java
new file mode 100644
index 00000000000..7469d65d45c
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/value/RecordType.java
@@ -0,0 +1,162 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.value;
+
+import javax.annotation.Nullable;
+import java.util.*;
+
+/**
+ * Represents an XQuery 4.0 record type: {@code record(name as xs:string, age as xs:integer)}.
+ *
+ * A record type is a typed map descriptor. It specifies which keys a map
+ * must have, what types the values must be, and whether additional keys
+ * are allowed (extensible records).
+ *
+ * Design note: this class is aligned with the Parser branch's
+ * SequenceType infrastructure (isRecordType, getFieldDeclarations,
+ * isExtensible) so the branches merge cleanly.
+ */
+public class RecordType {
+
+ private final List fields;
+ private final boolean extensible;
+
+ /**
+ * A single field declaration in a record type.
+ */
+ public static class FieldDeclaration {
+ private final String name;
+ private final SequenceType type;
+ private final boolean optional;
+
+ public FieldDeclaration(final String name, @Nullable final SequenceType type, final boolean optional) {
+ this.name = name;
+ this.type = type;
+ this.optional = optional;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ @Nullable
+ public SequenceType getType() {
+ return type;
+ }
+
+ public boolean isOptional() {
+ return optional;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder(name);
+ if (optional) {
+ sb.append('?');
+ }
+ if (type != null) {
+ sb.append(" as ").append(type);
+ }
+ return sb.toString();
+ }
+ }
+
+ public RecordType(final List fields, final boolean extensible) {
+ this.fields = Collections.unmodifiableList(fields);
+ this.extensible = extensible;
+ }
+
+ public List getFieldDeclarations() {
+ return fields;
+ }
+
+ public boolean isExtensible() {
+ return extensible;
+ }
+
+ /**
+ * Check whether the given map matches this record type.
+ *
+ * @param map the map to check
+ * @return true if the map matches all field declarations
+ */
+ public boolean matches(final org.exist.xquery.functions.map.AbstractMapType map) {
+ // Check all required fields exist and have matching types
+ for (final FieldDeclaration field : fields) {
+ final Sequence value = map.get(new StringValue(field.getName()));
+ if (value == null || value.isEmpty()) {
+ if (!field.isOptional()) {
+ return false; // required field missing
+ }
+ continue;
+ }
+ // Check value type if declared
+ if (field.getType() != null) {
+ try {
+ if (!field.getType().checkType(value)) {
+ return false;
+ }
+ } catch (final org.exist.xquery.XPathException e) {
+ return false;
+ }
+ }
+ }
+
+ // If not extensible, check no extra keys
+ if (!extensible) {
+ final Set declaredNames = new HashSet<>();
+ for (final FieldDeclaration field : fields) {
+ declaredNames.add(field.getName());
+ }
+ for (final io.lacuna.bifurcan.IEntry entry : map) {
+ try {
+ if (!declaredNames.contains(entry.key().getStringValue())) {
+ return false; // extra key not allowed
+ }
+ } catch (final org.exist.xquery.XPathException e) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("record(");
+ for (int i = 0; i < fields.size(); i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append(fields.get(i));
+ }
+ if (extensible) {
+ if (!fields.isEmpty()) {
+ sb.append(", ");
+ }
+ sb.append('*');
+ }
+ sb.append(')');
+ return sb.toString();
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/value/SequenceType.java b/exist-core/src/main/java/org/exist/xquery/value/SequenceType.java
index f00c9811ea1..c1fee371f0d 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/SequenceType.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/SequenceType.java
@@ -21,6 +21,7 @@
*/
package org.exist.xquery.value;
+import javax.annotation.Nullable;
import org.exist.dom.QName;
import org.exist.xquery.Cardinality;
import org.exist.xquery.ErrorCodes;
@@ -42,6 +43,9 @@ public class SequenceType {
private Cardinality cardinality = Cardinality.EXACTLY_ONE;
private QName nodeName = null;
+ // XQuery 4.0 record type support
+ private RecordType recordType = null;
+
public SequenceType() {
}
@@ -108,6 +112,46 @@ public void setNodeName(QName qname) {
this.nodeName = qname;
}
+ // --- XQuery 4.0 Record Type Support ---
+
+ /**
+ * Check if this SequenceType is a record type.
+ */
+ public boolean isRecordType() {
+ return primaryType == Type.RECORD && recordType != null;
+ }
+
+ /**
+ * Get the record type's field declarations.
+ */
+ @Nullable
+ public java.util.List getFieldDeclarations() {
+ return recordType != null ? recordType.getFieldDeclarations() : null;
+ }
+
+ /**
+ * Check if the record type is extensible (allows extra keys).
+ */
+ public boolean isRecordExtensible() {
+ return recordType != null && recordType.isExtensible();
+ }
+
+ /**
+ * Set the record type definition.
+ */
+ public void setRecordType(final RecordType recordType) {
+ this.primaryType = Type.RECORD;
+ this.recordType = recordType;
+ }
+
+ /**
+ * Get the record type, or null if this isn't a record type.
+ */
+ @Nullable
+ public RecordType getRecordType() {
+ return recordType;
+ }
+
/**
* Check the specified sequence against this SequenceType.
*
@@ -135,6 +179,16 @@ public boolean checkType(final Sequence seq) throws XPathException {
* @return true, if item is a subtype of primaryType
*/
public boolean checkType(final Item item) {
+ // XQuery 4.0 record type checking
+ if (isRecordType()) {
+ if (!Type.subTypeOf(item.getType(), Type.MAP_ITEM)) {
+ return false;
+ }
+ if (item instanceof org.exist.xquery.functions.map.AbstractMapType) {
+ return recordType.matches((org.exist.xquery.functions.map.AbstractMapType) item);
+ }
+ return false;
+ }
int type = item.getType();
if (type == Type.NODE) {
final Node realNode = ((NodeValue) item).getNode();
diff --git a/exist-core/src/main/java/org/exist/xquery/value/Type.java b/exist-core/src/main/java/org/exist/xquery/value/Type.java
index f60c60d7255..df8495fc1d9 100644
--- a/exist-core/src/main/java/org/exist/xquery/value/Type.java
+++ b/exist-core/src/main/java/org/exist/xquery/value/Type.java
@@ -133,7 +133,10 @@ public class Type {
public final static int JAVA_OBJECT = 68;
public final static int EMPTY_SEQUENCE = 69; // NOTE(AR) this types does appear in the XQ 3.1 spec - https://www.w3.org/TR/xquery-31/#id-sequencetype-syntax
- private final static int[] superTypes = new int[69];
+ // XQuery 4.0 record type — a subtype of map(*)
+ public final static int RECORD = 70;
+
+ private final static int[] superTypes = new int[71];
private final static Int2ObjectOpenHashMap typeNames = new Int2ObjectOpenHashMap<>(69, Hash.FAST_LOAD_FACTOR);
private final static Object2IntOpenHashMap typeCodes = new Object2IntOpenHashMap<>(78, Hash.FAST_LOAD_FACTOR);
static {
@@ -247,6 +250,8 @@ public class Type {
// FUNCTION_REFERENCE sub-types
defineSubType(FUNCTION, MAP_ITEM);
+ // XQ4: RECORD is a subtype of MAP
+ defineSubType(MAP_ITEM, RECORD);
defineSubType(FUNCTION, ARRAY_ITEM);
// NODE types
@@ -327,6 +332,7 @@ public class Type {
defineBuiltInType(FUNCTION, "function(*)", "function");
defineBuiltInType(ARRAY_ITEM, "array(*)", "array");
defineBuiltInType(MAP_ITEM, "map(*)", "map"); // keep `map` for backward compatibility
+ defineBuiltInType(RECORD, "record(*)", "record");
defineBuiltInType(CDATA_SECTION, "cdata-section()");
defineBuiltInType(JAVA_OBJECT, "object");
defineBuiltInType(EMPTY_SEQUENCE, "empty-sequence()", "empty()"); // keep `empty()` for backward compatibility
diff --git a/exist-core/src/test/java/org/exist/xquery/value/RecordTypeTest.java b/exist-core/src/test/java/org/exist/xquery/value/RecordTypeTest.java
new file mode 100644
index 00000000000..e039540d869
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/value/RecordTypeTest.java
@@ -0,0 +1,383 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.value;
+
+import org.exist.EXistException;
+import org.exist.storage.BrokerPool;
+import org.exist.storage.DBBroker;
+import org.exist.test.ExistEmbeddedServer;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.util.ExpressionDumper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for XQuery 4.0 record type system.
+ */
+public class RecordTypeTest {
+
+ private static ExistEmbeddedServer existEmbeddedServer;
+
+ @BeforeAll
+ static void startDb() throws Exception {
+ existEmbeddedServer = new ExistEmbeddedServer(true, true);
+ existEmbeddedServer.startDb();
+ }
+
+ @AfterAll
+ static void stopDb() {
+ if (existEmbeddedServer != null) {
+ existEmbeddedServer.stopDb();
+ }
+ }
+
+ @Test
+ void testTypeHierarchy() {
+ assertTrue(Type.subTypeOf(Type.RECORD, Type.MAP_ITEM));
+ assertTrue(Type.subTypeOf(Type.RECORD, Type.FUNCTION));
+ assertTrue(Type.subTypeOf(Type.RECORD, Type.ITEM));
+ assertFalse(Type.subTypeOf(Type.MAP_ITEM, Type.RECORD));
+ }
+
+ @Test
+ void testTypeName() {
+ assertEquals("record(*)", Type.getTypeName(Type.RECORD));
+ }
+
+ @Test
+ void testFieldDeclaration() {
+ final RecordType.FieldDeclaration field = new RecordType.FieldDeclaration(
+ "name", new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false);
+ assertEquals("name", field.getName());
+ assertFalse(field.isOptional());
+ assertNotNull(field.getType());
+ }
+
+ @Test
+ void testOptionalField() {
+ final RecordType.FieldDeclaration field = new RecordType.FieldDeclaration(
+ "age", new SequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE), true);
+ assertTrue(field.isOptional());
+ }
+
+ @Test
+ void testRecordTypeToString() {
+ final List fields = Arrays.asList(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false),
+ new RecordType.FieldDeclaration("age",
+ new SequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE), true)
+ );
+ final RecordType rt = new RecordType(fields, false);
+ final String str = rt.toString();
+ assertTrue(str.startsWith("record("));
+ assertTrue(str.contains("name"));
+ assertTrue(str.contains("age?"));
+ assertTrue(str.endsWith(")"));
+ }
+
+ @Test
+ void testExtensibleRecordType() {
+ final RecordType rt = new RecordType(
+ List.of(new RecordType.FieldDeclaration("x", null, false)),
+ true);
+ assertTrue(rt.isExtensible());
+ assertTrue(rt.toString().contains("*"));
+ }
+
+ @Test
+ void testSequenceTypeRecordAPI() {
+ final SequenceType st = new SequenceType();
+ assertFalse(st.isRecordType());
+
+ final List fields = List.of(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false)
+ );
+ st.setRecordType(new RecordType(fields, false));
+ assertTrue(st.isRecordType());
+ assertEquals(Type.RECORD, st.getPrimaryType());
+ assertNotNull(st.getFieldDeclarations());
+ assertEquals(1, st.getFieldDeclarations().size());
+ assertFalse(st.isRecordExtensible());
+ }
+
+ /** Simple expression wrapper for testing — returns a fixed Sequence. */
+ private static class ConstantExpr extends AbstractExpression {
+ private final Sequence value;
+
+ ConstantExpr(final XQueryContext context, final Sequence value) {
+ super(context);
+ this.value = value;
+ }
+
+ @Override
+ public Sequence eval(final Sequence contextSequence, final Item contextItem) {
+ return value;
+ }
+
+ @Override
+ public int returnsType() {
+ return value.isEmpty() ? Type.EMPTY_SEQUENCE : value.getItemType();
+ }
+
+ @Override
+ public void analyze(final AnalyzeContextInfo contextInfo) {}
+
+ @Override
+ public void dump(final ExpressionDumper dumper) {
+ dumper.display(value.toString());
+ }
+ }
+
+ // === Phase 3: FieldAccessor tests ===
+
+ @Test
+ void testFieldAccessorEval() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ // Build a map: map { "name": "Alice", "age": 30 }
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+ map.add(new StringValue("age"), new IntegerValue(30));
+
+ // Create a FieldAccessor for ".name"
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final FieldAccessor accessor = new FieldAccessor(context, baseExpr, "name");
+ accessor.analyze(new AnalyzeContextInfo());
+
+ final Sequence result = accessor.eval(Sequence.EMPTY_SEQUENCE, null);
+ assertFalse(result.isEmpty());
+ assertEquals("Alice", result.getStringValue());
+ }
+ }
+
+ @Test
+ void testFieldAccessorMissingField() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final FieldAccessor accessor = new FieldAccessor(context, baseExpr, "missing");
+ accessor.analyze(new AnalyzeContextInfo());
+
+ final Sequence result = accessor.eval(Sequence.EMPTY_SEQUENCE, null);
+ assertTrue(result.isEmpty());
+ }
+ }
+
+ @Test
+ void testFieldAccessorNonMap() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ final Expression baseExpr = new ConstantExpr(context, new StringValue("not a map"));
+ final FieldAccessor accessor = new FieldAccessor(context, baseExpr, "name");
+ accessor.analyze(new AnalyzeContextInfo());
+
+ assertThrows(XPathException.class, () ->
+ accessor.eval(Sequence.EMPTY_SEQUENCE, null));
+ }
+ }
+
+ @Test
+ void testFieldAccessorEmptySequence() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ final Expression baseExpr = new ConstantExpr(context, Sequence.EMPTY_SEQUENCE);
+ final FieldAccessor accessor = new FieldAccessor(context, baseExpr, "name");
+ accessor.analyze(new AnalyzeContextInfo());
+
+ final Sequence result = accessor.eval(Sequence.EMPTY_SEQUENCE, null);
+ assertTrue(result.isEmpty());
+ }
+ }
+
+ // === Phase 4: RecordTypeCheck tests ===
+
+ @Test
+ void testRecordTypeCheckPass() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ // Build record type: record(name as xs:string, age as xs:integer)
+ final RecordType rt = new RecordType(List.of(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false),
+ new RecordType.FieldDeclaration("age",
+ new SequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE), false)
+ ), false);
+
+ // Build a matching map
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+ map.add(new StringValue("age"), new IntegerValue(30));
+
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final RecordTypeCheck check = new RecordTypeCheck(context, rt, baseExpr);
+ check.analyze(new AnalyzeContextInfo());
+
+ final Sequence result = check.eval(Sequence.EMPTY_SEQUENCE, null);
+ assertFalse(result.isEmpty());
+ }
+ }
+
+ @Test
+ void testRecordTypeCheckFailMissingField() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ final RecordType rt = new RecordType(List.of(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false),
+ new RecordType.FieldDeclaration("age",
+ new SequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE), false)
+ ), false);
+
+ // Map missing 'age'
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final RecordTypeCheck check = new RecordTypeCheck(context, rt, baseExpr);
+ check.analyze(new AnalyzeContextInfo());
+
+ final XPathException ex = assertThrows(XPathException.class, () ->
+ check.eval(Sequence.EMPTY_SEQUENCE, null));
+ assertTrue(ex.getMessage().contains("missing required field"));
+ }
+ }
+
+ @Test
+ void testRecordTypeCheckOptionalFieldOK() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ // age? is optional
+ final RecordType rt = new RecordType(List.of(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false),
+ new RecordType.FieldDeclaration("age",
+ new SequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE), true)
+ ), false);
+
+ // Map without 'age' — should pass since it's optional
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final RecordTypeCheck check = new RecordTypeCheck(context, rt, baseExpr);
+ check.analyze(new AnalyzeContextInfo());
+
+ final Sequence result = check.eval(Sequence.EMPTY_SEQUENCE, null);
+ assertFalse(result.isEmpty());
+ }
+ }
+
+ @Test
+ void testRecordTypeCheckNonMapFails() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ final RecordType rt = new RecordType(List.of(
+ new RecordType.FieldDeclaration("x", null, false)
+ ), false);
+
+ final Expression baseExpr = new ConstantExpr(context, new StringValue("not a map"));
+ final RecordTypeCheck check = new RecordTypeCheck(context, rt, baseExpr);
+ check.analyze(new AnalyzeContextInfo());
+
+ assertThrows(XPathException.class, () ->
+ check.eval(Sequence.EMPTY_SEQUENCE, null));
+ }
+ }
+
+ @Test
+ void testRecordTypeCheckExtensibleAllowsExtraKeys() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ // record(name as xs:string, *)
+ final RecordType rt = new RecordType(List.of(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false)
+ ), true);
+
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+ map.add(new StringValue("extra"), new IntegerValue(42));
+
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final RecordTypeCheck check = new RecordTypeCheck(context, rt, baseExpr);
+ check.analyze(new AnalyzeContextInfo());
+
+ final Sequence result = check.eval(Sequence.EMPTY_SEQUENCE, null);
+ assertFalse(result.isEmpty());
+ }
+ }
+
+ @Test
+ void testRecordTypeCheckNonExtensibleRejectsExtraKeys() throws EXistException, XPathException {
+ final BrokerPool pool = existEmbeddedServer.getBrokerPool();
+ try (final DBBroker broker = pool.get(java.util.Optional.of(pool.getSecurityManager().getSystemSubject()))) {
+ final XQueryContext context = new XQueryContext(pool);
+
+ // record(name as xs:string) — NOT extensible
+ final RecordType rt = new RecordType(List.of(
+ new RecordType.FieldDeclaration("name",
+ new SequenceType(Type.STRING, Cardinality.EXACTLY_ONE), false)
+ ), false);
+
+ final MapType map = new MapType(null, context);
+ map.add(new StringValue("name"), new StringValue("Alice"));
+ map.add(new StringValue("extra"), new IntegerValue(42));
+
+ final Expression baseExpr = new ConstantExpr(context, map);
+ final RecordTypeCheck check = new RecordTypeCheck(context, rt, baseExpr);
+ check.analyze(new AnalyzeContextInfo());
+
+ assertThrows(XPathException.class, () ->
+ check.eval(Sequence.EMPTY_SEQUENCE, null));
+ }
+ }
+}