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)); + } + } +}