Skip to content

Commit 5bdda68

Browse files
joewizclaude
andcommitted
[feature] Complete XQ4 record type support: record(*) rejection, key ordering, fn:dateTime-record
- Reject record(*) and record(..., *) extensible types at parse time (XPST0003) - Add RecordMapType with ordered keys() to preserve field declaration order - Implement fn:dateTime-record with 0-7 arity overloads returning typed DATETIME_RECORD - Register fn:dateTime-record as named record type resolvable in instance-of expressions - Non-extensible record coercion drops extra keys instead of rejecting Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 7e842e0 commit 5bdda68

8 files changed

Lines changed: 383 additions & 10 deletions

File tree

exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ imaginaryTokenDefinitions
192192
PREVIOUS_ITEM
193193
NEXT_ITEM
194194
WINDOW_VARS
195+
RECORD_TEST
196+
RECORD_FIELD
195197
;
196198
197199
// === XPointer ===
@@ -592,6 +594,8 @@ itemType throws XPathException
592594
|
593595
( "array" LPAREN ) => arrayType
594596
|
597+
( { LA(1) == NCNAME && LT(1).getText().equals("record") && LA(2) == LPAREN }? NCNAME LPAREN ) => recordType
598+
|
595599
( LPAREN ) => parenthesizedItemType
596600
|
597601
( . LPAREN ) => kindTest
@@ -686,6 +690,65 @@ arrayTypeTest throws XPathException
686690
}
687691
;
688692
693+
// === XQuery 4.0 Record Type ===
694+
695+
recordType throws XPathException
696+
:
697+
{ LA(1) == NCNAME && LT(1).getText().equals("record") && LA(2) == LPAREN && LA(3) == RPAREN }?
698+
emptyRecordTest
699+
|
700+
{ LA(1) == NCNAME && LT(1).getText().equals("record") && LA(2) == LPAREN && LA(3) == STAR }?
701+
extensibleRecordReject
702+
|
703+
typedRecordTest
704+
;
705+
706+
emptyRecordTest throws XPathException
707+
:
708+
r:NCNAME! LPAREN! RPAREN!
709+
{
710+
#emptyRecordTest = #(#[RECORD_TEST, "record"], #emptyRecordTest);
711+
}
712+
;
713+
714+
extensibleRecordReject throws XPathException
715+
:
716+
r:NCNAME! LPAREN! STAR RPAREN!
717+
{
718+
throw new XPathException(r.getLine(), r.getColumn(), ErrorCodes.XPST0003,
719+
"Extensible record types record(*) are not supported in XQuery 4.0");
720+
}
721+
;
722+
723+
typedRecordTest throws XPathException
724+
{ boolean extensible = false; }
725+
:
726+
r:NCNAME! LPAREN! recordFieldDecl (COMMA!
727+
( ( STAR ) => STAR { extensible = true; }
728+
| recordFieldDecl
729+
)
730+
)* RPAREN!
731+
{
732+
if (extensible) {
733+
throw new XPathException(r.getLine(), r.getColumn(), ErrorCodes.XPST0003,
734+
"Extensible record types record(..., *) are not supported in XQuery 4.0");
735+
}
736+
#typedRecordTest = #(#[RECORD_TEST, "record"], #typedRecordTest);
737+
}
738+
;
739+
740+
recordFieldDecl throws XPathException
741+
{ String fieldName = null; String fieldLabel = null; boolean isOptional = false; }
742+
:
743+
fieldName=fn:ncnameOrKeyword
744+
( QUESTION { isOptional = true; } )?
745+
( "as" sequenceType )?
746+
{
747+
fieldLabel = isOptional ? fieldName.concat("?") : fieldName;
748+
#recordFieldDecl = #(#[RECORD_FIELD, fieldLabel], #recordFieldDecl);
749+
}
750+
;
751+
689752
// === Expressions ===
690753
691754
queryBody throws XPathException: expr ;

exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ throws XPathException
10491049
try {
10501050
QName qn= QName.parse(staticContext, t.getText());
10511051
int code= Type.getType(qn);
1052-
if (!Type.subTypeOf(code, Type.ANY_ATOMIC_TYPE))
1052+
if (!Type.subTypeOf(code, Type.ANY_ATOMIC_TYPE) && !Type.subTypeOf(code, Type.RECORD))
10531053
throw new XPathException(t.getLine(), t.getColumn(), ErrorCodes.XPST0051, qn.toString() + " is not atomic");
10541054
type.setPrimaryType(code);
10551055
} catch (final XPathException e) {
@@ -1132,6 +1132,37 @@ throws XPathException
11321132
)
11331133
)
11341134
|
1135+
#(
1136+
RECORD_TEST
1137+
{
1138+
type.setPrimaryType(Type.RECORD);
1139+
List<RecordType.FieldDeclaration> recordFields = new ArrayList<RecordType.FieldDeclaration>();
1140+
}
1141+
(
1142+
#(
1143+
rf:RECORD_FIELD
1144+
{
1145+
String rfName = rf.getText();
1146+
boolean rfOptional = rfName.endsWith("?");
1147+
if (rfOptional) {
1148+
rfName = rfName.substring(0, rfName.length() - 1);
1149+
}
1150+
SequenceType rfType = null;
1151+
}
1152+
(
1153+
{ rfType = new SequenceType(); }
1154+
sequenceType [rfType]
1155+
)?
1156+
{
1157+
recordFields.add(new RecordType.FieldDeclaration(rfName, rfType, rfOptional));
1158+
}
1159+
)
1160+
)*
1161+
{
1162+
type.setRecordType(new RecordType(recordFields, false));
1163+
}
1164+
)
1165+
|
11351166
#(
11361167
"item" { type.setPrimaryType(Type.ITEM); }
11371168
)

exist-core/src/main/java/org/exist/xquery/RecordTypeCheck.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.exist.dom.persistent.DocumentSet;
2525
import org.exist.xquery.functions.map.AbstractMapType;
2626
import org.exist.xquery.functions.map.MapType;
27+
import org.exist.xquery.functions.map.RecordMapType;
2728
import org.exist.xquery.util.ExpressionDumper;
2829
import org.exist.xquery.value.*;
2930

@@ -96,8 +97,14 @@ private Sequence coerce(final Item item) throws XPathException {
9697
final AbstractMapType sourceMap = (AbstractMapType) item;
9798
final java.util.List<RecordType.FieldDeclaration> fields = recordType.getFieldDeclarations();
9899

99-
// Build a new map with only declared fields, in declaration order
100-
final MapType coercedMap = new MapType(expression, context);
100+
// Build field order list for RecordMapType
101+
final java.util.List<String> fieldOrder = new java.util.ArrayList<>(fields.size());
102+
for (final RecordType.FieldDeclaration f : fields) {
103+
fieldOrder.add(f.getName());
104+
}
105+
106+
// Build a new record map with only declared fields, in declaration order
107+
final RecordMapType coercedMap = new RecordMapType(expression, context, fieldOrder);
101108

102109
for (final RecordType.FieldDeclaration field : fields) {
103110
final StringValue key = new StringValue(expression, field.getName());
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* eXist-db Open Source Native XML Database
3+
* Copyright (C) 2001 The eXist-db Authors
4+
*
5+
6+
* http://www.exist-db.org
7+
*
8+
* This library is free software; you can redistribute it and/or
9+
* modify it under the terms of the GNU Lesser General Public
10+
* License as published by the Free Software Foundation; either
11+
* version 2.1 of the License, or (at your option) any later version.
12+
*
13+
* This library is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16+
* Lesser General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Lesser General Public
19+
* License along with this library; if not, write to the Free Software
20+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21+
*/
22+
package org.exist.xquery.functions.fn;
23+
24+
import org.exist.xquery.*;
25+
import org.exist.xquery.functions.map.RecordMapType;
26+
import org.exist.xquery.value.*;
27+
28+
import java.util.List;
29+
30+
import static org.exist.xquery.FunctionDSL.*;
31+
import static org.exist.xquery.functions.fn.FnModule.functionSignature;
32+
import static org.exist.xquery.functions.fn.FnModule.functionSignatures;
33+
34+
/**
35+
* Implementation of the XQuery 4.0 fn:dateTime-record named record constructor.
36+
*
37+
* <p>The function accepts 0 to 7 optional parameters (year, month, day,
38+
* hours, minutes, seconds, timezone) and returns a map of type
39+
* {@code fn:dateTime-record}. Only fields with non-empty values are
40+
* included in the result map.</p>
41+
*
42+
* @see <a href="https://qt4cg.org/specifications/xpath-functions-40/Overview.html#dateTime-record">
43+
* XPath and XQuery Functions and Operators 4.0: fn:dateTime-record</a>
44+
*/
45+
public class FnDateTimeRecord extends BasicFunction {
46+
47+
private static final String FS_NAME = "dateTime-record";
48+
49+
/** The field names in declaration order. */
50+
private static final List<String> FIELD_ORDER = List.of(
51+
"year", "month", "day", "hours", "minutes", "seconds", "timezone"
52+
);
53+
54+
private static final FunctionReturnSequenceType FS_RETURN_TYPE = returns(
55+
Type.DATETIME_RECORD,
56+
"A record of type fn:dateTime-record containing the specified date/time components.");
57+
58+
static final FunctionSignature[] FS_DATETIME_RECORD = functionSignatures(
59+
FS_NAME,
60+
"Constructs a dateTime-record from optional date/time component values.",
61+
FS_RETURN_TYPE,
62+
arities(
63+
arity(),
64+
arity(
65+
optParam("year", Type.INTEGER, "The year component")
66+
),
67+
arity(
68+
optParam("year", Type.INTEGER, "The year component"),
69+
optParam("month", Type.INTEGER, "The month component")
70+
),
71+
arity(
72+
optParam("year", Type.INTEGER, "The year component"),
73+
optParam("month", Type.INTEGER, "The month component"),
74+
optParam("day", Type.INTEGER, "The day component")
75+
),
76+
arity(
77+
optParam("year", Type.INTEGER, "The year component"),
78+
optParam("month", Type.INTEGER, "The month component"),
79+
optParam("day", Type.INTEGER, "The day component"),
80+
optParam("hours", Type.INTEGER, "The hours component")
81+
),
82+
arity(
83+
optParam("year", Type.INTEGER, "The year component"),
84+
optParam("month", Type.INTEGER, "The month component"),
85+
optParam("day", Type.INTEGER, "The day component"),
86+
optParam("hours", Type.INTEGER, "The hours component"),
87+
optParam("minutes", Type.INTEGER, "The minutes component")
88+
),
89+
arity(
90+
optParam("year", Type.INTEGER, "The year component"),
91+
optParam("month", Type.INTEGER, "The month component"),
92+
optParam("day", Type.INTEGER, "The day component"),
93+
optParam("hours", Type.INTEGER, "The hours component"),
94+
optParam("minutes", Type.INTEGER, "The minutes component"),
95+
optParam("seconds", Type.DECIMAL, "The seconds component")
96+
),
97+
arity(
98+
optParam("year", Type.INTEGER, "The year component"),
99+
optParam("month", Type.INTEGER, "The month component"),
100+
optParam("day", Type.INTEGER, "The day component"),
101+
optParam("hours", Type.INTEGER, "The hours component"),
102+
optParam("minutes", Type.INTEGER, "The minutes component"),
103+
optParam("seconds", Type.DECIMAL, "The seconds component"),
104+
optParam("timezone", Type.DAY_TIME_DURATION, "The timezone as xs:dayTimeDuration")
105+
)
106+
)
107+
);
108+
109+
public FnDateTimeRecord(final XQueryContext context, final FunctionSignature signature) {
110+
super(context, signature);
111+
}
112+
113+
@Override
114+
public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
115+
final RecordMapType result = new RecordMapType(this, context, FIELD_ORDER, Type.DATETIME_RECORD);
116+
117+
for (int i = 0; i < args.length && i < FIELD_ORDER.size(); i++) {
118+
final Sequence arg = args[i];
119+
if (arg != null && !arg.isEmpty()) {
120+
final String fieldName = FIELD_ORDER.get(i);
121+
if ("timezone".equals(fieldName)) {
122+
result.add(new StringValue(this, fieldName), coerceTimezone(arg));
123+
} else {
124+
result.add(new StringValue(this, fieldName), arg);
125+
}
126+
}
127+
}
128+
129+
return result;
130+
}
131+
132+
/**
133+
* Coerce the timezone argument to xs:dayTimeDuration.
134+
* Accepts xs:dayTimeDuration directly, or xs:duration (coerced).
135+
* Rejects xs:string with XPTY0004.
136+
*/
137+
private Sequence coerceTimezone(final Sequence value) throws XPathException {
138+
final Item item = value.itemAt(0);
139+
if (item instanceof DurationValue dv) {
140+
if (dv.getType() == Type.DAY_TIME_DURATION) {
141+
return value;
142+
}
143+
// Coerce xs:duration or xs:yearMonthDuration to xs:dayTimeDuration
144+
return dv.convertTo(Type.DAY_TIME_DURATION);
145+
}
146+
throw new XPathException(this, ErrorCodes.XPTY0004,
147+
"Expected xs:dayTimeDuration for timezone parameter, got " +
148+
Type.getTypeName(item.getType()));
149+
}
150+
}

exist-core/src/main/java/org/exist/xquery/functions/fn/FnModule.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ public class FnModule extends AbstractInternalModule {
6666
new FunctionDef(FunData.signatures[0], FunData.class),
6767
new FunctionDef(FunData.signatures[1], FunData.class),
6868
new FunctionDef(FunDateTime.signature, FunDateTime.class),
69+
// --- XQuery 4.0: fn:dateTime-record ---
70+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[0], FnDateTimeRecord.class),
71+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[1], FnDateTimeRecord.class),
72+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[2], FnDateTimeRecord.class),
73+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[3], FnDateTimeRecord.class),
74+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[4], FnDateTimeRecord.class),
75+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[5], FnDateTimeRecord.class),
76+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[6], FnDateTimeRecord.class),
77+
new FunctionDef(FnDateTimeRecord.FS_DATETIME_RECORD[7], FnDateTimeRecord.class),
78+
// --- End XQuery 4.0: fn:dateTime-record ---
6979
new FunctionDef(FunDeepEqual.signatures[0], FunDeepEqual.class),
7080
new FunctionDef(FunDeepEqual.signatures[1], FunDeepEqual.class),
7181
new FunctionDef(FunDefaultCollation.signature, FunDefaultCollation.class),

0 commit comments

Comments
 (0)