diff --git a/.gitignore b/.gitignore index 2f96c7e51c2..8ba285c898a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,4 @@ work/ # Claude planning files plans/ -.codacy/ - -# Debug logs (e.g. from reindex investigation) -reindex-dbg.log +.xqts-runner/ diff --git a/exist-ant/src/test/resources-filtered/conf.xml b/exist-ant/src/test/resources-filtered/conf.xml index 52cac5dde3f..cd3d1cd2aa5 100644 --- a/exist-ant/src/test/resources-filtered/conf.xml +++ b/exist-ant/src/test/resources-filtered/conf.xml @@ -753,6 +753,8 @@ + + diff --git a/exist-core/pom.xml b/exist-core/pom.xml index c1b7f369c72..15be3d187d3 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -169,13 +169,13 @@ org.bouncycastle bcprov-jdk18on - 1.84 + 1.83 at.yawk.lz4 lz4-java - 1.11.0 + 1.10.4 @@ -324,6 +324,12 @@ + + nu.validator + htmlparser + 1.4.16 + + org.apache.ws.commons.util ws-commons-util @@ -392,16 +398,8 @@ - org.exist-db - exist-saxon-regex - 9.4.0-9.e1 - - - - net.sf.saxon - Saxon-HE - - + de.bottlecaps + markup-blitz @@ -431,37 +429,16 @@ ${aspectj.version} - - org.eclipse.jetty - jetty-jaas - ${jetty.version} - runtime - - - - org.apache.servicemix.bundles - org.apache.servicemix.bundles.antlr - - - - - - - org.apache.mina - mina-core - 2.2.5 - + org.eclipse.jetty jetty-http - ${jetty.version} org.eclipse.jetty jetty-security - ${jetty.version} runtime @@ -532,13 +509,13 @@ org.jgrapht jgrapht-core - 1.5.3 + 1.5.2 org.jgrapht jgrapht-opt - 1.5.3 + 1.5.2 @@ -637,21 +614,44 @@ removed. Unfortunately, at this time, it is required for Monex's Remote Console to function. --> - org.eclipse.jetty - jetty-annotations + org.eclipse.jetty.ee10 + jetty-ee10-annotations - org.eclipse.jetty - jetty-servlet + org.eclipse.jetty.ee10 + jetty-ee10-servlet - org.eclipse.jetty - jetty-webapp + org.eclipse.jetty.ee10 + jetty-ee10-webapp org.eclipse.jetty jetty-xml + + + jakarta.websocket + jakarta.websocket-client-api + provided + + + jakarta.websocket + jakarta.websocket-api + provided + + + + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jakarta-server + + + + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jakarta-client + ${jetty.version} + test + org.apache.httpcomponents httpcore @@ -839,6 +839,12 @@ src/main/java/org/exist/util/io/TemporaryFileManager.java src/test/java/org/exist/util/io/CachingFilterInputStreamNonMarkableByteArrayInputStreamTest.java src/main/java/org/exist/xquery/functions/fn/FnFormatNumbers.java + + + src/test/resources/xinclude-test-suite/** @@ -1037,13 +1043,14 @@ The BaseX Team. The original license statement is also included below.]]>org.xmlresolver:xmlresolver:jar:${xmlresolver.version} org.exist-db.thirdparty.org.eclipse.wst.xml:xpath2:jar:1.2.0 edu.princeton.cup:java-cup:jar:10k - org.eclipse.jetty:jetty-jaas:jar:${jetty.version} + org.eclipse.jetty:jetty-deploy:jar:${jetty.version} org.eclipse.jetty:jetty-jmx:jar:${jetty.version} - org.eclipse.jetty:jetty-annotations:jar:${jetty.version} + org.eclipse.jetty.ee10:jetty-ee10-annotations:jar:${jetty.version} org.eclipse.jetty:jetty-security:jar:${jetty.version} ${project.groupId}:exist-jetty-config:jar:${project.version} org.apache.mina:mina-core + org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-client:jar:${jetty.version} @@ -1201,6 +1208,7 @@ The BaseX Team. The original license statement is also included below.]]>${project.build.testOutputDirectory}/log4j2.xml + 180 + + + org.exist.storage.lock.DeadlockIT + org.exist.xmldb.RemoveCollectionIT + @{jacocoArgLine} --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dfile.encoding=${project.build.sourceEncoding} -Dexist.recovery.progressbar.hide=true ${project.basedir}/../exist-jetty-config/target/classes/org/exist/jetty diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g index d852d700444..a0752c5e871 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQuery.g @@ -83,6 +83,11 @@ options { protected Deque> globalStack = new ArrayDeque<>(); protected Deque elementStack = new ArrayDeque<>(); protected XQueryLexer lexer; + protected boolean xq4Enabled = false; + + // Version declared via "xquery version ;" prolog: 10, 30, 31, or 40. + // Default 30 preserves historical eXist behavior (no version decl => XQ 3.0+ rules). + protected int parsedXQueryVersion = 30; public XQueryParser(XQueryLexer lexer) { this((TokenStream)lexer); @@ -90,6 +95,10 @@ options { setASTNodeClass("org.exist.xquery.parser.XQueryAST"); } + public boolean isXQ4() { return xq4Enabled; } + + public int getParsedXQueryVersion() { return parsedXQueryVersion; } + public boolean foundErrors() { return foundError; } @@ -118,6 +127,63 @@ options { foundError = true; exceptions.add(e); } + + // Returns true if the supplied unprefixed name is a reserved function name + // per the XQuery 3.0+ FunctionDecl rule. empty-sequence, array, and map are + // intentionally excluded: XQTS function-decl-reserved-function-names-010a + // (XQ40+) treats empty-sequence as a valid function name in XQuery 4.0. + protected boolean isReservedFunctionName(final String name) { + if (name == null || name.indexOf(':') >= 0 || name.indexOf('{') >= 0) { + return false; + } + switch (name) { + case "attribute": + case "comment": + case "document-node": + case "element": + case "function": + case "if": + case "item": + case "namespace-node": + case "node": + case "processing-instruction": + case "schema-attribute": + case "schema-element": + case "switch": + case "text": + case "typeswitch": + return true; + default: + return false; + } + } + + /** + * XQuery 3.1 section 3.1.7.1: an inline function expression must not be + * annotated as %public or %private (XQST0125). The annotations subtree + * is suppressed from the AST (see {@code ann:annotations!}), so we + * inspect it here in the parser before it is dropped. + */ + protected static void rejectInlineFunctionPublicPrivate(antlr.collections.AST annotationsRoot) throws XPathException { + if (annotationsRoot == null) { + return; + } + for (antlr.collections.AST a = annotationsRoot; a != null; a = a.getNextSibling()) { + if (a.getType() != ANNOT_DECL) { + continue; + } + final String text = a.getText(); + if (text == null) { + continue; + } + final int colon = text.lastIndexOf(':'); + final String localName = colon >= 0 ? text.substring(colon + 1) : text; + if ("public".equals(localName) || "private".equals(localName)) { + throw new XPathException(a.getLine(), a.getColumn(), ErrorCodes.XQST0125, + "Inline function expressions must not be annotated as %" + localName + "."); + } + } + } } /* The following tokens are assigned by the parser (not the lexer) @@ -161,6 +227,7 @@ imaginaryTokenDefinitions MAP MAP_TEST LOOKUP + FILTER_AM ARRAY ARRAY_TEST PROLOG @@ -192,6 +259,78 @@ imaginaryTokenDefinitions PREVIOUS_ITEM NEXT_ITEM WINDOW_VARS + // Full Text (W3C XQuery and XPath Full Text 3.0) + FT_CONTAINS + FT_SELECTION + FT_OR + FT_AND + FT_MILD_NOT + FT_UNARY_NOT + FT_PRIMARY_WITH_OPTIONS + FT_WORDS + FT_ANYALL_OPTION + FT_TIMES + FT_RANGE + FT_ORDER + FT_WINDOW + FT_DISTANCE + FT_SCOPE + FT_CONTENT + FT_MATCH_OPTION + FT_CASE_OPTION + FT_DIACRITICS_OPTION + FT_STEM_OPTION + FT_THESAURUS_OPTION + FT_THESAURUS_ID + FT_STOP_WORD_OPTION + FT_STOP_WORDS + FT_STOP_WORDS_EXCEPT + FT_LANGUAGE_OPTION + FT_WILDCARD_OPTION + FT_EXTENSION_OPTION + FT_EXTENSION_SELECTION + FT_IGNORE_OPTION + FT_WEIGHT + FT_SCORE_VAR + FT_OPTION_DECL + // XQuery 4.0 Parser Extensions + FOCUS_FUNCTION + KEYWORD_ARG + FOR_MEMBER + STRING_TEMPLATE + FOR_KEY + FOR_VALUE + FOR_KEY_VALUE + VALUE_VAR + SWITCH_BOOLEAN + MAPPING_ARROW + FILTER_AM + QNAME_LITERAL + PARAM_DEFAULT + CHOICE_TYPE + ENUM_TYPE + TERNARY + SEQ_DESTRUCTURE + ARRAY_DESTRUCTURE + MAP_DESTRUCTURE + DESTRUCTURE_VAR_TYPE + RECORD_TEST + RECORD_FIELD + // Decimal Format Declarations + DECIMAL_FORMAT_DECL + DEF_DECIMAL_FORMAT_DECL + // XQuery 4.0 JNode Kind Tests + JSON_NODE_TEST + JSON_OBJECT_TEST + JSON_ARRAY_TEST + JSON_STRING_TEST + JSON_NUMBER_TEST + JSON_BOOLEAN_TEST + JSON_NULL_TEST + JSON_MEMBER_TEST + // === XQuery 4.0 Map Content Expressions === + MAP_CONTENT + ANNOTATED_FUNCTION_TEST ; // === XPointer === @@ -256,11 +395,21 @@ prolog throws XPathException ( importDecl | - ( "declare" ( "default" | "boundary-space" | "ordering" | "construction" | "base-uri" | "copy-namespaces" | "namespace" ) ) => + ( "declare" ( "default" | "boundary-space" | "ordering" | "construction" | "base-uri" | "copy-namespaces" | "namespace" | "decimal-format" | "revalidation" ) ) => s:setter { if(!inSetters) - throw new XPathException(#s, "Default declarations have to come first"); + throw new XPathException(#s, ErrorCodes.XPST0003, "Default declarations have to come first"); + } + | + ( "declare" "ft-option" ) + => fto:ftOptionDecl + { + // XQFT 3.0 §2.6: FTOptionDecl is in the first section of the prolog + // (same level as setters and imports), not the second section. + if (!inSetters) + throw new XPathException(#fto, ErrorCodes.XPST0003, + "'declare ft-option' must appear before variable and function declarations"); } | ( "declare" "option" ) @@ -269,10 +418,13 @@ prolog throws XPathException ( "declare" "function" ) => functionDeclUp { inSetters = false; } | + ( "declare" "updating" "function" ) + => updatingFunctionDeclUp { inSetters = false; } + | ( "declare" "variable" ) => varDeclUp { inSetters = false; } | - ( "declare" "context" "item" ) + ( "declare" "context" ("item" | "value") ) => contextItemDeclUp { inSetters = false; } | ( "declare" MOD ) @@ -292,7 +444,17 @@ importDecl throws XPathException versionDecl throws XPathException : "xquery" "version" v:STRING_LITERAL ( "encoding"! enc:STRING_LITERAL )? - { #versionDecl = #(#[VERSION_DECL, v.getText()], enc); } + { + #versionDecl = #(#[VERSION_DECL, v.getText()], enc); + final String ver = v.getText(); + switch (ver) { + case "4.0" -> { xq4Enabled = true; parsedXQueryVersion = 40; } + case "3.1" -> parsedXQueryVersion = 31; + case "3.0" -> parsedXQueryVersion = 30; + case "1.0" -> parsedXQueryVersion = 10; + default -> { } + } + } ; setter @@ -311,6 +473,9 @@ setter { #setter= #(#[DEF_FUNCTION_NS_DECL, "defaultFunctionNSDecl"], deff); } | "order"^ "empty"! ( "greatest" | "least" ) + | + "decimal-format"! ( dfDefProperty )* + { #setter = #(#[DEF_DECIMAL_FORMAT_DECL, "defaultDecimalFormatDecl"], #setter); } ) | ( "declare" "boundary-space" ) => @@ -325,14 +490,39 @@ setter ( "declare" "construction" ) => "declare"! "construction"^ ( "preserve" | "strip" ) | + // === W3C XQuery Update Facility 3.0 - Revalidation Declaration === + ( "declare" "revalidation" ) => + "declare"! "revalidation"^ ( "strict" | "lax" | "skip" ) + | ( "declare" "copy-namespaces" ) => "declare"! "copy-namespaces"^ preserveMode COMMA! inheritMode | ( "declare" "namespace" ) => namespaceDecl + | + ( "declare" "decimal-format" ) => + decimalFormatDecl ) ; +decimalFormatDecl +{ String eq = null; } +: + decl:"declare"! "decimal-format"! eq=eqName! ( dfDefProperty )* + { + #decimalFormatDecl = #(#[DECIMAL_FORMAT_DECL, eq], #decimalFormatDecl); + #decimalFormatDecl.copyLexInfo(#decl); + } + ; + +dfDefProperty +: + ( "decimal-separator"^ | "grouping-separator"^ | "infinity"^ | "minus-sign"^ + | "NaN"^ | "percent"^ | "per-mille"^ | "zero-digit"^ | "digit"^ + | "pattern-separator"^ | "exponent-separator"^ ) + EQ! STRING_LITERAL + ; + preserveMode : ( "preserve" | "no-preserve" ) @@ -428,7 +618,7 @@ annotateDecl! throws XPathException : decl:"declare"! ann:annotations! ( - ("function") => f:functionDecl[#ann] { #annotateDecl = #f; } + ("function") => f:functionDecl[#ann, false] { #annotateDecl = #f; } | ("variable") => v:varDecl[#decl, #ann] { #annotateDecl = #v; } ) @@ -441,7 +631,7 @@ contextItemDeclUp! throws XPathException contextItemDecl [XQueryAST decl] throws XPathException : - "context"! "item"! ( typeDeclaration )? + "context"! ( "item"! | "value"! ) ( typeDeclaration )? ( COLON! EQ! e1:expr | @@ -464,10 +654,22 @@ annotation String name= null; } : - MOD! name=eqName! (LPAREN! literal (COMMA! literal)* RPAREN!)? + MOD! name=eqName! (LPAREN! annotationLiteral (COMMA! annotationLiteral)* RPAREN!)? { #annotation= #(#[ANNOT_DECL, name], #annotation); } ; +// XQ4: annotation parameters support literals, true(), false(), and negated numeric literals +// Note: true()/false() must be matched via NCNAME + semantic predicate, NOT as "true"/"false" keywords. +// Using quoted keyword syntax would register them in testLiterals, breaking true()/false() function +// calls throughout the grammar (ANTLR 2 converts all NCNAMEs matching keywords to LITERAL_xxx tokens). +annotationLiteral +: + literal + | ( { LT(1).getText().equals("true") || LT(1).getText().equals("false") }? b:NCNAME LPAREN! RPAREN! + { #annotationLiteral = #[STRING_LITERAL, #b.getText()]; #b = null; } ) + | MINUS! n:numericLiteral { #n.setText("-" + #n.getText()); #annotationLiteral = #n; } + ; + eqName returns [String name] { name= null; } : @@ -496,19 +698,35 @@ bracedUriLiteral returns [String uri] functionDeclUp! throws XPathException : - "declare"! f:functionDecl[null] { #functionDeclUp = #f; } + "declare"! f:functionDecl[null, false] { #functionDeclUp = #f; } + ; + +updatingFunctionDeclUp! throws XPathException +: + "declare"! "updating"! f:functionDecl[null, true] { #updatingFunctionDeclUp = #f; } ; -functionDecl [XQueryAST ann] throws XPathException +functionDecl [XQueryAST ann, boolean updating] throws XPathException { String name= null; } : - "function"! name=eqName! lp:LPAREN! ( paramList )? + "function"! name=eqName! + { + // Reserved-name restriction was introduced in XQuery 3.0; XQuery 1.0 allows these names. + if (parsedXQueryVersion >= 30 && isReservedFunctionName(name)) { + throw new XPathException(ErrorCodes.XPST0003, + "A reserved function name '" + name + "' cannot be used as the name of a function declaration."); + } + } + lp:LPAREN! ( paramList )? RPAREN! ( returnType )? ( functionBody | "external" ) { #functionDecl= #(#[FUNCTION_DECL, name, org.exist.xquery.parser.XQueryFunctionAST.class.getName()], #ann, #functionDecl); #functionDecl.copyLexInfo(#lp); #functionDecl.setDoc(getXQDoc()); + if (updating) { + ((XQueryFunctionAST) #functionDecl).setUpdating(true); + } } exception catch [RecognitionException e] { @@ -550,7 +768,10 @@ param throws XPathException { String varName= null; } : DOLLAR! varName=eqName ( t:typeDeclaration )? - { #param= #(#[VARIABLE_BINDING, varName], #t); } + ( ( { xq4Enabled }? COLON EQ ) => COLON! EQ! pd:exprSingle! + { #pd = #(#[PARAM_DEFAULT, "param-default"], #pd); } + )? + { #param= #(#[VARIABLE_BINDING, varName], #t, #pd); } ; uriList throws XPathException @@ -586,12 +807,20 @@ itemType throws XPathException : ( "item" LPAREN ) => "item"^ LPAREN! RPAREN! | + ( MOD ) => annotatedFunctionTest + | ( "function" LPAREN ) => functionTest | + ( "fn" LPAREN ) => fnShorthandFunctionTest + | ( "map" LPAREN ) => mapType | ( "array" LPAREN ) => arrayType | + ( "record" LPAREN ) => recordType + | + ( "enum" LPAREN ) => enumType + | ( LPAREN ) => parenthesizedItemType | ( . LPAREN ) => kindTest @@ -600,13 +829,51 @@ itemType throws XPathException ; parenthesizedItemType throws XPathException +{ int count = 0; } +: + LPAREN! itemType { count++; } ( UNION! itemType { count++; } )* RPAREN! + { + if (count > 1) { + #parenthesizedItemType = #(#[CHOICE_TYPE, "choice-type"], #parenthesizedItemType); + } + } + ; + +enumType throws XPathException +{ List enumValues = new ArrayList(); } : - LPAREN! itemType RPAREN! + e:"enum"! LPAREN! + s1:STRING_LITERAL! { enumValues.add(s1.getText()); } + ( COMMA! s2:STRING_LITERAL! { enumValues.add(s2.getText()); } )* + RPAREN! + { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < enumValues.size(); i++) { + if (i > 0) sb.append(","); + sb.append(enumValues.get(i)); + } + #enumType = #(#[ENUM_TYPE, sb.toString()]); + #enumType.copyLexInfo(#e); + } ; singleType throws XPathException +{ int count = 0; } : - atomicType ( QUESTION )? + ( + ( "enum" LPAREN ) => enumType ( QUESTION )? + | + ( LPAREN ) => + LPAREN! atomicType { count++; } ( UNION! atomicType { count++; } )* RPAREN! + { + if (count > 1) { + #singleType = #(#[CHOICE_TYPE, "choice-type"], #singleType); + } + } + ( QUESTION )? + | + atomicType ( QUESTION )? + ) ; atomicType throws XPathException @@ -634,10 +901,52 @@ anyFunctionTest throws XPathException typedFunctionTest throws XPathException : - "function"! LPAREN! (sequenceType (COMMA! sequenceType)*)? RPAREN! "as" sequenceType + "function"! LPAREN! (fnShorthandParam (COMMA! fnShorthandParam)*)? RPAREN! "as" sequenceType { #typedFunctionTest = #(#[FUNCTION_TEST, "anyFunction"], #typedFunctionTest); } ; +// XQ4: fn(...) as shorthand for function(...) in type positions +fnShorthandFunctionTest throws XPathException +: + ( "fn" LPAREN STAR RPAREN) => fnShorthandAnyFunctionTest + | + fnShorthandTypedFunctionTest + ; + +fnShorthandAnyFunctionTest throws XPathException +: + "fn"! LPAREN! s2:STAR RPAREN! + { #fnShorthandAnyFunctionTest = #(#[FUNCTION_TEST, "anyFunction"], #s2); } + ; + +fnShorthandTypedFunctionTest throws XPathException +: + "fn"! LPAREN! (fnShorthandParam (COMMA! fnShorthandParam)*)? RPAREN! "as" sequenceType + { #fnShorthandTypedFunctionTest = #(#[FUNCTION_TEST, "anyFunction"], #fnShorthandTypedFunctionTest); } + ; + +// XQ3.1+: AnnotatedFunctionTest := Annotation+ FunctionTest +// Allows annotations on function item types in sequence-type positions, e.g.: +// () instance of %eg:x function(*) +annotatedFunctionTest throws XPathException +: + (annotation)+ + ( ( "function" LPAREN ) => functionTest + | fnShorthandFunctionTest + ) + { + #annotatedFunctionTest = #(#[ANNOTATED_FUNCTION_TEST, "annotated-function-test"], #annotatedFunctionTest); + } + ; + +// XQ4: fn() type parameters can optionally have names: fn($name as type, ...) +fnShorthandParam throws XPathException +: + ( DOLLAR ) => DOLLAR! eqName! "as"! sequenceType + | + sequenceType + ; + mapType throws XPathException : ( "map" LPAREN STAR ) => anyMapTypeTest @@ -686,6 +995,65 @@ arrayTypeTest throws XPathException } ; +// === XQuery 4.0 Record Type === + +recordType throws XPathException +: + ( "record" LPAREN RPAREN ) => emptyRecordTest + | + ( "record" LPAREN STAR ) => extensibleRecordReject + | + typedRecordTest + ; + +emptyRecordTest throws XPathException +: + m:"record"! LPAREN! RPAREN! + { + #emptyRecordTest = #(#[RECORD_TEST, "record"], #emptyRecordTest); + #emptyRecordTest.copyLexInfo(#m); + } + ; + +extensibleRecordReject throws XPathException +: + m:"record"! LPAREN! STAR RPAREN! + { + throw new XPathException(m.getLine(), m.getColumn(), ErrorCodes.XPST0003, + "Extensible record types record(*) are not supported in XQuery 4.0"); + } + ; + +typedRecordTest throws XPathException +{ boolean extensible = false; } +: + m:"record"! LPAREN! recordFieldDecl (COMMA! + ( ( STAR ) => STAR { extensible = true; } + | recordFieldDecl + ) + )* RPAREN! + { + if (extensible) { + throw new XPathException(m.getLine(), m.getColumn(), ErrorCodes.XPST0003, + "Extensible record types record(..., *) are not supported in XQuery 4.0"); + } + #typedRecordTest = #(#[RECORD_TEST, "record"], #typedRecordTest); + #typedRecordTest.copyLexInfo(#m); + } + ; + +recordFieldDecl throws XPathException +{ String fieldName = null; String fieldLabel = null; boolean isOptional = false; } +: + fieldName=ncnameOrKeyword! + ( QUESTION! { isOptional = true; } )? + ( "as"! sequenceType )? + { + fieldLabel = isOptional ? fieldName.concat("?") : fieldName; + #recordFieldDecl = #(#[RECORD_FIELD, fieldLabel], #recordFieldDecl); + } + ; + // === Expressions === queryBody throws XPathException: expr ; @@ -702,17 +1070,26 @@ expr throws XPathException exprSingle throws XPathException : - ( ( "for" | "let" ) ("tumbling" | "sliding" | DOLLAR ) ) => flworExpr + ( ( "for" | "let" ) ("tumbling" | "sliding" | "score" | "member" | "key" | "value" | DOLLAR) ) => flworExpr | ( "try" LCURLY ) => tryCatchExpr | ( ( "some" | "every" ) DOLLAR ) => quantifiedExpr | ( "if" LPAREN ) => ifExpr | ( "switch" LPAREN ) => switchExpr | ( "typeswitch" LPAREN ) => typeswitchExpr + // === Legacy update (DEPRECATED - use W3C XQuery Update Facility 3.0 syntax instead) === | ( "update" ( "replace" | "value" | "insert" | "delete" | "rename" )) => updateExpr + // === W3C XQuery Update Facility 3.0 === + | ( "insert" ( "node" | "nodes" ) ) => xqufInsertExpr + | ( "delete" ( "node" | "nodes" ) ) => xqufDeleteExpr + | ( "replace" ( "node" | "value" ) ) => xqufReplaceExpr + | ( "rename" "node" ) => xqufRenameExpr + | ( "copy" DOLLAR ) => xqufTransformExpr | orExpr ; -// === Xupdate === +// === Legacy update (DEPRECATED - use W3C XQuery Update Facility 3.0 syntax instead) === +// To remove legacy update support, delete this section and the updateExpr +// alternative in exprSingle above. updateExpr throws XPathException : @@ -752,11 +1129,65 @@ renameExpr throws XPathException "rename" exprSingle "as"! exprSingle ; -// === try/catch === +// === W3C XQuery Update Facility 3.0 === + +xqufInsertExpr throws XPathException +: + "insert"^ ( "node"! | "nodes"! ) exprSingle + ( + ( "as" "first" "into" ) => "as"! "first" "into"! exprSingle + | ( "as" "last" "into" ) => "as"! "last" "into"! exprSingle + | "into" exprSingle + | "before" exprSingle + | "after" exprSingle + ) + ; + +xqufDeleteExpr throws XPathException +: + "delete"^ ( "node"! | "nodes"! ) exprSingle + ; + +xqufReplaceExpr throws XPathException +: + "replace"^ + ( + ( "value" "of" "node" ) => "value" "of"! "node"! exprSingle "with"! exprSingle + | "node"! exprSingle "with"! exprSingle + ) + ; + +xqufRenameExpr throws XPathException +: + "rename"^ "node"! exprSingle "as"! exprSingle + ; + +xqufTransformExpr throws XPathException +: + "copy"^ + xqufCopyBinding ( COMMA! xqufCopyBinding )* + "modify"! exprSingle + "return"! exprSingle + ; + +xqufCopyBinding throws XPathException +{ String varName; } +: + DOLLAR! varName=v:varName! COLON! EQ! exprSingle + { + #xqufCopyBinding = #(#[VARIABLE_BINDING, varName], #xqufCopyBinding); + #xqufCopyBinding.copyLexInfo(#v); + } + ; + +// === try/catch/finally === tryCatchExpr throws XPathException : "try"^ LCURLY! tryTargetExpr RCURLY! - (catchClause)+ + ( + (catchClause)+ ( { xq4Enabled }? finallyClause )? + | { xq4Enabled }? finallyClause + ) ; tryTargetExpr throws XPathException @@ -769,6 +1200,11 @@ catchClause throws XPathException "catch"^ catchErrorList (catchVars)? LCURLY! expr RCURLY! ; +finallyClause throws XPathException +: + "finally"^ LCURLY! (expr)? RCURLY! + ; + catchErrorList throws XPathException : nameTest (UNION! nameTest)* @@ -809,14 +1245,14 @@ flworExpr throws XPathException initialClause throws XPathException : - ( ( "for" DOLLAR ) => forClause + ( ( "for" ( "member" | "key" | "value" | DOLLAR ) ) => forClause | ( "for" ( "tumbling" | "sliding" ) ) => windowClause | letClause ) ; intermediateClause throws XPathException : - ( initialClause | whereClause | groupByClause | orderByClause | countClause ) + ( initialClause | whereClause | whileClause | groupByClause | orderByClause | countClause ) ; whereClause throws XPathException @@ -824,6 +1260,11 @@ whereClause throws XPathException "where"^ exprSingle ; +whileClause throws XPathException +: + { xq4Enabled }? "while"^ exprSingle + ; + countClause throws XPathException { String varName; } : @@ -833,17 +1274,91 @@ countClause throws XPathException forClause throws XPathException : - "for"^ inVarBinding ( COMMA! inVarBinding )* + "for"^ forBinding ( COMMA! forBinding )* + ; + +forBinding throws XPathException +: + ( { xq4Enabled }? "member" ) => memberVarBinding + | ( { xq4Enabled }? "key" ) => keyVarBinding + | ( { xq4Enabled }? "value" ) => valueVarBinding + | inVarBinding + ; + +memberVarBinding throws XPathException +{ String varName; } +: + "member"! DOLLAR! varName=v:varName! ( typeDeclaration )? + ( positionalVar )? + "in"! exprSingle + { + #memberVarBinding= #(#[VARIABLE_BINDING, varName], #memberVarBinding); + #memberVarBinding.copyLexInfo(#v); + #memberVarBinding= #(#[FOR_MEMBER, null], #memberVarBinding); + } + ; + +keyVarBinding throws XPathException +{ String varName; } +: + "key"! DOLLAR! varName=v:varName! ( typeDeclaration )? + ( + ( "value" DOLLAR ) => keyValueVarPart + )? + ( positionalVar )? + "in"! exprSingle + { + #keyVarBinding= #(#[VARIABLE_BINDING, varName], #keyVarBinding); + #keyVarBinding.copyLexInfo(#v); + // Check if we have a value variable (keyValueVarPart was matched) + boolean hasValueVar = false; + AST child = #keyVarBinding.getFirstChild(); + while (child != null) { + if (child.getType() == VALUE_VAR) { hasValueVar = true; break; } + child = child.getNextSibling(); + } + if (hasValueVar) { + #keyVarBinding= #(#[FOR_KEY_VALUE, null], #keyVarBinding); + } else { + #keyVarBinding= #(#[FOR_KEY, null], #keyVarBinding); + } + } + ; + +keyValueVarPart throws XPathException +{ String valueVarName; } +: + "value"! DOLLAR! valueVarName=varName! ( typeDeclaration )? + { + #keyValueVarPart = #(#[VALUE_VAR, valueVarName], #keyValueVarPart); + } + ; + +valueVarBinding throws XPathException +{ String varName; } +: + "value"! DOLLAR! varName=v:varName! ( typeDeclaration )? + ( positionalVar )? + "in"! exprSingle + { + #valueVarBinding= #(#[VARIABLE_BINDING, varName], #valueVarBinding); + #valueVarBinding.copyLexInfo(#v); + #valueVarBinding= #(#[FOR_VALUE, null], #valueVarBinding); + } ; letClause throws XPathException : - "let"^ letVarBinding ( COMMA! letVarBinding )* + "let"^ ( ( "score" ) => ftScoreVarBinding | letVarBinding ) + ( COMMA! ( ( "score" ) => ftScoreVarBinding | letVarBinding ) )* ; windowClause throws XPathException : - "for"! ("tumbling"|"sliding") "window"^ inVarBinding windowStartCondition ( windowEndCondition )? + // XQ4 PR483: WindowStartCondition and the trailing WindowEndCondition are + // both individually optional (sliding without an end clause is rejected + // downstream during AST construction). + "for"! ("tumbling"|"sliding") "window"^ inVarBinding ( windowStartCondition )? ( windowEndCondition )? ; inVarBinding throws XPathException @@ -851,6 +1366,7 @@ inVarBinding throws XPathException : DOLLAR! varName=v:varName! ( typeDeclaration )? ( allowingEmpty )? ( positionalVar )? + ( ftScoreVar )? "in"! exprSingle { #inVarBinding= #(#[VARIABLE_BINDING, varName], #inVarBinding); @@ -872,12 +1388,15 @@ allowingEmpty windowStartCondition throws XPathException : - "start"^ windowVars "when" exprSingle + // XQ4 PR483: the "when ExprSingle" guard is optional; absent means the + // condition is implicitly true() (every item starts a window). + "start"^ windowVars ( "when" exprSingle )? ; windowEndCondition throws XPathException : - ( "only" )? "end"^ windowVars "when" exprSingle + // XQ4 PR483: same treatment for the end condition's "when ExprSingle". + ( "only" )? "end"^ windowVars ( "when" exprSingle )? ; windowVars throws XPathException @@ -904,6 +1423,16 @@ windowVars throws XPathException letVarBinding throws XPathException { String varName; } : + // XQ4: sequence destructuring - let $($x, $y) := expr + ( DOLLAR LPAREN ) => letDestructureSeq + | + // XQ4: array destructuring - let $[$x, $y] := expr + ( DOLLAR LPPAREN ) => letDestructureArray + | + // XQ4: map destructuring - let ${$x, $y} := expr + ( DOLLAR LCURLY ) => letDestructureMap + | + // Standard let binding DOLLAR! varName=v:varName! ( typeDeclaration )? COLON! EQ! exprSingle { @@ -912,6 +1441,86 @@ letVarBinding throws XPathException } ; +// XQFT 3.0: FTScoreVar in for binding - "score" "$" VarName +ftScoreVar +{ String varName; } +: + "score" DOLLAR! varName=varName + { #ftScoreVar= #[FT_SCORE_VAR, varName]; } + ; + +// XQFT 3.0: FTScoreVar as let clause - "score" "$" VarName ":=" ExprSingle +ftScoreVarBinding throws XPathException +{ String varName; } +: + "score"! DOLLAR! varName=v:varName! COLON! EQ! exprSingle + { + #ftScoreVarBinding= #(#[VARIABLE_BINDING, varName], #[FT_SCORE_VAR, "score"], #ftScoreVarBinding); + #ftScoreVarBinding.copyLexInfo(#v); + } + ; + +// XQ4: Per-variable type annotations: "x+,y" means $x has a DESTRUCTURE_VAR_TYPE child, $y does not +letDestructureSeq throws XPathException +{ String vn; + StringBuilder sb = new StringBuilder(); } +: + d:DOLLAR! LPAREN! + DOLLAR! vn=varName! { sb.append(vn); } + ( destructureVarType { sb.append("+"); } )? + ( COMMA! DOLLAR! vn=varName! { sb.append(",").append(vn); } + ( destructureVarType { sb.append("+"); } )? )* + RPAREN! ( typeDeclaration )? + COLON! EQ! exprSingle + { + #letDestructureSeq = #(#[SEQ_DESTRUCTURE, sb.toString()], #letDestructureSeq); + #letDestructureSeq.copyLexInfo(#d); + } + ; + +letDestructureArray throws XPathException +{ String vn; + StringBuilder sb = new StringBuilder(); } +: + d:DOLLAR! LPPAREN! + DOLLAR! vn=varName! { sb.append(vn); } + ( destructureVarType { sb.append("+"); } )? + ( COMMA! DOLLAR! vn=varName! { sb.append(",").append(vn); } + ( destructureVarType { sb.append("+"); } )? )* + RPPAREN! ( typeDeclaration )? + COLON! EQ! exprSingle + { + #letDestructureArray = #(#[ARRAY_DESTRUCTURE, sb.toString()], #letDestructureArray); + #letDestructureArray.copyLexInfo(#d); + } + ; + +letDestructureMap throws XPathException +{ String vn; + StringBuilder sb = new StringBuilder(); } +: + d:DOLLAR! LCURLY! + DOLLAR! vn=varName! { sb.append(vn); } + ( destructureVarType { sb.append("+"); } )? + ( COMMA! DOLLAR! vn=varName! { sb.append(",").append(vn); } + ( destructureVarType { sb.append("+"); } )? )* + RCURLY! ( typeDeclaration )? + COLON! EQ! exprSingle + { + #letDestructureMap = #(#[MAP_DESTRUCTURE, sb.toString()], #letDestructureMap); + #letDestructureMap.copyLexInfo(#d); + } + ; + +// Helper: wraps typeDeclaration in DESTRUCTURE_VAR_TYPE imaginary token +destructureVarType throws XPathException +: + td:typeDeclaration + { + #destructureVarType = #(#[DESTRUCTURE_VAR_TYPE, "vartype"], #td); + } + ; + orderByClause throws XPathException : ( "order"! "by"! | "stable"! "order"! "by"! ) orderSpecList @@ -973,9 +1582,26 @@ quantifiedInVarBinding throws XPathException switchExpr throws XPathException : - "switch"^ LPAREN! expr RPAREN! - ( switchCaseClause )+ - "default" "return"! exprSingle + "switch"^ LPAREN! + ( + // XQ4 omitted comparand - boolean mode: switch () { case boolExpr return ... } + ( RPAREN ) => + RPAREN! switchBooleanMarker + | + expr RPAREN! + ) + ( + // XQ4 braced syntax: switch (...) { case ... default ... } + ( LCURLY "case" ) => + LCURLY! ( switchCaseClause )+ "default" "return"! exprSingle RCURLY! + | + ( switchCaseClause )+ "default" "return"! exprSingle + ) + ; + +switchBooleanMarker +: + { #switchBooleanMarker = #(#[SWITCH_BOOLEAN, "switch-boolean"]); } ; switchCaseClause throws XPathException @@ -988,8 +1614,13 @@ typeswitchExpr throws XPathException { String varName; } : "typeswitch"^ LPAREN! expr RPAREN! - ( caseClause )+ - "default" ( defaultVar )? "return"! exprSingle + ( + // XQ4 braced syntax: typeswitch (...) { case ... default ... } + ( LCURLY "case" ) => + LCURLY! ( caseClause )+ "default" ( defaultVar )? "return"! exprSingle RCURLY! + | + ( caseClause )+ "default" ( defaultVar )? "return"! exprSingle + ) ; caseClause throws XPathException @@ -1024,12 +1655,28 @@ defaultVar throws XPathException ; ifExpr throws XPathException +{ + org.exist.xquery.parser.XQueryAST emptyNode = null; +} : - "if"^ LPAREN! expr RPAREN! t:"then"! thenExpr:exprSingle e:"else"! elseExpr:exprSingle - { - #thenExpr.copyLexInfo(#t); - #elseExpr.copyLexInfo(#e); - } + "if"^ LPAREN! expr RPAREN! + ( + // Traditional: if (cond) then expr else expr + ( "then" ) => + t:"then"! thenExpr:exprSingle e:"else"! elseExpr:exprSingle + { + #thenExpr.copyLexInfo(#t); + #elseExpr.copyLexInfo(#e); + } + | + // XQ4 Braced: if (cond) { expr } (no else clause; returns empty sequence if false) + LCURLY! bracedThenExpr:expr RCURLY! + { + // Synthesize empty sequence as implicit else branch + emptyNode = (org.exist.xquery.parser.XQueryAST) #(#[PARENTHESIZED, "()"]); + #ifExpr.addChild(emptyNode); + } + ) ; // === Logical === @@ -1037,6 +1684,12 @@ ifExpr throws XPathException orExpr throws XPathException : andExpr ( "or"^ andExpr )* + ( + { xq4Enabled }? DOUBLE_QUESTION! exprSingle DOUBLE_BANG! exprSingle + { + #orExpr = #(#[TERNARY, "ternary"], #orExpr); + } + )? ; andExpr throws XPathException @@ -1061,31 +1714,59 @@ castableExpr throws XPathException castExpr throws XPathException : - arrowExpr ( "cast"^ "as"! singleType )? + pipelineExpr ( "cast"^ "as"! singleType )? + ; + +pipelineExpr throws XPathException +: + arrowExpr ( { xq4Enabled }? PIPELINE_OP^ arrowExpr )* ; comparisonExpr throws XPathException : - r1:stringConcatExpr ( - ( BEFORE ) => BEFORE^ stringConcatExpr + r1:ftContainsExpr ( + ( BEFORE ) => BEFORE^ ftContainsExpr | - ( AFTER ) => AFTER^ stringConcatExpr - | ( ( "eq"^ | "ne"^ | "lt"^ | "le"^ | "gt"^ | "ge"^ ) stringConcatExpr ) - | ( GT EQ ) => GT^ EQ^ r2:rangeExpr + ( AFTER ) => AFTER^ ftContainsExpr + | ( ( "eq"^ | "ne"^ | "lt"^ | "le"^ | "gt"^ | "ge"^ ) ftContainsExpr ) + | ( GT EQ ) => GT^ EQ^ r2:ftContainsExpr { #comparisonExpr = #(#[GTEQ, ">="], #r1, #r2); } - | ( ( EQ^ | NEQ^ | GT^ | LT^ | LTEQ^ ) stringConcatExpr ) - | ( ( "is"^ | "isnot"^ ) stringConcatExpr ) + | ( ( EQ^ | NEQ^ | GT^ | LT^ | LTEQ^ ) ftContainsExpr ) + | ( ( "is"^ | "isnot"^ | "is-not"^ | "follows-or-is"^ | "precedes-or-is"^ ) ftContainsExpr ) )? ; -stringConcatExpr throws XPathException -{ boolean isConcat = false; } +// XQFT 3.0: FTContainsExpr sits between ComparisonExpr and OtherwiseExpr +ftContainsExpr throws XPathException : - r1:rangeExpr ( - CONCAT! rangeExpr { isConcat = true; } - )* - { - if (isConcat) + r1:otherwiseExpr ( + ( "contains" "text" ) => "contains"! "text"! ft:ftSelection ( ( "without" ) => fti:ftIgnoreOption )? + { + // Break auto-tree sibling links to prevent circular refs in ASTFactory.make() + #r1.setNextSibling(null); + #ft.setNextSibling(null); + if (#fti != null) { + #ftContainsExpr = #(#[FT_CONTAINS, "contains text"], #r1, #ft, #fti); + } else { + #ftContainsExpr = #(#[FT_CONTAINS, "contains text"], #r1, #ft); + } + } + )? + ; + +otherwiseExpr throws XPathException +: + stringConcatExpr ( { xq4Enabled }? "otherwise"^ stringConcatExpr )* + ; + +stringConcatExpr throws XPathException +{ boolean isConcat = false; } +: + r1:rangeExpr ( + CONCAT! rangeExpr { isConcat = true; } + )* + { + if (isConcat) #stringConcatExpr = #(#[CONCAT, "||"], #stringConcatExpr); } ; @@ -1222,18 +1903,40 @@ stepExpr throws XPathException | ( ( "element" | "attribute" | "text" | "document" | "comment" | "namespace-node" | "processing-instruction" | "namespace" | "ordered" | - "unordered" | "map" | "array" ) LCURLY ) => + "unordered" | "map" | "array" | "fn" | "function" | "validate" ) LCURLY ) => postfixExpr | + // "validate lax {" / "validate strict {" -- ValidateExpr (XQuery 3.1 section 3.18.1) + ( "validate" ( "lax" | "strict" ) LCURLY ) => postfixExpr + | ( ( "element" | "attribute" | "processing-instruction" | "namespace" ) eqName LCURLY ) => postfixExpr | + ( "fn" LPAREN ) => postfixExpr + | + // XQ4: get(args) as a path step - selects map values, array items, + // or JSON-node children by key/index. Must precede the eqName-LPAREN + // alternative below, which would otherwise treat it as a function call. + ( { xq4Enabled }? "get" LPAREN ) => getStep + | ( MOD | DOLLAR | ( eqName ( LPAREN | HASH ) ) | SELF | LPAREN | literal | XML_COMMENT | LT | - XML_PI | QUESTION | LPPAREN | STRING_CONSTRUCTOR_START ) + XML_PI | QUESTION | LPPAREN | STRING_CONSTRUCTOR_START | STRING_TEMPLATE_START | LCURLY | HASH ) => postfixExpr | axisStep ; +// XQ4: get() as a path step. Produces a LOOKUP AST so the existing tree walker +// builds a Lookup expression with null leftExpr - at runtime, the path context +// flows in as the lookup target. +getStep throws XPathException +: + g:"get"! LPAREN! e:exprSingle RPAREN! + { + #getStep = #(#[LOOKUP, "?"], #e); + #getStep.copyLexInfo(#g); + } + ; + axisStep throws XPathException : ( forwardOrReverseStep ) predicates @@ -1271,14 +1974,17 @@ forwardAxis : forwardAxisSpecifier COLON! COLON! ; forwardAxisSpecifier : "child" | "self" | "attribute" | "descendant" | "descendant-or-self" - | "following-sibling" | "following" + | "following-sibling-or-self" | "following-sibling" + | "following-or-self" | "following" ; reverseAxis : reverseAxisSpecifier COLON! COLON! ; reverseAxisSpecifier : - "parent" | "ancestor" | "ancestor-or-self" | "preceding-sibling" | "preceding" + "parent" | "ancestor" | "ancestor-or-self" + | "preceding-sibling-or-self" | "preceding-sibling" + | "preceding-or-self" | "preceding" ; nodeTest throws XPathException @@ -1326,18 +2032,53 @@ postfixExpr throws XPathException | (LPAREN) => dynamicFunCall | + // XQuery 4.0: FilterExprAM - must check before lookup + (QUESTION LPPAREN) => filterExprAM + | (QUESTION) => lookup )* ; +// XQuery 4.0: Array/Map Filter Expression +filterExprAM throws XPathException +{ } +: + q:QUESTION! LPPAREN! expr:exprSingle RPPAREN! + { + #filterExprAM = #(#[FILTER_AM, "?["], #expr); + #filterExprAM.copyLexInfo(#q); + } + ; + arrowExpr throws XPathException : - unaryExpr ( ARROW_OP^ arrowFunctionSpecifier argumentList )* + unaryExpr ( + ARROW_OP^ arrowFunctionSpecifier argumentList + | + { xq4Enabled }? MAPPING_ARROW_OP^ arrowFunctionSpecifier argumentList + | + { xq4Enabled }? METHOD_CALL_OP^ NCNAME argumentList + )* ; arrowFunctionSpecifier throws XPathException { String name= null; } : + // XQ4: inline/focus function expression + ( MOD | ( ("function" | "fn") (LPAREN | LCURLY) ) ) => inlineOrFocusFunctionExpr + | + // XQ4: named function reference (eqName '#' arity) + ( eqName HASH ) => namedFunctionRef + | + // XQ4: map constructor as function + ( "map" LCURLY ) => mapConstructor + | + // XQ4: bare map constructor as function + ( { xq4Enabled }? LCURLY ) => bareMapConstructor + | + // XQ4: array constructor as function + ( LPPAREN | ("array" LCURLY) ) => arrayConstructor + | name=n:eqName { #arrowFunctionSpecifier= #[EQNAME, name]; @@ -1350,7 +2091,7 @@ arrowFunctionSpecifier throws XPathException ; lookup throws XPathException -{ String name= null; } +{ String name= null; String varName= null; } : q:QUESTION! ( @@ -1360,18 +2101,59 @@ lookup throws XPathException #lookup.copyLexInfo(#q); } | + // XQ4: decimal and double literals as key selectors (?1.2, ?1.2e0) + { xq4Enabled }? dbl:DOUBLE_LITERAL + { + #lookup = #(#[LOOKUP, "?"], #dbl); + #lookup.copyLexInfo(#q); + } + | + { xq4Enabled }? dec:DECIMAL_LITERAL + { + #lookup = #(#[LOOKUP, "?"], #dec); + #lookup.copyLexInfo(#q); + } + | pos:INTEGER_LITERAL { #lookup = #(#[LOOKUP, "?"], #pos); #lookup.copyLexInfo(#q); } | + // XQ4: string literal as key selector (?"first value") + str:STRING_LITERAL + { + #lookup = #(#[LOOKUP, "?"], #str); + #lookup.copyLexInfo(#q); + } + | paren:parenthesizedExpr { #lookup = #(#[LOOKUP, "?"], #paren); #lookup.copyLexInfo(#q); } | + // XQ4: variable reference as key selector (?$var) + DOLLAR! varName=v:varName + { + #lookup = #(#[LOOKUP, "?"], #[VARIABLE_REF, varName]); + #lookup.copyLexInfo(#q); + } + | + // XQ4: context item as key selector (?.) + dot:SELF + { + #lookup = #(#[LOOKUP, "?"], #dot); + #lookup.copyLexInfo(#q); + } + | + // XQ4: QName literal as key selector (?#name) + qnl:qnameLiteral + { + #lookup = #(#[LOOKUP, "?"], #qnl); + #lookup.copyLexInfo(#q); + } + | STAR { #lookup = #(#[LOOKUP, "?*"]); @@ -1419,13 +2201,29 @@ primaryExpr throws XPathException | ( "unordered" LCURLY ) => unorderedExpr | + ( + "validate" + ( "lax" | "strict" )? + LCURLY + ) + => validateExpr + | ( LPPAREN | ( "array" LCURLY ) ) => arrayConstructor | ( "map" LCURLY ) => mapConstructor | + ( { xq4Enabled }? LCURLY RCURLY ) => bareMapConstructor + | + ( { xq4Enabled }? LCURLY exprSingle COLON ) => bareMapConstructor + | directConstructor | - ( MOD | "function" LPAREN | eqName HASH ) => functionItemExpr + ( { xq4Enabled }? ( "fn" | "function" ) LCURLY ) => focusFunctionExpr + | + // XQ4: QName literal (#local, #prefix:local, #Q{uri}local) + ( { xq4Enabled }? HASH ) => qnameLiteral + | + ( MOD | ( "fn" | "function" ) LPAREN | eqName HASH ) => functionItemExpr | ( eqName LPAREN ) => functionCall | @@ -1433,6 +2231,8 @@ primaryExpr throws XPathException | ( STRING_CONSTRUCTOR_START ) => stringConstructor | + ( { xq4Enabled }? STRING_TEMPLATE_START ) => stringTemplate + | contextItemExpr | parenthesizedExpr @@ -1459,22 +2259,54 @@ stringConstructorContent throws XPathException stringConstructorInterpolation throws XPathException : STRING_CONSTRUCTOR_INTERPOLATION_START^ - { lexer.inStringConstructor = false; } + { lexer.inStringConstructor = false; lexer.stringConstructorInterpolationDepth++; } ( expr )? STRING_CONSTRUCTOR_INTERPOLATION_END! - { lexer.inStringConstructor = true; } + { lexer.stringConstructorInterpolationDepth--; lexer.inStringConstructor = true; } + ; + +stringTemplate throws XPathException +: + st:STRING_TEMPLATE_START! + { lexer.inStringTemplate = true; } + ( STRING_TEMPLATE_CONTENT | stringTemplateInterpolation )* + STRING_TEMPLATE_END! + { lexer.inStringTemplate = false; } + { + #stringTemplate = #(#[STRING_TEMPLATE, null], #stringTemplate); + #stringTemplate.copyLexInfo(#st); + } + ; + +stringTemplateInterpolation throws XPathException +: + lc:LCURLY! + { lexer.inStringTemplate = false; lexer.stringTemplateDepth++; } + ( expr )? + RCURLY! + { lexer.stringTemplateDepth--; lexer.inStringTemplate = true; } ; mapConstructor throws XPathException : - a:"map"! LCURLY! ( mapAssignment ( COMMA! mapAssignment )* )? RCURLY! + a:"map"! LCURLY! ( mapContentExpr ( COMMA! mapContentExpr )* )? RCURLY! { #mapConstructor = #(#[MAP, "map"], #mapConstructor); #mapConstructor.copyLexInfo(#a); } ; -mapAssignment throws XPathException +bareMapConstructor throws XPathException +: + lc:LCURLY! ( mapContentExpr ( COMMA! mapContentExpr )* )? RCURLY! + { + #bareMapConstructor = #(#[MAP, "map"], #bareMapConstructor); + #bareMapConstructor.copyLexInfo(#lc); + } + ; + +// XQ4: map content expressions - either a key:value entry or a content expression (must evaluate to a map) +mapContentExpr throws XPathException : (exprSingle COLON! EQ!) => exprSingle COLON^ eq:EQ^ exprSingle { @@ -1482,8 +2314,14 @@ mapAssignment throws XPathException "The ':=' notation is no longer accepted in map expressions: use ':' instead."); } | - exprSingle COLON^ exprSingle - ; + (exprSingle COLON) => exprSingle COLON^ exprSingle + | + mc:exprSingle + { + #mapContentExpr = #(#[MAP_CONTENT, null], #mapContentExpr); + #mapContentExpr.copyLexInfo(#mc); + } + ; arrayConstructor throws XPathException : @@ -1510,6 +2348,20 @@ unorderedExpr throws XPathException "unordered"! LCURLY! expr RCURLY! ; +// XQuery 3.1 section 3.18.1 ValidateExpr - eXist does not implement the Schema +// Validation Feature, so we accept the syntax (per W3C grammar) and raise +// XQST0075 at parse time, matching what XQTS expects. +validateExpr throws XPathException +: + v:"validate"! + ( "lax"! | "strict"! )? + LCURLY! expr RCURLY! + { + throw new XPathException(v.getLine(), v.getColumn(), ErrorCodes.XQST0075, + "The eXist-db XQuery implementation does not support the Schema Validation Feature."); + } + ; + varRef throws XPathException { String varName = null; } : @@ -1525,6 +2377,16 @@ literal STRING_LITERAL^ | numericLiteral ; +qnameLiteral throws XPathException +{ String name = null; } +: + h:HASH! name=eqName + { + #qnameLiteral = #(#[QNAME_LITERAL, name]); + #qnameLiteral.copyLexInfo(#h); + } + ; + numericLiteral : DOUBLE_LITERAL^ | DECIMAL_LITERAL^ | INTEGER_LITERAL^ @@ -1539,7 +2401,7 @@ parenthesizedExpr throws XPathException functionItemExpr throws XPathException : - ( MOD | "function" ) => inlineFunctionExpr + ( MOD | "function" | "fn" ) => inlineOrFocusFunctionExpr | namedFunctionRef ; @@ -1553,24 +2415,44 @@ namedFunctionRef throws XPathException } ; -inlineFunctionExpr throws XPathException +inlineOrFocusFunctionExpr throws XPathException : - ann:annotations! "function"! lp:LPAREN! ( paramList )? - RPAREN! ( returnType )? - functionBody + ann:annotations! { - #inlineFunctionExpr = #(#[INLINE_FUNCTION_DECL, null], null, #inlineFunctionExpr); - #inlineFunctionExpr.copyLexInfo(#lp); + // XQuery 3.1 section 3.1.7.1: an inline function expression must not be + // annotated as %public or %private (XQST0125). The parser checks each + // ANNOT_DECL child of the suppressed annotations subtree before the + // root token is dropped. + rejectInlineFunctionPublicPrivate(#ann); } + ( "function"! | "fn"! ) + ( + (LPAREN) => lp:LPAREN! ( paramList )? + RPAREN! ( returnType )? + functionBody + { + #inlineOrFocusFunctionExpr = #(#[INLINE_FUNCTION_DECL, null], #ann, #inlineOrFocusFunctionExpr); + #inlineOrFocusFunctionExpr.copyLexInfo(#lp); + } + | + lc:LCURLY! ( expr )? RCURLY! + { + #inlineOrFocusFunctionExpr = #(#[FOCUS_FUNCTION, null], #inlineOrFocusFunctionExpr); + #inlineOrFocusFunctionExpr.copyLexInfo(#lc); + } + ) exception catch [RecognitionException e] { - if (#lp == null) { - throw new XPathException(e.getLine(), e.getColumn(), ErrorCodes.XPST0003, "Syntax error within inline function: " + e.getMessage()); - } else { - #lp.setLine(e.getLine()); - #lp.setColumn(e.getColumn()); - throw new XPathException(#lp, ErrorCodes.XPST0003, "Syntax error within user defined function: " + e.getMessage()); - } + throw new XPathException(e.getLine(), e.getColumn(), ErrorCodes.XPST0003, "Syntax error within inline function: " + e.getMessage()); + } + ; + +focusFunctionExpr throws XPathException +: + ( "fn"! | "function"! ) lc:LCURLY! ( expr )? RCURLY! + { + #focusFunctionExpr = #(#[FOCUS_FUNCTION, null], #focusFunctionExpr); + #focusFunctionExpr.copyLexInfo(#lc); } ; @@ -1595,8 +2477,50 @@ argumentList throws XPathException argument throws XPathException : - (QUESTION! ( NCNAME | INTEGER_LITERAL | LPAREN | STAR )) => lookup + (QUESTION ( ncnameOrKeyword | INTEGER_LITERAL | DECIMAL_LITERAL | DOUBLE_LITERAL | STRING_LITERAL | LPAREN | DOLLAR | SELF | HASH | STAR )) => unaryLookup | argumentPlaceholder + // XQ4 keyword arg lookahead. Four shapes are possible: bare name (ncname COLON EQ), + // lexer-split prefix:local (ncname COLON ncname COLON EQ), lexer-collapsed prefix:local + // (QNAME COLON EQ), and EQName (BRACED_URI_LITERAL ncname COLON EQ). + | ( { xq4Enabled }? ncnameOrKeyword COLON ( EQ | ncnameOrKeyword COLON EQ ) ) => keywordArgument + | ( { xq4Enabled }? QNAME COLON EQ ) => keywordArgument + | ( { xq4Enabled }? BRACED_URI_LITERAL ncnameOrKeyword COLON EQ ) => keywordArgument + | exprSingle + ; + +// XQ4: keyword arguments - name := value, prefix:name := value, or Q{uri}name := value +keywordArgument throws XPathException +{ String kwName = null; String prefix = null; String local = null; String uri = null; } +: + ( + // EQName keyword: Q{uri}local := value + ( BRACED_URI_LITERAL ncnameOrKeyword COLON EQ ) => + uriLit:BRACED_URI_LITERAL! local=ncnameOrKeyword! COLON! EQ! keywordArgumentValue + { kwName = "{" + uriLit.getText() + "}" + local; } + | + // Lexer-collapsed prefixed keyword: QNAME(prefix:local) := value + ( QNAME COLON EQ ) => + qn:QNAME! COLON! EQ! keywordArgumentValue + { kwName = qn.getText(); } + | + // Lexer-split prefixed keyword: prefix:name := value (when prefix is a keyword, etc.) + ( ncnameOrKeyword COLON ncnameOrKeyword COLON EQ ) => + prefix=ncnameOrKeyword! COLON! local=ncnameOrKeyword! COLON! EQ! keywordArgumentValue + { kwName = prefix + ":" + local; } + | + // Simple keyword: name := value + kwName=ncnameOrKeyword! COLON! EQ! keywordArgumentValue + ) + { + #keywordArgument = #(#[KEYWORD_ARG, kwName], #keywordArgument); + } + ; + +// XQ4: keyword argument value can be an expression or argument placeholder (?) +// Use lookahead to distinguish bare ? (placeholder) from ?key (unary lookup) +keywordArgumentValue throws XPathException +: + ( QUESTION ( RPAREN | COMMA ) ) => argumentPlaceholder | exprSingle ; @@ -1606,8 +2530,12 @@ contextItemExpr : SELF ; kindTest : - textTest | anyKindTest | elementTest | attributeTest | - commentTest | namespaceNodeTest | piTest | documentTest + textTest | anyKindTest | gnodeTest | elementTest | attributeTest | + commentTest | namespaceNodeTest | piTest | documentTest | + // === XQuery 4.0 JNode Kind Tests === + jsonNodeTest | jsonObjectTest | jsonArrayTest | jsonStringTest | + jsonNumberTest | jsonBooleanTest | jsonNullTest | jsonMemberTest | + jnodeTest ; textTest @@ -1620,6 +2548,13 @@ anyKindTest "node"^ LPAREN! RPAREN! ; +// XQ4: gnode() is a synonym for node() +gnodeTest +: + "gnode"! LPAREN! RPAREN! + { #gnodeTest = #[LITERAL_node, "node"]; } + ; + elementTest : "element"^ LPAREN! @@ -1678,12 +2613,87 @@ piTest documentTest : "document-node"^ LPAREN! - ( elementTest | schemaElementTest )? + ( + // XQ4 PR1604: document-node(*) is sugar for document-node(element(*)). + ( STAR ) => STAR + | elementTest + | schemaElementTest + )? RPAREN! ; schemaElementTest : "schema-element"^ LPAREN! eqName RPAREN! ; +// === XQuery 4.0 JNode Kind Tests === + +jsonNodeTest +: + "json-node"! LPAREN! RPAREN! + { #jsonNodeTest = #[JSON_NODE_TEST, "json-node()"]; } + ; + +jsonObjectTest +: + "object-node"! LPAREN! RPAREN! + { #jsonObjectTest = #[JSON_OBJECT_TEST, "object-node()"]; } + ; + +jsonArrayTest +: + "array-node"! LPAREN! RPAREN! + { #jsonArrayTest = #[JSON_ARRAY_TEST, "array-node()"]; } + ; + +jsonStringTest +: + "string-node"! LPAREN! RPAREN! + { #jsonStringTest = #[JSON_STRING_TEST, "string-node()"]; } + ; + +jsonNumberTest +: + "number-node"! LPAREN! RPAREN! + { #jsonNumberTest = #[JSON_NUMBER_TEST, "number-node()"]; } + ; + +jsonBooleanTest +: + "boolean-node"! LPAREN! RPAREN! + { #jsonBooleanTest = #[JSON_BOOLEAN_TEST, "boolean-node()"]; } + ; + +jsonNullTest +: + "null-node"! LPAREN! RPAREN! + { #jsonNullTest = #[JSON_NULL_TEST, "null-node()"]; } + ; + +jsonMemberTest +: + "member-node"! LPAREN! RPAREN! + { #jsonMemberTest = #[JSON_MEMBER_TEST, "member-node()"]; } + ; + +jnodeTest +: + "jnode"! LPAREN! + // Skip balanced parenthesized content (handles nested parens in record(), map(), etc.) + jnodeTestArgs + RPAREN! + { #jnodeTest = #[JSON_NODE_TEST, "json-node()"]; } + ; + +// Helper rule: skip balanced parenthesized content for jnode() arguments +jnodeTestArgs +: + ( options { greedy = true; } : + LPAREN jnodeTestArgs RPAREN + | ~(LPAREN | RPAREN) + )* + ; + +// === End XQuery 4.0 JNode Kind Tests === + qName returns [String name] { name= null; @@ -1828,10 +2838,10 @@ elementWithoutAttributes throws XPathException content:mixedElementContent END_TAG_START! cname=qn:qName! GT! { if (elementStack.isEmpty()) - throw new XPathException(#qn, "found additional closing tag: " + cname); + throw new XPathException(#qn, ErrorCodes.XQST0118, "found additional closing tag: " + cname); String prev= (String) elementStack.pop(); if (!prev.equals(cname)) - throw new XPathException(#qn, "found closing tag: " + cname + "; expected: " + prev); + throw new XPathException(#qn, ErrorCodes.XQST0118, "found closing tag: " + cname + "; expected: " + prev); #elementWithoutAttributes= #(#[ELEMENT, cname], #content); if (!elementStack.isEmpty()) { lexer.inElementContent= true; @@ -1880,10 +2890,10 @@ elementWithAttributes throws XPathException content:mixedElementContent END_TAG_START! cname=qn:qName! GT! { if (elementStack.isEmpty()) - throw new XPathException(#qn, ErrorCodes.XPST0003, "Found closing tag without opening tag: " + cname); + throw new XPathException(#qn, ErrorCodes.XQST0118, "Found closing tag without opening tag: " + cname); String prev= (String) elementStack.pop(); if (!prev.equals(cname)) - throw new XPathException(#qn, ErrorCodes.XPST0003, "Found closing tag: " + cname + "; expected: " + prev); + throw new XPathException(#qn, ErrorCodes.XQST0118, "Found closing tag: " + cname + "; expected: " + prev); #elementWithAttributes= #(#[ELEMENT, cname], #attrs); if (!elementStack.isEmpty()) { lexer.inElementContent= true; @@ -2062,54 +3072,460 @@ attributeEnclosedExpr throws XPathException } ; -/* All of the literals used in this grammar can also be - * part of a valid QName. We thus have to test for each - * of them below. - */ -ncnameOrKeyword returns [String name] -{ name= null; } +// === Full Text (W3C XQuery and XPath Full Text 3.0) === +// Spec: https://www.w3.org/TR/xpath-full-text-30/ + +ftSelection throws XPathException : - n1:NCNAME { name= n1.getText(); } - | - name=reservedKeywords + ftOr + ( + ( "ordered" | "window" | "distance" | "same" | "different" | "entire" | "at" ( "start" | "end" ) ) => + ftPosFilter + )* + { #ftSelection = #(#[FT_SELECTION, "FTSelection"], #ftSelection); } ; -reservedKeywords returns [String name] -{ name= null; } +ftOr throws XPathException +{ boolean hasOr = false; } : - "element" { name = "element"; } + ftAnd ( "ftor"! ftAnd { hasOr = true; } )* + { + if (hasOr) + #ftOr = #(#[FT_OR, "ftor"], #ftOr); + } + ; + +ftAnd throws XPathException +{ boolean hasAnd = false; } +: + ftMildNot ( "ftand"! ftMildNot { hasAnd = true; } )* + { + if (hasAnd) + #ftAnd = #(#[FT_AND, "ftand"], #ftAnd); + } + ; + +ftMildNot throws XPathException +{ boolean hasMildNot = false; } +: + ftUnaryNot ( ( "not" "in" ) => "not"! "in"! ftUnaryNot { hasMildNot = true; } )* + { + if (hasMildNot) + #ftMildNot = #(#[FT_MILD_NOT, "not in"], #ftMildNot); + } + ; + +ftUnaryNot throws XPathException +{ boolean negated = false; } +: + ( "ftnot"! { negated = true; } )? ftPrimaryWithOptions + { + if (negated) + #ftUnaryNot = #(#[FT_UNARY_NOT, "ftnot"], #ftUnaryNot); + } + ; + +ftPrimaryWithOptions throws XPathException +{ boolean hasOptions = false; } +: + ftPrimary + ( ( "using" ) => ftMatchOptions { hasOptions = true; } )? + ( ( "weight" LCURLY ) => ftWeight { hasOptions = true; } )? + { + if (hasOptions) + #ftPrimaryWithOptions = #(#[FT_PRIMARY_WITH_OPTIONS, "FTPrimaryWithOptions"], #ftPrimaryWithOptions); + } + ; + +ftPrimary throws XPathException +: + ftWords | - "to" { name = "to"; } + LPAREN! ftSelection RPAREN! | - "div" { name= "div"; } + ftExtensionSelection + ; + +// XQFT 3.0 §3.4.8: FTExtensionSelection ::= Pragma+ "{" FTSelection? "}" +// Pragmas are parsed but ignored (no FT-specific pragma support). +// If all pragmas are unrecognized and the body is empty, XQST0079 applies. +ftExtensionSelection throws XPathException +{ boolean hasBody = false; } +: + ( pragma )+ LCURLY! ( ftSelection { hasBody = true; } )? RCURLY! + { + #ftExtensionSelection = #(#[FT_EXTENSION_SELECTION, "FTExtensionSelection"], #ftExtensionSelection); + } + ; + +ftWords throws XPathException +: + ftWordsValue ( ftAnyallOption )? ( ( "occurs" ) => ftTimes )? + { #ftWords = #(#[FT_WORDS, "FTWords"], #ftWords); } + ; + +ftWordsValue throws XPathException +: + STRING_LITERAL | - "mod" { name= "mod"; } + LCURLY! expr RCURLY! + ; + +ftAnyallOption +: + ( "any" "word" ) => "any"! "word"! + { #ftAnyallOption = #[FT_ANYALL_OPTION, "any word"]; } | - "text" { name= "text"; } + "any"! + { #ftAnyallOption = #[FT_ANYALL_OPTION, "any"]; } | - "node" { name= "node"; } + ( "all" "words" ) => "all"! "words"! + { #ftAnyallOption = #[FT_ANYALL_OPTION, "all words"]; } | - "or" { name= "or"; } + "all"! + { #ftAnyallOption = #[FT_ANYALL_OPTION, "all"]; } | - "and" { name= "and"; } + "phrase"! + { #ftAnyallOption = #[FT_ANYALL_OPTION, "phrase"]; } + ; + +ftTimes throws XPathException +: + "occurs"! ftRange "times"! + { #ftTimes = #(#[FT_TIMES, "FTTimes"], #ftTimes); } + ; + +ftRange throws XPathException +: + ( "exactly" ) => "exactly"! additiveExpr + { #ftRange = #(#[FT_RANGE, "exactly"], #ftRange); } | - "child" { name= "child"; } + ( "at" "least" ) => "at"! "least"! additiveExpr + { #ftRange = #(#[FT_RANGE, "at least"], #ftRange); } | - "parent" { name= "parent"; } + ( "at" "most" ) => "at"! "most"! additiveExpr + { #ftRange = #(#[FT_RANGE, "at most"], #ftRange); } | - "self" { name= "self"; } + "from"! additiveExpr "to"! additiveExpr + { #ftRange = #(#[FT_RANGE, "from"], #ftRange); } + ; + +ftPosFilter throws XPathException +: + ( "ordered" ) => ftOrder | - "attribute" { name= "attribute"; } + ( "window" ) => ftWindow | - "comment" { name= "comment"; } + ( "distance" ) => ftDistance | - "document" { name= "document"; } + ( "same" ) => ftScope | - "document-node" { name= "document-node"; } + ( "different" ) => ftScope | - "collection" { name= "collection"; } + ( "at" "start" ) => ftContent | - "ancestor" { name= "ancestor"; } + ( "at" "end" ) => ftContent + | + ( "entire" ) => ftContent + ; + +ftOrder +: + "ordered"! + { #ftOrder = #[FT_ORDER, "ordered"]; } + ; + +ftWindow throws XPathException +: + "window"! additiveExpr ftUnit + { #ftWindow = #(#[FT_WINDOW, "window"], #ftWindow); } + ; + +ftDistance throws XPathException +: + "distance"! ftRange ftUnit + { #ftDistance = #(#[FT_DISTANCE, "distance"], #ftDistance); } + ; + +ftScope +: + ( "same" "sentence" ) => "same"! "sentence"! + { #ftScope = #[FT_SCOPE, "same sentence"]; } + | + ( "same" "paragraph" ) => "same"! "paragraph"! + { #ftScope = #[FT_SCOPE, "same paragraph"]; } + | + ( "different" "sentence" ) => "different"! "sentence"! + { #ftScope = #[FT_SCOPE, "different sentence"]; } + | + "different"! "paragraph"! + { #ftScope = #[FT_SCOPE, "different paragraph"]; } + ; + +ftContent +: + ( "at" "start" ) => "at"! "start"! + { #ftContent = #[FT_CONTENT, "at start"]; } + | + ( "at" "end" ) => "at"! "end"! + { #ftContent = #[FT_CONTENT, "at end"]; } + | + "entire"! "content"! + { #ftContent = #[FT_CONTENT, "entire content"]; } + ; + +ftUnit +: + "words" | "sentences" | "paragraphs" + ; + +// === Full Text Option Declaration (prolog) === +// XQFT 3.0 §5.2: declare ft-option using + +ftOptionDecl throws XPathException +: + "declare"! "ft-option"! ftMatchOptions + { #ftOptionDecl = #(#[FT_OPTION_DECL, "ft-option"], #ftOptionDecl); } + ; + +// === Full Text Match Options === + +ftMatchOptions throws XPathException +: + ( "using"! ftMatchOption )+ + ; + +ftMatchOption throws XPathException +: + ( "case" ) => ftCaseOption + | + ( "lowercase" ) => ftCaseOption + | + ( "uppercase" ) => ftCaseOption + | + ( "diacritics" ) => ftDiacriticsOption + | + ( "stemming" ) => ftStemOption + | + ( "no" "stemming" ) => ftStemOption + | + ( "thesaurus" ) => ftThesaurusOption + | + ( "no" "thesaurus" ) => ftThesaurusOption + | + ( "stop" ) => ftStopWordOption + | + ( "no" "stop" ) => ftStopWordOption + | + ( "language" ) => ftLanguageOption + | + ( "wildcards" ) => ftWildCardOption + | + ( "no" "wildcards" ) => ftWildCardOption + | + ftExtensionOption + ; + +ftCaseOption +: + ( "case" "insensitive" ) => "case"! "insensitive"! + { #ftCaseOption = #[FT_CASE_OPTION, "insensitive"]; } + | + ( "case" "sensitive" ) => "case"! "sensitive"! + { #ftCaseOption = #[FT_CASE_OPTION, "sensitive"]; } + | + "lowercase"! + { #ftCaseOption = #[FT_CASE_OPTION, "lowercase"]; } + | + "uppercase"! + { #ftCaseOption = #[FT_CASE_OPTION, "uppercase"]; } + ; + +ftDiacriticsOption +: + ( "diacritics" "insensitive" ) => "diacritics"! "insensitive"! + { #ftDiacriticsOption = #[FT_DIACRITICS_OPTION, "insensitive"]; } + | + "diacritics"! "sensitive"! + { #ftDiacriticsOption = #[FT_DIACRITICS_OPTION, "sensitive"]; } + ; + +ftStemOption +: + "stemming"! + { #ftStemOption = #[FT_STEM_OPTION, "stemming"]; } + | + "no"! "stemming"! + { #ftStemOption = #[FT_STEM_OPTION, "no stemming"]; } + ; + +ftThesaurusOption throws XPathException +: + ( "no" "thesaurus" ) => "no"! "thesaurus"! + { #ftThesaurusOption = #[FT_THESAURUS_OPTION, "no thesaurus"]; } + | + ( "thesaurus" LPAREN ) => "thesaurus"! LPAREN! ftThesaurusIDOrDefault ( COMMA! ftThesaurusID )* RPAREN! + { #ftThesaurusOption = #(#[FT_THESAURUS_OPTION, "thesaurus list"], #ftThesaurusOption); } + | + "thesaurus"! ftThesaurusIDOrDefault + { #ftThesaurusOption = #(#[FT_THESAURUS_OPTION, "thesaurus"], #ftThesaurusOption); } + ; + +ftThesaurusIDOrDefault throws XPathException +: + ( "default" ) => "default"! + { #ftThesaurusIDOrDefault = #[FT_THESAURUS_ID, "default"]; } + | + ftThesaurusID + ; + +ftThesaurusID throws XPathException +: + "at"! STRING_LITERAL ( "relationship"! STRING_LITERAL )? ( ftLiteralRange "levels"! )? + { #ftThesaurusID = #(#[FT_THESAURUS_ID, "at"], #ftThesaurusID); } + ; + +ftLiteralRange +: + ( "exactly" ) => "exactly"! INTEGER_LITERAL + { #ftLiteralRange = #(#[FT_RANGE, "exactly"], #ftLiteralRange); } + | + ( "at" "least" ) => "at"! "least"! INTEGER_LITERAL + { #ftLiteralRange = #(#[FT_RANGE, "at least"], #ftLiteralRange); } + | + ( "at" "most" ) => "at"! "most"! INTEGER_LITERAL + { #ftLiteralRange = #(#[FT_RANGE, "at most"], #ftLiteralRange); } + | + "from"! INTEGER_LITERAL "to"! INTEGER_LITERAL + { #ftLiteralRange = #(#[FT_RANGE, "from"], #ftLiteralRange); } + ; + +ftStopWordOption throws XPathException +: + ( "no" "stop" ) => "no"! "stop"! "words"! + { #ftStopWordOption = #[FT_STOP_WORD_OPTION, "no stop words"]; } + | + ( "stop" "words" "default" ) => "stop"! "words"! "default"! ( ftStopWordsInclExcl )* + { #ftStopWordOption = #(#[FT_STOP_WORD_OPTION, "stop words default"], #ftStopWordOption); } + | + "stop"! "words"! ftStopWords ( ftStopWordsInclExcl )* + { #ftStopWordOption = #(#[FT_STOP_WORD_OPTION, "stop words"], #ftStopWordOption); } + ; + +ftStopWords +: + ( "at" ) => "at"! STRING_LITERAL + { #ftStopWords = #(#[FT_STOP_WORDS, "at"], #ftStopWords); } + | + LPAREN! STRING_LITERAL ( COMMA! STRING_LITERAL )* RPAREN! + { #ftStopWords = #(#[FT_STOP_WORDS, "list"], #ftStopWords); } + ; + +ftStopWordsInclExcl +: + "union"! ftStopWords + | + "except"! ftStopWords + { #ftStopWordsInclExcl = #(#[FT_STOP_WORDS_EXCEPT, "except"], #ftStopWordsInclExcl); } + ; + +ftLanguageOption +: + "language"! STRING_LITERAL + { #ftLanguageOption = #(#[FT_LANGUAGE_OPTION, "language"], #ftLanguageOption); } + ; + +ftWildCardOption +: + "wildcards"! + { #ftWildCardOption = #[FT_WILDCARD_OPTION, "wildcards"]; } + | + "no"! "wildcards"! + { #ftWildCardOption = #[FT_WILDCARD_OPTION, "no wildcards"]; } + ; + +ftExtensionOption throws XPathException +{ String name; } +: + "option"! name=eqName STRING_LITERAL + { #ftExtensionOption = #(#[FT_EXTENSION_OPTION, name], #ftExtensionOption); } + ; + +ftWeight throws XPathException +: + "weight"! LCURLY! expr RCURLY! + { #ftWeight = #(#[FT_WEIGHT, "weight"], #ftWeight); } + ; + +ftIgnoreOption throws XPathException +: + "without"! "content"! unionExpr + { #ftIgnoreOption = #(#[FT_IGNORE_OPTION, "without content"], #ftIgnoreOption); } + ; + +/* All of the literals used in this grammar can also be + * part of a valid QName. We thus have to test for each + * of them below. + */ +ncnameOrKeyword returns [String name] +{ name= null; } +: + n1:NCNAME { name= n1.getText(); } + | + name=reservedKeywords + ; + +/** + * Top-level dispatcher for reserved keywords usable as NCNames. + * Split into feature-area sub-rules to reduce merge conflicts on the + * next integration branch. Each feature branch owns its sub-rule; + * merging adds a single alternative here instead of interleaving 80+ lines. + */ +reservedKeywords returns [String name] +{ name= null; } +: + name=coreReservedKeywords + | + name=xq4Keywords + ; + +// ---- Core reserved keywords (XQuery 3.1 + eXist-db extensions) ---- +coreReservedKeywords returns [String name] +{ name= null; } +: + "element" { name = "element"; } + | + "to" { name = "to"; } + | + "div" { name= "div"; } + | + "mod" { name= "mod"; } + | + "text" { name= "text"; } + | + "node" { name= "node"; } + | + "or" { name= "or"; } + | + "and" { name= "and"; } + | + "child" { name= "child"; } + | + "parent" { name= "parent"; } + | + "self" { name= "self"; } + | + "attribute" { name= "attribute"; } + | + "comment" { name= "comment"; } + | + "document" { name= "document"; } + | + "document-node" { name= "document-node"; } + | + "collection" { name= "collection"; } + | + "ancestor" { name= "ancestor"; } | "descendant" { name= "descendant"; } | @@ -2117,14 +3533,30 @@ reservedKeywords returns [String name] | "ancestor-or-self" { name= "ancestor-or-self"; } | + "preceding-sibling-or-self" { name= "preceding-sibling-or-self"; } + | "preceding-sibling" { name= "preceding-sibling"; } | + "following-sibling-or-self" { name= "following-sibling-or-self"; } + | "following-sibling" { name= "following-sibling"; } | + "following-or-self" { name = "following-or-self"; } + | "following" { name = "following"; } | + "preceding-or-self" { name = "preceding-or-self"; } + | "preceding" { name = "preceding"; } | + "following-or-self" { name = "following-or-self"; } + | + "preceding-or-self" { name = "preceding-or-self"; } + | + "following-sibling-or-self" { name = "following-sibling-or-self"; } + | + "preceding-sibling-or-self" { name = "preceding-sibling-or-self"; } + | "item" { name= "item"; } | "empty" { name= "empty"; } @@ -2137,8 +3569,8 @@ reservedKeywords returns [String name] | "namespace-node" { name= "namespace-node"; } | - "namespace" { name= "namespace"; } - | + "namespace" { name= "namespace"; } + | "if" { name= "if"; } | "then" { name= "then"; } @@ -2177,8 +3609,8 @@ reservedKeywords returns [String name] | "by" { name = "by"; } | - "group" { name = "group"; } - | + "group" { name = "group"; } + | "some" { name = "some"; } | "every" { name = "every"; } @@ -2229,8 +3661,12 @@ reservedKeywords returns [String name] | "base-uri" { name = "base-uri"; } | + // Legacy update keyword (DEPRECATED - only "update" is legacy-only; + // the others below are shared with W3C XQUF 3.0). + // To remove: delete "update" and keep the rest. "update" { name = "update"; } | + // Shared by legacy update and W3C XQUF 3.0 "replace" { name = "replace"; } | "delete" { name = "delete"; } @@ -2251,6 +3687,10 @@ reservedKeywords returns [String name] | "validate" { name = "validate"; } | + "lax" { name = "lax"; } + | + "strict" { name = "strict"; } + | "schema" { name = "schema"; } | "treat" { name = "treat"; } @@ -2289,7 +3729,7 @@ reservedKeywords returns [String name] | "tumbling" { name = "tumbling"; } | - "sliding" { name = "sliding"; } + "sliding" { name = "sliding"; } | "window" { name = "window"; } | @@ -2304,6 +3744,182 @@ reservedKeywords returns [String name] "next" { name = "next"; } | "when" { name = "when"; } + | + // W3C XQuery Update Facility 3.0 keywords + "copy" { name = "copy"; } + | + "modify" { name = "modify"; } + | + "nodes" { name = "nodes"; } + | + "before" { name = "before"; } + | + "after" { name = "after"; } + | + "first" { name = "first"; } + | + "last" { name = "last"; } + | + "updating" { name = "updating"; } + | + "ascending" { name = "ascending"; } + | + "descending" { name = "descending"; } + | + "greatest" { name = "greatest"; } + | + "least" { name = "least"; } + | + "satisfies" { name = "satisfies"; } + | + "schema-attribute" { name = "schema-attribute"; } + | + "revalidation" { name = "revalidation"; } + | + "skip" { name = "skip"; } + | + "strict" { name = "strict"; } + | + "lax" { name = "lax"; } + | + "castable" { name = "castable"; } + | + "idiv" { name = "idiv"; } + | + "processing-instruction" { name = "processing-instruction"; } + | + // Full Text keywords + "contains" { name = "contains"; } + | + "score" { name = "score"; } + | + "content" { name = "content"; } + | + "ftor" { name = "ftor"; } + | + "ftand" { name = "ftand"; } + | + "ftnot" { name = "ftnot"; } + | + "stemming" { name = "stemming"; } + | + "thesaurus" { name = "thesaurus"; } + | + "diacritics" { name = "diacritics"; } + | + "sensitive" { name = "sensitive"; } + | + "insensitive" { name = "insensitive"; } + | + "language" { name = "language"; } + | + "wildcards" { name = "wildcards"; } + | + "lowercase" { name = "lowercase"; } + | + "uppercase" { name = "uppercase"; } + | + "distance" { name = "distance"; } + | + "entire" { name = "entire"; } + | + "words" { name = "words"; } + | + "sentences" { name = "sentences"; } + | + "paragraphs" { name = "paragraphs"; } + | + "sentence" { name = "sentence"; } + | + "paragraph" { name = "paragraph"; } + | + "occurs" { name = "occurs"; } + | + "times" { name = "times"; } + | + "weight" { name = "weight"; } + | + "without" { name = "without"; } + | + "same" { name = "same"; } + | + "different" { name = "different"; } + | + "relationship" { name = "relationship"; } + | + "levels" { name = "levels"; } + | + "stop" { name = "stop"; } + | + "most" { name = "most"; } + | + "exactly" { name = "exactly"; } + | + "no" { name = "no"; } + | + "not" { name = "not"; } + | + "all" { name = "all"; } + | + "any" { name = "any"; } + | + "word" { name = "word"; } + | + "phrase" { name = "phrase"; } + | + "using" { name = "using"; } + | + "from" { name = "from"; } + | + "allowing" { name = "allowing"; } + | + // Decimal format property keywords + "decimal-format" { name = "decimal-format"; } + | + "decimal-separator" { name = "decimal-separator"; } + | + "grouping-separator" { name = "grouping-separator"; } + | + "infinity" { name = "infinity"; } + | + "minus-sign" { name = "minus-sign"; } + | + "NaN" { name = "NaN"; } + | + "percent" { name = "percent"; } + | + "per-mille" { name = "per-mille"; } + | + "zero-digit" { name = "zero-digit"; } + | + "digit" { name = "digit"; } + | + "pattern-separator" { name = "pattern-separator"; } + | + "exponent-separator" { name = "exponent-separator"; } + ; + +// ---- XQuery 4.0 keywords (feature/xquery-4.0-parser) ---- +xq4Keywords returns [String name] +{ name= null; } +: + "fn" { name = "fn"; } + | + "member" { name = "member"; } + | + "otherwise" { name = "otherwise"; } + | + "key" { name = "key"; } + | + "while" { name = "while"; } + | + "finally" { name = "finally"; } + | + "record" { name = "record"; } + | + "gnode" { name = "gnode"; } + | + "get" { name = "get"; } ; @@ -2324,6 +3940,9 @@ options { protected boolean wsExplicit= false; protected boolean parseStringLiterals= true; protected boolean inStringConstructor = false; + protected boolean inStringTemplate = false; + protected int stringTemplateDepth = 0; + protected int stringConstructorInterpolationDepth = 0; protected boolean inElementContent= false; protected boolean inAttributeContent= false; protected boolean inFunctionBody= false; @@ -2352,11 +3971,133 @@ options { newline(); } } + + /** + * Override the inherited keyword-table lookup with two fast paths: + * + * (1) Shape filter. Every XQuery / XQUF / XQFT keyword is composed + * entirely of lowercase ASCII letters (optionally separated by + * ASCII hyphens) and is between 2 and 25 characters long. Any + * NCNAME containing an uppercase letter, digit, underscore, or + * any other character cannot possibly appear in the keyword + * table, so we skip the lookup outright. + * + * (2) HashMap lookup. The default antlr.CharScanner implementation + * allocates an ANTLRHashString wrapper on every call and looks + * it up in a synchronized Hashtable. We mirror the keyword + * table into an unsynchronized HashMap{@code } + * on first use, then resolve hits with one map.get(text) call. + * + * Both paths preserve the existing semantics: a successful match + * returns the same token type the inherited code would have returned; + * a miss returns the supplied default ttype unchanged. If, for any + * reason, the cache cannot be built (e.g. reflection denied by the + * security manager), the lexer transparently falls back to the + * inherited Hashtable lookup. + */ + @Override + public int testLiteralsTable(final String text, final int ttype) { + if (LEGACY_LITERAL_LOOKUP) { + return super.testLiteralsTable(text, ttype); + } + final int len = text.length(); + if (len < KW_MIN_LEN || len > KW_MAX_LEN) { + return ttype; + } + for (int i = 0; i < len; i++) { + final char c = text.charAt(i); + if ((c < 'a' || c > 'z') && c != '-') { + return ttype; + } + } + java.util.Map cache = literalsCache; + if (cache == null) { + cache = ensureLiteralsCache(); + } + if (cache == FAILED_CACHE) { + return super.testLiteralsTable(text, ttype); + } + final Integer t = cache.get(text); + return (t != null) ? t.intValue() : ttype; + } + + // Escape hatch: -Dexist.xquery.lexer.legacyLiterals=true reverts the + // keyword-table lookup to the inherited synchronized Hashtable path. + // Useful for A/B comparisons and as a last-resort safety valve. + private static final boolean LEGACY_LITERAL_LOOKUP = + Boolean.getBoolean("exist.xquery.lexer.legacyLiterals"); + + private static volatile java.util.Map literalsCache; + private static final java.util.Map FAILED_CACHE = + java.util.Collections.emptyMap(); + + private java.util.Map ensureLiteralsCache() { + java.util.Map cache = literalsCache; + if (cache != null) { + return cache; + } + synchronized (XQueryLexer.class) { + cache = literalsCache; + if (cache != null) { + return cache; + } + try { + final java.lang.reflect.Field f = + antlr.ANTLRHashString.class.getDeclaredField("s"); + f.setAccessible(true); + final java.util.HashMap built = + new java.util.HashMap(literals.size() * 2); + final java.util.Enumeration e = literals.keys(); + while (e.hasMoreElements()) { + final Object key = e.nextElement(); + final String s = (String) f.get(key); + if (s != null) { + built.put(s, (Integer) literals.get(key)); + } + } + cache = built; + } catch (final Throwable any) { + cache = FAILED_CACHE; + } + literalsCache = cache; + return cache; + } + } + + // Bounds derived from the generated keyword table: shortest keywords + // ("as", "at", "by", "do", "fn", "if", "in", "is", "lt", "ne", "of", + // "or", "to") are 2 chars; longest ("preceding-sibling-or-self") is 25. + private static final int KW_MIN_LEN = 2; + private static final int KW_MAX_LEN = 25; + + /** + * Disambiguate (# as pragma vs ( + #QName literal. + * Scans past (# and the QName. Returns true (pragma) if the QName + * is followed by whitespace or #). Returns false (QName literal) + * if followed by , or ). + */ + private boolean isPragmaContext() throws CharStreamException { + // LA(1)='(' LA(2)='#' -- start scanning from LA(3) + int i = 3; + // Skip the QName (letters, digits, -, ., _, :) + while (Character.isLetterOrDigit(LA(i)) || LA(i) == '-' || LA(i) == '.' || LA(i) == '_' || LA(i) == ':') { + i++; + } + char afterQName = LA(i); + // If followed by , or ) it's a QName literal argument + if (afterQName == ',' || afterQName == ')') { + return false; + } + // Otherwise it's a pragma (whitespace, #), or other pragma content) + return true; + } } protected SLASH options { paraphrase="single slash '/'"; }: '/' ; protected DSLASH options { paraphrase="double slash '//'"; }: '/' '/' ; protected BANG : '!' ; +protected DOUBLE_BANG options { paraphrase="double bang '!!'"; }: '!' '!' ; +protected DOUBLE_QUESTION options { paraphrase="double question '??'"; }: '?' '?' ; protected MOD : '%' ; protected COLON : ':' ; protected COMMA : ',' ; @@ -2374,7 +4115,10 @@ protected SELF options { paraphrase="."; }: '.' ; protected PARENT options { paraphrase=".."; }: ".." ; protected UNION options { paraphrase="union"; }: '|' ; protected CONCAT options { paraphrase="||"; }: '|' '|'; +protected METHOD_CALL_OP options { paraphrase="method call operator"; }: '=' '?' '>'; +protected MAPPING_ARROW_OP options { paraphrase="mapping arrow operator"; }: '=' '!' '>'; protected ARROW_OP options { paraphrase="arrow operator"; }: '=' '>'; +protected PIPELINE_OP options { paraphrase="pipeline operator"; }: '-' '>'; protected AT options { paraphrase="@ char"; }: '@' ; protected DOLLAR options { paraphrase="dollar sign '$'"; }: '$' ; protected EQ options { paraphrase="="; }: '=' ; @@ -2408,12 +4152,17 @@ protected LETTER protected DIGITS : - ( DIGIT )+ + ( DIGIT )+ ( '_' ( DIGIT )+ )* ; protected HEX_DIGITS : - ( '0'..'9' | 'a'..'f' | 'A'..'F' )+ + ( '0'..'9' | 'a'..'f' | 'A'..'F' )+ ( '_' ( '0'..'9' | 'a'..'f' | 'A'..'F' )+ )* + ; + +protected BINARY_DIGITS +: + ( '0' | '1' )+ ( '_' ( '0' | '1' )+ )* ; protected NCNAME @@ -2467,19 +4216,36 @@ options { protected INTEGER_LITERAL : - { !(inElementContent || inAttributeContent) }? DIGITS + { !(inElementContent || inAttributeContent) }? + ( + // XQuery 4.0 numeric literal extensions: hex (0x...) and binary (0b...) + // prefixes, plus '_' digit separators between digits. + ( '0' ('x' | 'X') ) => '0' ('x' | 'X') HEX_DIGITS ( '_' HEX_DIGITS )* + | ( '0' ('b' | 'B') ) => '0' ('b' | 'B') ('0' | '1')+ ( '_' ('0' | '1')+ )* + | DIGITS ( '_' DIGITS )* + ) + ; + +protected HEX_INTEGER_LITERAL +: + { !(inElementContent || inAttributeContent) }? '0' ('x' | 'X') HEX_DIGITS + ; + +protected BINARY_INTEGER_LITERAL +: + { !(inElementContent || inAttributeContent) }? '0' ('b' | 'B') BINARY_DIGITS ; protected DOUBLE_LITERAL : { !(inElementContent || inAttributeContent) }? - ( ( '.' DIGITS ) | ( DIGITS ( '.' ( DIGIT )* )? ) ) ( 'e' | 'E' ) ( '+' | '-' )? DIGITS + ( ( '.' DIGITS ) | ( DIGITS ( '.' ( DIGITS )? )? ) ) ( 'e' | 'E' ) ( '+' | '-' )? DIGITS ; protected DECIMAL_LITERAL : { !(inElementContent || inAttributeContent) }? - ( '.' DIGITS ) | ( DIGITS ( '.' ( DIGIT )* )? ) + ( '.' DIGITS ) | ( DIGITS ( '.' ( DIGITS )? )? ) ; protected PREDEFINED_ENTITY_REF @@ -2520,7 +4286,6 @@ options { : ( ( '\n' ) => '\n' { newline(); } | - ( '&' ) => ( PREDEFINED_ENTITY_REF | CHAR_REF ) | ( ( ']' '`' ) ~ ( '`' ) ) => ( ']' '`' ) | ( ']' ~ ( '`' ) ) => ']' | ( '`' ~ ( '{') ) => '`' | @@ -2528,6 +4293,21 @@ options { )+ ; +protected STRING_TEMPLATE_START options { paraphrase="start of string template"; }: '`'; +protected STRING_TEMPLATE_END options { paraphrase="end of string template"; }: '`'; + +protected STRING_TEMPLATE_CONTENT +options { + testLiterals = false; + paraphrase = "string template content"; +} +: + ( + '\n' { newline(); } | + ~ ( '\n' | '{' | '}' | '`') + )+ + ; + protected BRACED_URI_LITERAL options { paraphrase="braced uri literal"; @@ -2641,6 +4421,46 @@ options { testLiterals = false; } : + { inStringTemplate }? + ( '`' '`' ) => '`' '`' { + $setType(STRING_TEMPLATE_CONTENT); + } + | + { inStringTemplate }? + ( '{' '{' ) => '{' '{' { + $setType(STRING_TEMPLATE_CONTENT); + } + | + { inStringTemplate }? + ( '}' '}' ) => '}' '}' { + $setType(STRING_TEMPLATE_CONTENT); + } + | + { inStringTemplate }? + STRING_TEMPLATE_END { + $setType(STRING_TEMPLATE_END); + } + | + { inStringTemplate }? + LCURLY { + $setType(LCURLY); + } + | + { inStringTemplate }? + STRING_TEMPLATE_CONTENT { + $setType(STRING_TEMPLATE_CONTENT); + } + | + { !inStringConstructor && !inStringTemplate }? + ( '`' '`' '[' ) => STRING_CONSTRUCTOR_START { + $setType(STRING_CONSTRUCTOR_START); + } + | + { !inStringConstructor && !inStringTemplate }? + STRING_TEMPLATE_START { + $setType(STRING_TEMPLATE_START); + } + | { !inStringConstructor }? STRING_CONSTRUCTOR_START { $setType(STRING_CONSTRUCTOR_START); @@ -2656,7 +4476,7 @@ options { $setType(STRING_CONSTRUCTOR_INTERPOLATION_START); } | - { !inStringConstructor }? + { !inStringConstructor && stringTemplateDepth == 0 && stringConstructorInterpolationDepth > 0 }? STRING_CONSTRUCTOR_INTERPOLATION_END { $setType(STRING_CONSTRUCTOR_INTERPOLATION_END); } @@ -2777,7 +4597,7 @@ options { ( NAME_START_CHAR ) => ncname:NCNAME { $setType(ncname.getType()); } | - { parseStringLiterals && !inElementContent && !inStringConstructor }? + { parseStringLiterals && !inElementContent && !inStringConstructor && !inStringTemplate }? STRING_LITERAL { $setType(STRING_LITERAL); } | BRACED_URI_LITERAL { $setType(BRACED_URI_LITERAL); } @@ -2801,7 +4621,15 @@ options { ( '.' ) => SELF { $setType(SELF); } | - ( INTEGER_LITERAL ( '.' ( INTEGER_LITERAL )? )? ( 'e' | 'E' ) ) + // XQ4: hex integer literals (0xFF, 0xCAFE_BABE) + ( '0' ('x' | 'X') ) + => HEX_INTEGER_LITERAL { $setType(INTEGER_LITERAL); } + | + // XQ4: binary integer literals (0b1010, 0b1111_0000) + ( '0' ('b' | 'B') ) + => BINARY_INTEGER_LITERAL { $setType(INTEGER_LITERAL); } + | + ( INTEGER_LITERAL ( '.' ( DIGITS )? )? ( 'e' | 'E' ) ) => DOUBLE_LITERAL { $setType(DOUBLE_LITERAL); } | @@ -2816,6 +4644,8 @@ options { { !(inAttributeContent || inElementContent) }? DSLASH { $setType(DSLASH); } | + ( DOUBLE_BANG ) => DOUBLE_BANG { $setType(DOUBLE_BANG); } + | BANG { $setType(BANG); } | COLON { $setType(COLON); } @@ -2828,10 +4658,17 @@ options { | STAR { $setType(STAR); } | + // XQ4: Unicode multiplication sign (U+00D7) as alternative to * + '\u00D7' { $setType(STAR); } + | + ( DOUBLE_QUESTION ) => DOUBLE_QUESTION { $setType(DOUBLE_QUESTION); } + | QUESTION { $setType(QUESTION); } | PLUS { $setType(PLUS); } | + ( PIPELINE_OP ) => PIPELINE_OP { $setType(PIPELINE_OP); } + | MINUS { $setType(MINUS); } | LPPAREN { $setType(LPPAREN); } @@ -2846,6 +4683,10 @@ options { | DOLLAR { $setType(DOLLAR); } | + ( METHOD_CALL_OP ) => METHOD_CALL_OP { $setType(METHOD_CALL_OP); } + | + ( MAPPING_ARROW_OP ) => MAPPING_ARROW_OP { $setType(MAPPING_ARROW_OP); } + | ARROW_OP { $setType(ARROW_OP); } | EQ { $setType(EQ); } @@ -2863,6 +4704,7 @@ options { | XML_CDATA_END { $setType(XML_CDATA_END); } | + { LA(1) == '(' && LA(2) == '#' && isPragmaContext() }? PRAGMA_START { $setType(PRAGMA_START); diff --git a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g index 3109d357158..3b172fc959e 100644 --- a/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g +++ b/exist-core/src/main/antlr/org/exist/xquery/parser/XQueryTree.g @@ -53,9 +53,11 @@ header { import org.exist.xquery.value.*; import org.exist.xquery.functions.fn.*; import org.exist.xquery.update.*; + import org.exist.xquery.xquf.*; import org.exist.storage.ElementValue; import org.exist.xquery.functions.map.MapExpr; import org.exist.xquery.functions.array.ArrayConstructor; + import org.exist.xquery.ft.*; import static org.apache.commons.lang3.ArrayUtils.isNotEmpty; } @@ -131,6 +133,8 @@ options { QName varName; SequenceType sequenceType= null; QName posVar = null; + QName scoreVar = null; + boolean isScoreBinding = false; Expression inputSequence; Expression action; FLWORClause.ClauseType type = FLWORClause.ClauseType.FOR; @@ -139,6 +143,11 @@ options { List windowConditions = null; WindowExpr.WindowType windowType = null; boolean allowEmpty = false; + QName valueVarName = null; + SequenceType valueSequenceType = null; + // XQ4 destructuring + List destructureVarNames = null; + List destructureVarTypes = null; } /** @@ -148,16 +157,100 @@ options { String ns = qname.getNamespaceURI(); if (ns.equals(Namespaces.XPATH_FUNCTIONS_NS)) { String ln = qname.getLocalPart(); - return ("private".equals(ln) || "public".equals(ln)); + return ("private".equals(ln) || "public".equals(ln) || "updating".equals(ln)); } else { return !(ns.equals(Namespaces.XML_NS) || ns.equals(Namespaces.SCHEMA_NS) || ns.equals(Namespaces.SCHEMA_INSTANCE_NS) || ns.equals(Namespaces.XPATH_FUNCTIONS_MATH_NS) - || ns.equals(Namespaces.XQUERY_OPTIONS_NS)); + || ns.equals(Namespaces.XPATH_FUNCTIONS_MAP_NS) + || ns.equals(Namespaces.XPATH_FUNCTIONS_ARRAY_NS) + || ns.equals(Namespaces.XQUERY_OPTIONS_NS) + || ns.equals(Namespaces.XQUERY_NS)); } } + /** The XQuery annotation namespace (http://www.w3.org/2012/xquery) */ + private static final String XQUERY_ANNOTATION_NS = "http://www.w3.org/2012/xquery"; + + /** Check if a QName refers to a %public or %private visibility annotation */ + private static boolean isVisibilityAnnotation(QName qn) { + String ns = qn.getNamespaceURI(); + String ln = qn.getLocalPart(); + return ("public".equals(ln) || "private".equals(ln)) + && (Namespaces.XPATH_FUNCTIONS_NS.equals(ns) || XQUERY_ANNOTATION_NS.equals(ns)); + } + + /** + * Check for duplicate or conflicting %public/%private annotations. + * @param annots the parsed annotation list + * @param errorCode XQST0106 for functions, XQST0116 for variables + * @param declType "function" or "variable" for error messages + * @param ast the AST node for error location reporting + */ + private static void checkVisibilityAnnotations(List annots, ErrorCodes.ErrorCode errorCode, String declType, XQueryAST ast) + throws XPathException { + int publicCount = 0; + int privateCount = 0; + for (int i = 0; i < annots.size(); i++) { + List la = (List) annots.get(i); + QName qn = (QName) la.get(0); + if (isVisibilityAnnotation(qn)) { + if ("public".equals(qn.getLocalPart())) { + publicCount++; + } else if ("private".equals(qn.getLocalPart())) { + privateCount++; + } + } + } + if (publicCount + privateCount > 1) { + throw new XPathException(ast, errorCode, + "A " + declType + " declaration must not contain more than one " + + "%public or %private annotation, and must not contain both."); + } + } + + /** + * Check if any annotation in the list is %private. + */ + private static boolean hasPrivateAnnotation(List annots) { + for (int i = 0; i < annots.size(); i++) { + List la = (List) annots.get(i); + QName qn = (QName) la.get(0); + if (isVisibilityAnnotation(qn) + && "private".equals(qn.getLocalPart())) { + return true; + } + } + return false; + } + + private static void checkInlineFunctionAnnotations(List annots, AST astNode) throws XPathException { + // XQuery 3.1 section 3.1.7.1: an inline function expression must not be + // annotated as %public or %private. The reserved annotation names live in + // the default function namespace; we also accept the bare local part to + // remain robust against differences in default function namespace + // resolution between top-level modules and util:eval scopes. + for (Object o : annots) { + List la = (List) o; + QName qn = (QName) la.get(0); + final String local = qn.getLocalPart(); + if (("public".equals(local) || "private".equals(local)) + && annotationInDefaultFunctionNamespace(qn)) { + throw new XPathException(astNode.getLine(), astNode.getColumn(), + ErrorCodes.XQST0125, + "Inline function expressions must not be annotated as %" + local + "."); + } + } + } + + private static boolean annotationInDefaultFunctionNamespace(QName qn) { + final String ns = qn.getNamespaceURI(); + return ns == null + || ns.isEmpty() + || Namespaces.XPATH_FUNCTIONS_NS.equals(ns); + } + private static void processAnnotations(List annots, FunctionSignature signature) { Annotation[] anns = new Annotation[annots.size()]; @@ -185,6 +278,15 @@ options { //set the Annotations on the Function Signature signature.setAnnotations(anns); + + // W3C XQuery Update Facility 3.0: %updating annotation + for (Annotation a : anns) { + if ("updating".equals(a.getName().getLocalPart()) + && Namespaces.XPATH_FUNCTIONS_NS.equals(a.getName().getNamespaceURI())) { + signature.setUpdating(true); + break; + } + } } private static void processParams(List varList, UserDefinedFunction func, FunctionSignature signature) @@ -210,6 +312,122 @@ options { return variableName; } } + + private static String dfRequireSingleChar(final AST node, final String propName, final String value) throws XPathException { + if (value.codePointCount(0, value.length()) != 1) { + throw new XPathException(node.getLine(), node.getColumn(), ErrorCodes.XQST0098, + "The value of decimal-format property '" + propName + "' must be a single character, but got: \"" + value + "\""); + } + return value; + } + + private static void dfValidateZeroDigit(final AST node, final String value) throws XPathException { + final int cp = value.codePointAt(0); + if (Character.getType(cp) != Character.DECIMAL_DIGIT_NUMBER || Character.getNumericValue(cp) != 0) { + throw new XPathException(node.getLine(), node.getColumn(), ErrorCodes.XQST0098, + "The value of decimal-format property 'zero-digit' must be a Unicode digit with numeric value zero, but got: \"" + value + "\""); + } + } + + private static void dfValidateDistinctPictureChars(final AST node, final DecimalFormat df) throws XPathException { + // The 8 single-character picture-string properties must all have distinct values + final int[] chars = { df.decimalSeparator, df.groupingSeparator, df.percent, df.perMille, + df.zeroDigit, df.digit, df.patternSeparator, df.exponentSeparator }; + final String[] names = { "decimal-separator", "grouping-separator", "percent", "per-mille", + "zero-digit", "digit", "pattern-separator", "exponent-separator" }; + for (int i = 0; i < chars.length; i++) { + for (int j = i + 1; j < chars.length; j++) { + if (chars[i] == chars[j]) { + throw new XPathException(node.getLine(), node.getColumn(), ErrorCodes.XQST0098, + "Decimal-format properties '" + names[i] + "' and '" + names[j] + + "' must have distinct values, but both are: '" + new String(Character.toChars(chars[i])) + "'"); + } + } + } + } + + private DecimalFormat processDecimalFormatProperties(final AST parentNode) throws XPathException { + // Start with UNNAMED defaults + int decimalSeparator = DecimalFormat.UNNAMED.decimalSeparator; + int exponentSeparator = DecimalFormat.UNNAMED.exponentSeparator; + int groupingSeparator = DecimalFormat.UNNAMED.groupingSeparator; + int percent = DecimalFormat.UNNAMED.percent; + int perMille = DecimalFormat.UNNAMED.perMille; + int zeroDigit = DecimalFormat.UNNAMED.zeroDigit; + int digit = DecimalFormat.UNNAMED.digit; + int patternSeparator = DecimalFormat.UNNAMED.patternSeparator; + String infinity = DecimalFormat.UNNAMED.infinity; + String nan = DecimalFormat.UNNAMED.NaN; + String minusSign = DecimalFormat.UNNAMED.minusSign; + + AST child = parentNode.getFirstChild(); + while (child != null) { + final String propName = child.getText(); + final AST valueNode = child.getFirstChild(); + if (valueNode == null) { + child = child.getNextSibling(); + continue; + } + final String value = valueNode.getText(); + + switch (propName) { + case "decimal-separator": + dfRequireSingleChar(child, propName, value); + decimalSeparator = value.codePointAt(0); + break; + case "grouping-separator": + dfRequireSingleChar(child, propName, value); + groupingSeparator = value.codePointAt(0); + break; + case "infinity": + infinity = value; + break; + case "minus-sign": + // XPath 4.0: minus-sign is a string (rendition) -- multi-character allowed + minusSign = value; + break; + case "NaN": + nan = value; + break; + case "percent": + dfRequireSingleChar(child, propName, value); + percent = value.codePointAt(0); + break; + case "per-mille": + dfRequireSingleChar(child, propName, value); + perMille = value.codePointAt(0); + break; + case "zero-digit": + dfRequireSingleChar(child, propName, value); + dfValidateZeroDigit(child, value); + zeroDigit = value.codePointAt(0); + break; + case "digit": + dfRequireSingleChar(child, propName, value); + digit = value.codePointAt(0); + break; + case "pattern-separator": + dfRequireSingleChar(child, propName, value); + patternSeparator = value.codePointAt(0); + break; + case "exponent-separator": + dfRequireSingleChar(child, propName, value); + exponentSeparator = value.codePointAt(0); + break; + default: + break; + } + child = child.getNextSibling(); + } + + final DecimalFormat df = new DecimalFormat( + decimalSeparator, exponentSeparator, groupingSeparator, + percent, perMille, zeroDigit, digit, + patternSeparator, infinity, nan, minusSign + ); + dfValidateDistinctPictureChars(parentNode, df); + return df; + } } xpointer [PathExpr path] @@ -267,14 +485,24 @@ throws PermissionDeniedException, EXistException, XPathException v:VERSION_DECL { final String version = v.getText(); - if (version.equals("3.1")) { + if (version.equals("4.0")) { + if (!"true".equals(System.getProperty("exist.xquery4.enabled", "true"))) { + throw new XPathException(v, ErrorCodes.XPST0003, + "XQuery 4.0 is not enabled. Set system property exist.xquery4.enabled=true to enable."); + } + context.setXQueryVersion(40); + staticContext.setXQueryVersion(40); + } else if (version.equals("3.1")) { context.setXQueryVersion(31); + staticContext.setXQueryVersion(31); } else if (version.equals("3.0")) { context.setXQueryVersion(30); + staticContext.setXQueryVersion(30); } else if (version.equals("1.0")) { context.setXQueryVersion(10); + staticContext.setXQueryVersion(10); } else { - throw new XPathException(v, ErrorCodes.XQST0031, "Wrong XQuery version: require 1.0, 3.0 or 3.1"); + throw new XPathException(v, ErrorCodes.XQST0031, "Wrong XQuery version: require 1.0, 3.0, 3.1, or 4.0"); } } ( enc:STRING_LITERAL )? @@ -302,6 +530,10 @@ throws PermissionDeniedException, EXistException, XPathException #( m:MODULE_DECL uri:STRING_LITERAL { + if (uri.getText() == null || uri.getText().isEmpty()) { + throw new XPathException(uri.getLine(), uri.getColumn(), ErrorCodes.XQST0088, + "The literal that specifies the target namespace in a module declaration must not be of zero length."); + } if (myModule == null) myModule = new ExternalModuleImpl(uri.getText(), m.getText()); else { @@ -337,6 +569,8 @@ throws PermissionDeniedException, EXistException, XPathException boolean baseuri = false; boolean ordering = false; boolean construction = false; + Set declaredDecimalFormats = new HashSet(); + boolean defaultDecimalFormatDeclared = false; }: ( @@ -387,7 +621,7 @@ throws PermissionDeniedException, EXistException, XPathException ) { if (orderempty) - throw new XPathException(prolog_AST_in, ErrorCodes.XQST0065, "Ordering mode already declared."); + throw new XPathException(prolog_AST_in, ErrorCodes.XQST0069, "Default empty-order already declared."); orderempty = true; } ) @@ -454,16 +688,27 @@ throws PermissionDeniedException, EXistException, XPathException { // ignored if (construction) - throw new XPathException(prolog_AST_in, ErrorCodes.XQST0069, "Construction already declared."); + throw new XPathException(prolog_AST_in, ErrorCodes.XQST0067, "Construction already declared."); construction = true; } ) | + // === W3C XQuery Update Facility 3.0 - Revalidation Declaration === + #( + "revalidation" ( "strict" | "lax" | "skip" ) + { + // eXist does not support schema revalidation; declaration is accepted and ignored + } + ) + | #( DEF_NAMESPACE_DECL defu:STRING_LITERAL - { // Use setDefaultElementNamespace() + { + // Check for duplicate default element namespace first (XQST0066) + context.setDefaultElementNamespace(defu.getText(), null); + staticContext.setDefaultElementNamespace(defu.getText(), null); context.declareNamespace("", defu.getText()); - staticContext.declareNamespace("",defu.getText()); + staticContext.declareNamespace("", defu.getText()); } ) | @@ -510,8 +755,12 @@ throws PermissionDeniedException, EXistException, XPathException } declaredGlobalVars.add(qn); } - { List annots = new ArrayList(); } + { List annots = new ArrayList(); boolean varIsPrivate = false; } (annotations [annots] + { + checkVisibilityAnnotations(annots, ErrorCodes.XQST0116, "variable", qname); + varIsPrivate = hasPrivateAnnotation(annots); + } )? ( #( @@ -525,6 +774,7 @@ throws PermissionDeniedException, EXistException, XPathException { final VariableDeclaration decl= new VariableDeclaration(context, qn, enclosed); decl.setSequenceType(type); + decl.setPrivate(varIsPrivate); decl.setASTNode(e); path.add(decl); if(myModule != null) { @@ -554,6 +804,7 @@ throws PermissionDeniedException, EXistException, XPathException final VariableDeclaration decl = new VariableDeclaration(context, qn, defaultValue); decl.setSequenceType(type); + decl.setPrivate(varIsPrivate); decl.setASTNode(ext); if (external == null) { path.add(decl); @@ -632,6 +883,51 @@ throws PermissionDeniedException, EXistException, XPathException ) ) | + // XQFT 3.0 §5.2: declare ft-option using + #( + FT_OPTION_DECL + { + FTMatchOptions ftDefaultOpts = new FTMatchOptions(); + } + ftDefaultOpts=ftMatchOptionsExpr + { + if (ftDefaultOpts.hasConflict()) { + throw new XPathException(ErrorCodes.FTST0019, + ftDefaultOpts.getConflictDescription()); + } + context.setDefaultFTMatchOptions(ftDefaultOpts); + } + ) + | + #( + dfDecl:DECIMAL_FORMAT_DECL (.)* + { + final QName dfQName; + try { + dfQName = QName.parse(staticContext, dfDecl.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(dfDecl.getLine(), dfDecl.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix in decimal format name: " + dfDecl.getText()); + } + final String dfKey = dfQName.getNamespaceURI() + ":" + dfQName.getLocalPart(); + if (declaredDecimalFormats.contains(dfKey)) + throw new XPathException(dfDecl, ErrorCodes.XQST0097, "Duplicate decimal format declaration: " + dfDecl.getText()); + declaredDecimalFormats.add(dfKey); + final DecimalFormat df = processDecimalFormatProperties(dfDecl); + context.setStaticDecimalFormat(dfQName, df); + } + ) + | + #( + defDfDecl:DEF_DECIMAL_FORMAT_DECL (.)* + { + if (defaultDecimalFormatDeclared) + throw new XPathException(defDfDecl, ErrorCodes.XQST0097, "Duplicate default decimal format declaration."); + defaultDecimalFormatDeclared = true; + final DecimalFormat df = processDecimalFormatProperties(defDfDecl); + context.setDefaultStaticDecimalFormat(df); + } + ) + | functionDecl [path] | importDecl [path] @@ -651,15 +947,17 @@ throws PermissionDeniedException, EXistException, XPathException moduleURI:STRING_LITERAL ( uriList [uriList] )? { + // Normalize whitespace in module namespace URI per XQuery spec section 4.12: + // xs:anyURI uses collapse (strip leading/trailing, replace internal whitespace sequences with single space) + final String moduleNamespaceUri = moduleURI.getText().strip().replaceAll("[\\x20\\x09\\x0A\\x0D]+", " "); + if (modulePrefix != null) { if (declaredNamespaces.get(modulePrefix) != null) { throw new XPathException(i, ErrorCodes.XQST0033, "Prolog contains " + "multiple declarations for namespace prefix: " + modulePrefix); } - declaredNamespaces.put(modulePrefix, moduleURI.getText()); + declaredNamespaces.put(modulePrefix, moduleNamespaceUri); } - - final String moduleNamespaceUri = moduleURI.getText(); if (importedModules.contains(moduleNamespaceUri)) { throw new XPathException(i, ErrorCodes.XQST0047, "Prolog has " + "more than one 'import module' statement for module(s) of namespace: " + moduleNamespaceUri); @@ -828,12 +1126,21 @@ throws PermissionDeniedException, EXistException, XPathException { QName qn= null; try { - qn = QName.parse(staticContext, name.getText(), staticContext.getDefaultFunctionNamespace()); + // XQ4 (PR2200): unprefixed function declarations go into "no namespace" + // instead of the default function namespace (fn:) + if (name.getText() != null && !name.getText().contains(":") && staticContext.getXQueryVersion() >= 40) { + qn = new QName(name.getText(), ""); + } else { + qn = QName.parse(staticContext, name.getText(), staticContext.getDefaultFunctionNamespace()); + } } catch (final IllegalQNameException iqe) { throw new XPathException(name.getLine(), name.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + name.getText()); } FunctionSignature signature= new FunctionSignature(qn); signature.setDescription(name.getDoc()); + if (name instanceof XQueryFunctionAST && ((XQueryFunctionAST) name).isUpdating()) { + signature.setUpdating(true); + } UserDefinedFunction func= new UserDefinedFunction(context, signature); func.setASTNode(name); List varList= new ArrayList(3); @@ -841,6 +1148,7 @@ throws PermissionDeniedException, EXistException, XPathException { List annots = new ArrayList(); } (annotations [annots] { + checkVisibilityAnnotations(annots, ErrorCodes.XQST0106, "function", name); processAnnotations(annots, signature); } )? @@ -848,6 +1156,21 @@ throws PermissionDeniedException, EXistException, XPathException { processParams(varList, func, signature); + // XQ4 (PR197): a required parameter cannot follow one with a default value. + // Raise XQST0148 if any param without a default value appears after a param + // that has one. + boolean __sawDefault = false; + for (Object __pObj : varList) { + final FunctionParameterSequenceType __param = (FunctionParameterSequenceType) __pObj; + if (__param.hasDefaultValue()) { + __sawDefault = true; + } else if (__sawDefault) { + throw new XPathException(name.getLine(), name.getColumn(), + ErrorCodes.XQST0148, + "A required parameter must not follow a parameter with a default value"); + } + } + final String qualifiedNameArity = signature.getName().toURIQualifiedName() + '#' + signature.getArgumentCount(); if (importedModuleFunctions != null && importedModuleFunctions.contains(qualifiedNameArity)) { throw new XPathException(name.getLine(), name.getColumn(), ErrorCodes.XQST0034, "Prolog has " + @@ -859,7 +1182,14 @@ throws PermissionDeniedException, EXistException, XPathException "as" { SequenceType type= new SequenceType(); } sequenceType [type] - { signature.setReturnType(type); } + { + signature.setReturnType(type); + // XUST0028: updating functions must not declare a return type + if (signature.isUpdating()) { + throw new XPathException(name.getLine(), name.getColumn(), + ErrorCodes.XUST0028, "An updating function must not declare a return type."); + } + } ) )? ( @@ -903,6 +1233,9 @@ throws PermissionDeniedException, EXistException, XPathException ( annotations [annots] { + // XQuery 3.1 section 4.18 / section 3.1.7.1: an inline function expression + // must not be annotated as %public or %private (XQST0125). + checkInlineFunctionAnnotations(annots, name); processAnnotations(annots, signature); } )? @@ -930,11 +1263,46 @@ throws PermissionDeniedException, EXistException, XPathException ) ; +focusFunctionDecl [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ step = null; }: + #( + ff:FOCUS_FUNCTION + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(ff, ErrorCodes.XPST0003, + "Focus functions require xquery version \"4.0\""); + } + PathExpr body = new PathExpr(context); + body.setASTNode(focusFunctionDecl_AST_in); + + // Create a function with a single implicit parameter + FunctionSignature signature = new FunctionSignature(InlineFunction.INLINE_FUNCTION_QNAME); + UserDefinedFunction func = new UserDefinedFunction(context, signature); + func.setASTNode(ff); + + // Add the implicit focus parameter: $(.focus) as item()* + FunctionParameterSequenceType focusParam = new FunctionParameterSequenceType( + FocusFunction.FOCUS_PARAM_NAME, Type.ITEM, Cardinality.ZERO_OR_MORE, + "implicit focus parameter"); + signature.setArgumentTypes(new SequenceType[] { focusParam }); + signature.setReturnType(new SequenceType(Type.ITEM, Cardinality.ZERO_OR_MORE)); + func.addVariable(FocusFunction.FOCUS_PARAM_NAME); + } + ( expr [body] )? + { + func.setFunctionBody(body); + step = new FocusFunction(context, func); + } + ) + ; + /** * Parse params in function declaration. */ paramList [List vars] -throws XPathException +throws PermissionDeniedException, EXistException, XPathException : param [vars] ( param [vars] )* ; @@ -943,7 +1311,7 @@ throws XPathException * Single function param. */ param [List vars] -throws XPathException +throws PermissionDeniedException, EXistException, XPathException : #( varname:VARIABLE_BINDING @@ -959,6 +1327,22 @@ throws XPathException sequenceType [var] ) )? + ( + #( + pd:PARAM_DEFAULT + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(pd, ErrorCodes.XPST0003, + "Default parameter values require xquery version \"4.0\""); + } + PathExpr defaultExpr = new PathExpr(context); + } + expr [defaultExpr] + { + var.setDefaultValue(defaultExpr.simplify()); + } + ) + )? ) ; @@ -1049,7 +1433,7 @@ throws XPathException try { QName qn= QName.parse(staticContext, t.getText()); int code= Type.getType(qn); - if (!Type.subTypeOf(code, Type.ANY_ATOMIC_TYPE)) + if (!Type.subTypeOf(code, Type.ANY_ATOMIC_TYPE) && !Type.subTypeOf(code, Type.RECORD) && code != Type.ERROR) throw new XPathException(t.getLine(), t.getColumn(), ErrorCodes.XPST0051, qn.toString() + " is not atomic"); type.setPrimaryType(code); } catch (final XPathException e) { @@ -1082,8 +1466,6 @@ throws XPathException STAR | ( - // TODO: parameter types are collected, but not used! - // Change SequenceType accordingly. { List paramTypes = new ArrayList(5); } ( { SequenceType paramType = new SequenceType(); } @@ -1092,46 +1474,127 @@ throws XPathException )* { SequenceType returnType = new SequenceType(); } "as" sequenceType [returnType] + { + type.setFunctionParamTypes(paramTypes.toArray(new SequenceType[0])); + type.setFunctionReturnType(returnType); + } + ) + ) + ) + | + // XQ3.1+: AnnotatedFunctionTest. Annotations are validated for reserved + // namespaces (XQST0045), then the inner FunctionTest is processed identically + // to a non-annotated one. Annotations themselves are not currently used as + // type-system constraints (consistent with eXist's existing FUNCTION_TEST handling). + #( + ANNOTATED_FUNCTION_TEST { type.setPrimaryType(Type.FUNCTION); } + { List annots = new ArrayList(); } + ( annotation [annots] )+ + #( + FUNCTION_TEST + ( + STAR + | + ( + { List paramTypes2 = new ArrayList(5); } + ( + { SequenceType paramType2 = new SequenceType(); } + sequenceType [paramType2] + { paramTypes2.add(paramType2); } + )* + { SequenceType returnType2 = new SequenceType(); } + "as" sequenceType [returnType2] + ) ) ) ) | #( - MAP_TEST { type.setPrimaryType(Type.MAP_ITEM); } + mt:MAP_TEST { type.setPrimaryType(Type.MAP_ITEM); } ( STAR | ( - // TODO: parameter types are collected, but not used! - // Change SequenceType accordingly. { List paramTypes = new ArrayList(5); } ( { SequenceType paramType = new SequenceType(); } sequenceType [paramType] { paramTypes.add(paramType); } )* + { + // Per XPath/XQuery 3.1 MapTest: must be either map(*) (already + // handled by STAR above) or map(KeyType, ValueType) - exactly + // two sequence types, with KeyType an ItemType (no cardinality). + if (paramTypes.size() != 2) { + throw new XPathException(mt.getLine(), mt.getColumn(), ErrorCodes.XPST0003, + "Map test must take exactly 0 or 2 type arguments, got " + paramTypes.size()); + } + if (paramTypes.get(0).getCardinality() != org.exist.xquery.Cardinality.EXACTLY_ONE) { + throw new XPathException(mt.getLine(), mt.getColumn(), ErrorCodes.XPST0003, + "Map key type must be an ItemType (cardinality must be 1, no occurrence indicator)"); + } + type.setFunctionParamTypes(paramTypes.toArray(new SequenceType[0])); + } ) ) ) | #( - ARRAY_TEST { type.setPrimaryType(Type.ARRAY_ITEM); } + at:ARRAY_TEST { type.setPrimaryType(Type.ARRAY_ITEM); } ( STAR | ( - // TODO: parameter types are collected, but not used! - // Change SequenceType accordingly. { List paramTypes = new ArrayList(5); } ( { SequenceType paramType = new SequenceType(); } sequenceType [paramType] { paramTypes.add(paramType); } )* + { + // Per XPath/XQuery 3.1 ArrayTest: must be either array(*) (STAR + // above) or array(SequenceType) - exactly one sequence type. + if (paramTypes.size() != 1) { + throw new XPathException(at.getLine(), at.getColumn(), ErrorCodes.XPST0003, + "Array test must take exactly 0 or 1 type arguments, got " + paramTypes.size()); + } + type.setFunctionParamTypes(paramTypes.toArray(new SequenceType[0])); + } ) ) ) | + #( + RECORD_TEST + { + type.setPrimaryType(Type.RECORD); + List recordFields = new ArrayList(); + } + ( + #( + rf:RECORD_FIELD + { + String rfName = rf.getText(); + boolean rfOptional = rfName.endsWith("?"); + if (rfOptional) { + rfName = rfName.substring(0, rfName.length() - 1); + } + SequenceType rfType = null; + } + ( + { rfType = new SequenceType(); } + sequenceType [rfType] + )? + { + recordFields.add(new RecordType.FieldDeclaration(rfName, rfType, rfOptional)); + } + ) + )* + { + type.setRecordType(new RecordType(recordFields, false)); + } + ) + | #( "item" { type.setPrimaryType(Type.ITEM); } ) @@ -1229,6 +1692,10 @@ throws XPathException "document-node" { type.setPrimaryType(Type.DOCUMENT); } ( + // XQ4 PR1604: document-node(*) bare-STAR short form + // (already lowered by the parser into a STAR child). + STAR + | #( lelement2:"element" ( dneq:EQNAME @@ -1262,6 +1729,111 @@ throws XPathException #( "schema-element" EQNAME ) )? ) + // === XQuery 4.0 JNode Kind Tests (version-gated) === + | + #( jnt1:JSON_NODE_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt1, ErrorCodes.XPST0003, "json-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_NODE); + } + ) + | + #( jnt2:JSON_OBJECT_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt2, ErrorCodes.XPST0003, "object-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_OBJECT); + } + ) + | + #( jnt3:JSON_ARRAY_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt3, ErrorCodes.XPST0003, "array-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_ARRAY); + } + ) + | + #( jnt4:JSON_STRING_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt4, ErrorCodes.XPST0003, "string-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_STRING); + } + ) + | + #( jnt5:JSON_NUMBER_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt5, ErrorCodes.XPST0003, "number-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_NUMBER); + } + ) + | + #( jnt6:JSON_BOOLEAN_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt6, ErrorCodes.XPST0003, "boolean-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_BOOLEAN); + } + ) + | + #( jnt7:JSON_NULL_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt7, ErrorCodes.XPST0003, "null-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_NULL); + } + ) + | + #( jnt8:JSON_MEMBER_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jnt8, ErrorCodes.XPST0003, "member-node() requires xquery version \"4.0\""); + } + type.setPrimaryType(Type.JSON_MEMBER); + } + ) + // === End XQuery 4.0 JNode Kind Tests === + | + #( + CHOICE_TYPE + { + List alternatives = new ArrayList(); + } + ( + { + SequenceType altType = new SequenceType(); + } + sequenceType [altType] + { + alternatives.add(altType); + } + )+ + { + for (final SequenceType alt : alternatives) { + type.addChoiceAlternative(alt); + } + type.setPrimaryType(Type.ITEM); + } + ) + | + #( + en:ENUM_TYPE + { + String enumText = en.getText(); + String[] enumVals = enumText.split(",", -1); + type.setEnumValues(enumVals); + } + ) ) ( STAR { type.setCardinality(Cardinality.ZERO_OR_MORE); } @@ -1293,6 +1865,14 @@ throws PermissionDeniedException, EXistException, XPathException | step=arrowOp [path] | + step=mappingArrowOp [path] + | + step=pipelineOp [path] + | + step=methodCallOp [path] // XQ4 method call operator =?> + | + step=otherwiseExpr [path] + | step=typeCastExpr [path] | // sequence constructor: @@ -1363,44 +1943,433 @@ throws PermissionDeniedException, EXistException, XPathException } ) | - // conditional: + step=exprFlowControl [path] + + | + // treat as: #( - astIf:"if" + "treat" { - PathExpr testExpr= new PathExpr(context); - PathExpr thenExpr= new PathExpr(context); - PathExpr elseExpr= new PathExpr(context); + PathExpr expr = new PathExpr(context); + expr.setASTNode(expr_AST_in); + SequenceType type= new SequenceType(); } - step=expr [testExpr] - step=astThen:expr [thenExpr] - step=astElse:expr [elseExpr] + step=expr [expr] + sequenceType [type] { - thenExpr.setASTNode(astThen); - elseExpr.setASTNode(astElse); - ConditionalExpression cond = - new ConditionalExpression(context, testExpr, thenExpr, - new DebuggableExpression(elseExpr)); - cond.setASTNode(astIf); - path.add(cond); - step = cond; + step = new TreatAsExpression(context, expr, type); + step.setASTNode(expr_AST_in); + path.add(step); } ) | - // quantified expression: some + // switch #( - "some" + switchAST:"switch" { - List clauses= new ArrayList(); - PathExpr satisfiesExpr = new PathExpr(context); - satisfiesExpr.setASTNode(expr_AST_in); + PathExpr operand = new PathExpr(context); + operand.setASTNode(expr_AST_in); + boolean booleanMode = false; } ( - #( - someVarName:VARIABLE_BINDING - { + SWITCH_BOOLEAN + { booleanMode = true; } + | + step=expr [operand] + ) + { + SwitchExpression switchExpr = new SwitchExpression(context, operand); + switchExpr.setBooleanMode(booleanMode); + switchExpr.setASTNode(switchAST); + path.add(switchExpr); + } + ( + { + List caseOperands = new ArrayList(2); + PathExpr returnExpr = new PathExpr(context); + returnExpr.setASTNode(expr_AST_in); + } + (( + { + PathExpr caseOperand = new PathExpr(context); + caseOperand.setASTNode(expr_AST_in); + } + "case" + expr [caseOperand] + { caseOperands.add(caseOperand); } + )+ + #( + "return" + step= expr [returnExpr] + { switchExpr.addCase(caseOperands, returnExpr); } + )) + )+ + ( + "default" + { + PathExpr returnExpr = new PathExpr(context); + returnExpr.setASTNode(expr_AST_in); + } + step=expr [returnExpr] + { + switchExpr.setDefault(returnExpr); + } + ) + { step = switchExpr; } + ) + | + // typeswitch + #( + "typeswitch" + { + PathExpr operand = new PathExpr(context); + operand.setASTNode(expr_AST_in); + } + step=expr [operand] + { + TypeswitchExpression tswitch = new TypeswitchExpression(context, operand); + tswitch.setASTNode(expr_AST_in); + path.add(tswitch); + } + ( + { + PathExpr returnExpr = new PathExpr(context); + returnExpr.setASTNode(expr_AST_in); + QName qn = null; + List types = new ArrayList(2); + SequenceType type = new SequenceType(); + } + #( + "case" + ( + var:VARIABLE_BINDING + { + try { + qn = QName.parse(staticContext, var.getText()); + } catch (final IllegalQNameException iqe) { + throw new XPathException(var.getLine(), var.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + var.getText()); + } + } + )? + ( + sequenceType[type] + { + types.add(type); + type = new SequenceType(); + } + )+ + // Need return as root in following to disambiguate + // e.g. ( case a xs:integer ( * 3 3 ) ) + // which gives xs:integer* and no operator left for 3 3 ... + // Now ( case a xs:integer ( return ( + 3 3 ) ) ) /ljo + #( + "return" + step= expr [returnExpr] + { + SequenceType[] atype = new SequenceType[types.size()]; + atype = types.toArray(atype); + tswitch.addCase(atype, qn, returnExpr); + } + ) + ) + + )+ + ( + "default" + { + PathExpr returnExpr = new PathExpr(context); + returnExpr.setASTNode(expr_AST_in); + QName qn = null; + } + ( + dvar:VARIABLE_BINDING + { + try { + qn = QName.parse(staticContext, dvar.getText()); + } catch (final IllegalQNameException iqe) { + throw new XPathException(dvar.getLine(), dvar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + dvar.getText()); + } + } + )? + step=expr [returnExpr] + { + tswitch.setDefault(qn, returnExpr); + } + ) + { step = tswitch; } + ) + | + // logical operator: or + #( + "or" + { + PathExpr left= new PathExpr(context); + left.setASTNode(expr_AST_in); + } + step=expr [left] + { + PathExpr right= new PathExpr(context); + right.setASTNode(expr_AST_in); + } + step=expr [right] + ) + { + OpOr or= new OpOr(context); + or.addPath(left); + or.addPath(right); + path.addPath(or); + step = or; + } + | + // logical operator: and + #( + "and" + { + PathExpr left= new PathExpr(context); + left.setASTNode(expr_AST_in); + + PathExpr right= new PathExpr(context); + right.setASTNode(expr_AST_in); + } + step=expr [left] + step=expr [right] + ) + { + OpAnd and= new OpAnd(context); + and.addPath(left); + and.addPath(right); + path.addPath(and); + step = and; + } + | + // union expressions: | and union + #( + UNION + { + PathExpr left= new PathExpr(context); + left.setASTNode(expr_AST_in); + + PathExpr right= new PathExpr(context); + right.setASTNode(expr_AST_in); + } + step=expr [left] + step=expr [right] + ) + { + Union union= new Union(context, left, right); + path.add(union); + step = union; + } + | + // intersections: + #( "intersect" + { + PathExpr left = new PathExpr(context); + left.setASTNode(expr_AST_in); + + PathExpr right = new PathExpr(context); + right.setASTNode(expr_AST_in); + } + step=expr [left] + step=expr [right] + ) + { + Intersect intersect = new Intersect(context, left, right); + path.add(intersect); + step = intersect; + } + | + #( "except" + { + PathExpr left = new PathExpr(context); + left.setASTNode(expr_AST_in); + + PathExpr right = new PathExpr(context); + right.setASTNode(expr_AST_in); + } + step=expr [left] + step=expr [right] + ) + { + Except intersect = new Except(context, left, right); + path.add(intersect); + step = intersect; + } + | + // absolute path expression starting with a / + #( + ABSOLUTE_SLASH + { + path.setHasSlash(); + RootNode root= new RootNode(context); + path.add(root); + } + ( step=expr [path] )? + ) + | + // absolute path expression starting with // + #( + ABSOLUTE_DSLASH + { + path.setHasSlash(); + RootNode root= new RootNode(context); + path.add(root); + } + ( + step=expr [path] + { + if (step instanceof LocationStep) { + LocationStep s= (LocationStep) step; + if (s.getAxis() == Constants.ATTRIBUTE_AXIS || + (s.getTest().getType() == Type.ATTRIBUTE && s.getAxis() == Constants.CHILD_AXIS)) + // combines descendant-or-self::node()/attribute:* + s.setAxis(Constants.DESCENDANT_ATTRIBUTE_AXIS); + else if (s.getAxis() <= Constants.PRECEDING_SIBLING_AXIS) { + // Reverse axis: insert explicit descendant-or-self::node() step + LocationStep descStep = new LocationStep(context, Constants.DESCENDANT_SELF_AXIS, new TypeTest(Type.NODE)); + descStep.setAbbreviated(true); + path.replaceLastExpression(descStep); + path.add(step); + } else { + s.setAxis(Constants.DESCENDANT_SELF_AXIS); + s.setAbbreviated(true); + } + } else + step.setPrimaryAxis(Constants.DESCENDANT_SELF_AXIS); + } + )? + ) + | + // range expression: to + #( + "to" + { + PathExpr start= new PathExpr(context); + start.setASTNode(expr_AST_in); + + PathExpr end= new PathExpr(context); + end.setASTNode(expr_AST_in); + + List args= new ArrayList(2); + args.add(start); + args.add(end); + } + step=expr [start] + step=expr [end] + { + RangeExpression range= new RangeExpression(context); + range.setASTNode(expr_AST_in); + range.setArguments(args); + path.addPath(range); + step = range; + } + ) + | + step=generalComp [path] + | + step=valueComp [path] + | + step=nodeComp [path] + | + step=ftContainsExpr [path] + | + step=primaryExpr [path] + | + step=pathExpr [path] + | + step=extensionExpr [path] + | + step=numericExpr [path] + | + // Legacy update (DEPRECATED) + step=updateExpr [path] + | + // W3C XQuery Update Facility 3.0 + step=xqufInsertExpr [path] + | + step=xqufDeleteExpr [path] + | + step=xqufReplaceExpr [path] + | + step=xqufRenameExpr [path] + | + step=xqufTransformExpr [path] + ; + +/** + * Flow control expressions extracted from expr to avoid + * Java method size limit (64KB bytecode). + * Handles: conditional, ternary, quantified (some/every), + * try/catch/finally, FLWOR, instance of. + */ +exprFlowControl [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ step = null; } +: + // conditional: + #( + astIf:"if" + { + PathExpr testExpr= new PathExpr(context); + PathExpr thenExpr= new PathExpr(context); + PathExpr elseExpr= new PathExpr(context); + } + step=expr [testExpr] + step=astThen:expr [thenExpr] + step=astElse:expr [elseExpr] + { + thenExpr.setASTNode(astThen); + elseExpr.setASTNode(astElse); + ConditionalExpression cond = + new ConditionalExpression(context, testExpr, thenExpr, + new DebuggableExpression(elseExpr)); + cond.setASTNode(astIf); + path.add(cond); + step = cond; + } + ) + | + // ternary conditional: condition ?? then !! else + #( + astTernary:TERNARY + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(astTernary, ErrorCodes.XPST0003, + "The ternary conditional operator (?? !!) requires xquery version \"4.0\""); + } + PathExpr ternTestExpr = new PathExpr(context); + PathExpr ternThenExpr = new PathExpr(context); + PathExpr ternElseExpr = new PathExpr(context); + } + step=expr [ternTestExpr] + step=expr [ternThenExpr] + step=expr [ternElseExpr] + { + ConditionalExpression ternCond = + new ConditionalExpression(context, ternTestExpr, ternThenExpr, + new DebuggableExpression(ternElseExpr)); + ternCond.setASTNode(astTernary); + path.add(ternCond); + step = ternCond; + } + ) + | + // quantified expression: some + #( + "some" + { + List clauses= new ArrayList(); + PathExpr satisfiesExpr = new PathExpr(context); + satisfiesExpr.setASTNode(exprFlowControl_AST_in); + } + ( + #( + someVarName:VARIABLE_BINDING + { ForLetClause clause= new ForLetClause(); PathExpr inputSequence = new PathExpr(context); - inputSequence.setASTNode(expr_AST_in); + inputSequence.setASTNode(exprFlowControl_AST_in); } ( #( @@ -1428,7 +2397,7 @@ throws PermissionDeniedException, EXistException, XPathException for (int i= clauses.size() - 1; i >= 0; i--) { ForLetClause clause= (ForLetClause) clauses.get(i); BindingExpression expr = new QuantifiedExpression(context, QuantifiedExpression.SOME); - expr.setASTNode(expr_AST_in); + expr.setASTNode(exprFlowControl_AST_in); expr.setVariable(clause.varName); expr.setSequenceType(clause.sequenceType); expr.setInputSequence(clause.inputSequence); @@ -1447,7 +2416,7 @@ throws PermissionDeniedException, EXistException, XPathException { List clauses= new ArrayList(); PathExpr satisfiesExpr = new PathExpr(context); - satisfiesExpr.setASTNode(expr_AST_in); + satisfiesExpr.setASTNode(exprFlowControl_AST_in); } ( #( @@ -1455,7 +2424,7 @@ throws PermissionDeniedException, EXistException, XPathException { ForLetClause clause= new ForLetClause(); PathExpr inputSequence = new PathExpr(context); - inputSequence.setASTNode(expr_AST_in); + inputSequence.setASTNode(exprFlowControl_AST_in); } ( #( @@ -1483,7 +2452,7 @@ throws PermissionDeniedException, EXistException, XPathException for (int i= clauses.size() - 1; i >= 0; i--) { ForLetClause clause= (ForLetClause) clauses.get(i); BindingExpression expr = new QuantifiedExpression(context, QuantifiedExpression.EVERY); - expr.setASTNode(expr_AST_in); + expr.setASTNode(exprFlowControl_AST_in); expr.setVariable(clause.varName); expr.setSequenceType(clause.sequenceType); expr.setInputSequence(clause.inputSequence); @@ -1501,7 +2470,7 @@ throws PermissionDeniedException, EXistException, XPathException astTry:"try" { PathExpr tryTargetExpr = new PathExpr(context); - tryTargetExpr.setASTNode(expr_AST_in); + tryTargetExpr.setASTNode(exprFlowControl_AST_in); } step=expr [tryTargetExpr] { @@ -1514,7 +2483,7 @@ throws PermissionDeniedException, EXistException, XPathException final List catchErrorList = new ArrayList<>(2); final List catchVars = new ArrayList<>(3); final PathExpr catchExpr = new PathExpr(context); - catchExpr.setASTNode(expr_AST_in); + catchExpr.setASTNode(exprFlowControl_AST_in); } #( astCatch:"catch" @@ -1565,7 +2534,21 @@ throws PermissionDeniedException, EXistException, XPathException cond.addCatchClause(catchErrorList, catchVars, catchExpr); } ) - )+ + )* + ( + #( + astFinally:"finally" + { + final PathExpr finallyExpr = new PathExpr(context); + finallyExpr.setASTNode(astFinally); + } + (step=expr [finallyExpr])? + { + finallyExpr.setASTNode(astFinally); + cond.setFinallyExpr(finallyExpr); + } + ) + )? { step = cond; @@ -1592,7 +2575,7 @@ throws PermissionDeniedException, EXistException, XPathException ForLetClause clause= new ForLetClause(); clause.ast = varName; PathExpr inputSequence= new PathExpr(context); - inputSequence.setASTNode(expr_AST_in);inputSequence.setASTNode(expr_AST_in); + inputSequence.setASTNode(exprFlowControl_AST_in);inputSequence.setASTNode(exprFlowControl_AST_in); final DistinctVariableNames distinctVariableNames = new DistinctVariableNames(); } ( @@ -1616,6 +2599,16 @@ throws PermissionDeniedException, EXistException, XPathException } } )? + ( + scoreVar:FT_SCORE_VAR + { + try { + clause.scoreVar = distinctVariableNames.check(ErrorCodes.XQST0089, scoreVar, QName.parse(staticContext, scoreVar.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(scoreVar.getLine(), scoreVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + scoreVar.getText()); + } + } + )? step=expr [inputSequence] { try { @@ -1627,6 +2620,199 @@ throws PermissionDeniedException, EXistException, XPathException clauses.add(clause); } ) + | + #( + fmAST:FOR_MEMBER + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(fmAST, ErrorCodes.XPST0003, + "The 'for member' clause requires xquery version \"4.0\""); + } + } + #( + memberVarName:VARIABLE_BINDING + { + ForLetClause clause= new ForLetClause(); + clause.ast = memberVarName; + clause.type = FLWORClause.ClauseType.FOR_MEMBER; + PathExpr inputSequence= new PathExpr(context); + inputSequence.setASTNode(exprFlowControl_AST_in); + final DistinctVariableNames memberDistinctVars = new DistinctVariableNames(); + } + ( + #( + "as" + { clause.sequenceType= new SequenceType(); } + sequenceType [clause.sequenceType] + ) + )? + ( + memberPosVar:POSITIONAL_VAR + { + try { + clause.posVar = memberDistinctVars.check(ErrorCodes.XQST0089, memberPosVar, QName.parse(staticContext, memberPosVar.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(memberPosVar.getLine(), memberPosVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + memberPosVar.getText()); + } + } + )? + step=expr [inputSequence] + { + try { + clause.varName = memberDistinctVars.check(ErrorCodes.XQST0089, memberVarName, QName.parse(staticContext, memberVarName.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(memberVarName.getLine(), memberVarName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + memberVarName.getText()); + } + clause.inputSequence= inputSequence; + clauses.add(clause); + } + ) + ) + | + #( + FOR_KEY + #( + keyVarName:VARIABLE_BINDING + { + ForLetClause clause= new ForLetClause(); + clause.ast = keyVarName; + clause.type = FLWORClause.ClauseType.FOR_KEY; + PathExpr inputSequence= new PathExpr(context); + inputSequence.setASTNode(exprFlowControl_AST_in); + final DistinctVariableNames keyDistinctVars = new DistinctVariableNames(); + } + ( + #( + "as" + { clause.sequenceType= new SequenceType(); } + sequenceType [clause.sequenceType] + ) + )? + ( + keyPosVar:POSITIONAL_VAR + { + try { + clause.posVar = keyDistinctVars.check(ErrorCodes.XQST0089, keyPosVar, QName.parse(staticContext, keyPosVar.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(keyPosVar.getLine(), keyPosVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + keyPosVar.getText()); + } + } + )? + step=expr [inputSequence] + { + try { + clause.varName = keyDistinctVars.check(ErrorCodes.XQST0089, keyVarName, QName.parse(staticContext, keyVarName.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(keyVarName.getLine(), keyVarName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + keyVarName.getText()); + } + clause.inputSequence= inputSequence; + clauses.add(clause); + } + ) + ) + | + #( + FOR_VALUE + #( + valueVarName:VARIABLE_BINDING + { + ForLetClause clause= new ForLetClause(); + clause.ast = valueVarName; + clause.type = FLWORClause.ClauseType.FOR_VALUE; + PathExpr inputSequence= new PathExpr(context); + inputSequence.setASTNode(exprFlowControl_AST_in); + final DistinctVariableNames valueDistinctVars = new DistinctVariableNames(); + } + ( + #( + "as" + { clause.sequenceType= new SequenceType(); } + sequenceType [clause.sequenceType] + ) + )? + ( + valuePosVar:POSITIONAL_VAR + { + try { + clause.posVar = valueDistinctVars.check(ErrorCodes.XQST0089, valuePosVar, QName.parse(staticContext, valuePosVar.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(valuePosVar.getLine(), valuePosVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + valuePosVar.getText()); + } + } + )? + step=expr [inputSequence] + { + try { + clause.varName = valueDistinctVars.check(ErrorCodes.XQST0089, valueVarName, QName.parse(staticContext, valueVarName.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(valueVarName.getLine(), valueVarName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + valueVarName.getText()); + } + clause.inputSequence= inputSequence; + clauses.add(clause); + } + ) + ) + | + #( + FOR_KEY_VALUE + #( + kvKeyVarName:VARIABLE_BINDING + { + ForLetClause clause= new ForLetClause(); + clause.ast = kvKeyVarName; + clause.type = FLWORClause.ClauseType.FOR_KEY_VALUE; + PathExpr inputSequence= new PathExpr(context); + inputSequence.setASTNode(exprFlowControl_AST_in); + final DistinctVariableNames kvDistinctVars = new DistinctVariableNames(); + } + ( + #( + "as" + { clause.sequenceType= new SequenceType(); } + sequenceType [clause.sequenceType] + ) + )? + ( + #( + kvValueVar:VALUE_VAR + { + try { + clause.valueVarName = kvDistinctVars.check(ErrorCodes.XQST0089, kvValueVar, QName.parse(staticContext, kvValueVar.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(kvValueVar.getLine(), kvValueVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + kvValueVar.getText()); + } + } + ( + #( + "as" + { clause.valueSequenceType = new SequenceType(); } + sequenceType [clause.valueSequenceType] + ) + )? + ) + )? + ( + kvPosVar:POSITIONAL_VAR + { + try { + clause.posVar = kvDistinctVars.check(ErrorCodes.XQST0089, kvPosVar, QName.parse(staticContext, kvPosVar.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(kvPosVar.getLine(), kvPosVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + kvPosVar.getText()); + } + } + )? + step=expr [inputSequence] + { + try { + clause.varName = kvDistinctVars.check(ErrorCodes.XQST0089, kvKeyVarName, QName.parse(staticContext, kvKeyVarName.getText(), null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(kvKeyVarName.getLine(), kvKeyVarName.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + kvKeyVarName.getText()); + } + clause.inputSequence= inputSequence; + clauses.add(clause); + } + ) + ) )+ ) | @@ -1640,8 +2826,18 @@ throws PermissionDeniedException, EXistException, XPathException clause.ast = letVarName; clause.type = FLWORClause.ClauseType.LET; PathExpr inputSequence= new PathExpr(context); - inputSequence.setASTNode(expr_AST_in); + inputSequence.setASTNode(exprFlowControl_AST_in); } + ( + letScoreVar:FT_SCORE_VAR + { + try { + clause.scoreVar = QName.parse(staticContext, letScoreVar.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(letScoreVar.getLine(), letScoreVar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + letScoreVar.getText()); + } + } + )? ( #( "as" @@ -1660,6 +2856,189 @@ throws PermissionDeniedException, EXistException, XPathException clauses.add(clause); } ) + | + // XQ4: sequence destructuring + #( + seqDestAST:SEQ_DESTRUCTURE + { + ForLetClause seqClause = new ForLetClause(); + seqClause.ast = seqDestAST; + seqClause.type = FLWORClause.ClauseType.LET_SEQ_DESTRUCTURE; + seqClause.destructureVarNames = new ArrayList(); + seqClause.destructureVarTypes = new ArrayList(); + String[] seqVarNames = seqDestAST.getText().split(",", -1); + int seqTypedIdx = 0; + boolean[] seqHasType = new boolean[seqVarNames.length]; + for (int dv = 0; dv < seqVarNames.length; dv++) { + String svn = seqVarNames[dv]; + seqHasType[dv] = svn.endsWith("+"); + if (seqHasType[dv]) svn = svn.substring(0, svn.length() - 1); + try { + seqClause.destructureVarNames.add( + QName.parse(staticContext, svn, null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(seqDestAST.getLine(), seqDestAST.getColumn(), + ErrorCodes.XPST0081, "No namespace defined for prefix " + svn); + } + seqClause.destructureVarTypes.add(null); + } + PathExpr seqInput = new PathExpr(context); + seqInput.setASTNode(exprFlowControl_AST_in); + } + ( + #( + DESTRUCTURE_VAR_TYPE + #( + "as" + { + SequenceType seqVarType = new SequenceType(); + while (seqTypedIdx < seqHasType.length && !seqHasType[seqTypedIdx]) seqTypedIdx++; + } + sequenceType [seqVarType] + { + if (seqTypedIdx < seqClause.destructureVarTypes.size()) { + seqClause.destructureVarTypes.set(seqTypedIdx, seqVarType); + } + seqTypedIdx++; + } + ) + ) + )* + ( + #( + "as" + { seqClause.sequenceType = new SequenceType(); } + sequenceType [seqClause.sequenceType] + ) + )? + step=expr [seqInput] + { + seqClause.inputSequence = seqInput; + clauses.add(seqClause); + } + ) + | + // XQ4: array destructuring + #( + arrDestAST:ARRAY_DESTRUCTURE + { + ForLetClause arrClause = new ForLetClause(); + arrClause.ast = arrDestAST; + arrClause.type = FLWORClause.ClauseType.LET_ARRAY_DESTRUCTURE; + arrClause.destructureVarNames = new ArrayList(); + arrClause.destructureVarTypes = new ArrayList(); + String[] arrVarNames = arrDestAST.getText().split(",", -1); + int arrTypedIdx = 0; + boolean[] arrHasType = new boolean[arrVarNames.length]; + for (int dv = 0; dv < arrVarNames.length; dv++) { + String avn = arrVarNames[dv]; + arrHasType[dv] = avn.endsWith("+"); + if (arrHasType[dv]) avn = avn.substring(0, avn.length() - 1); + try { + arrClause.destructureVarNames.add( + QName.parse(staticContext, avn, null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(arrDestAST.getLine(), arrDestAST.getColumn(), + ErrorCodes.XPST0081, "No namespace defined for prefix " + avn); + } + arrClause.destructureVarTypes.add(null); + } + PathExpr arrInput = new PathExpr(context); + arrInput.setASTNode(exprFlowControl_AST_in); + } + ( + #( + DESTRUCTURE_VAR_TYPE + #( + "as" + { + SequenceType arrVarType = new SequenceType(); + while (arrTypedIdx < arrHasType.length && !arrHasType[arrTypedIdx]) arrTypedIdx++; + } + sequenceType [arrVarType] + { + if (arrTypedIdx < arrClause.destructureVarTypes.size()) { + arrClause.destructureVarTypes.set(arrTypedIdx, arrVarType); + } + arrTypedIdx++; + } + ) + ) + )* + ( + #( + "as" + { arrClause.sequenceType = new SequenceType(); } + sequenceType [arrClause.sequenceType] + ) + )? + step=expr [arrInput] + { + arrClause.inputSequence = arrInput; + clauses.add(arrClause); + } + ) + | + // XQ4: map destructuring + #( + mapDestAST:MAP_DESTRUCTURE + { + ForLetClause mapClause = new ForLetClause(); + mapClause.ast = mapDestAST; + mapClause.type = FLWORClause.ClauseType.LET_MAP_DESTRUCTURE; + mapClause.destructureVarNames = new ArrayList(); + mapClause.destructureVarTypes = new ArrayList(); + String[] mapVarNames = mapDestAST.getText().split(",", -1); + int mapTypedIdx = 0; + boolean[] mapHasType = new boolean[mapVarNames.length]; + for (int dv = 0; dv < mapVarNames.length; dv++) { + String mvn = mapVarNames[dv]; + mapHasType[dv] = mvn.endsWith("+"); + if (mapHasType[dv]) mvn = mvn.substring(0, mvn.length() - 1); + try { + mapClause.destructureVarNames.add( + QName.parse(staticContext, mvn, null)); + } catch (final IllegalQNameException iqe) { + throw new XPathException(mapDestAST.getLine(), mapDestAST.getColumn(), + ErrorCodes.XPST0081, "No namespace defined for prefix " + mvn); + } + mapClause.destructureVarTypes.add(null); + } + PathExpr mapInput = new PathExpr(context); + mapInput.setASTNode(exprFlowControl_AST_in); + } + ( + #( + DESTRUCTURE_VAR_TYPE + #( + "as" + { + SequenceType mapVarType = new SequenceType(); + while (mapTypedIdx < mapHasType.length && !mapHasType[mapTypedIdx]) mapTypedIdx++; + } + sequenceType [mapVarType] + { + if (mapTypedIdx < mapClause.destructureVarTypes.size()) { + mapClause.destructureVarTypes.set(mapTypedIdx, mapVarType); + } + mapTypedIdx++; + } + ) + ) + )* + ( + #( + "as" + { mapClause.sequenceType = new SequenceType(); } + sequenceType [mapClause.sequenceType] + ) + )? + step=expr [mapInput] + { + mapClause.inputSequence = mapInput; + clauses.add(mapClause); + } + ) )+ ) | @@ -1709,11 +3088,13 @@ throws PermissionDeniedException, EXistException, XPathException } ) ) - // windowStartCondition + // windowStartCondition (XQ4 PR483: whole clause is optional, "when" within is optional) + ( #( "start" { PathExpr whenExpr = new PathExpr(context); + boolean hasWhen = false; QName currentItemName = null; QName previousItemName = null; QName nextItemName = null; @@ -1769,19 +3150,30 @@ throws PermissionDeniedException, EXistException, XPathException } )? ) - "when" - step=expr [whenExpr] + ( + "when" + step=expr [whenExpr] + { hasWhen = true; } + )? { WindowCondition windowCondition = new WindowCondition( - context, false, currentItemName, windowStartPosVar, previousItemName, nextItemName, whenExpr + context, false, currentItemName, windowStartPosVar, previousItemName, nextItemName, hasWhen ? whenExpr : null ); clause.windowConditions.add(windowCondition); } ) - // windowEndCondition + )? + { + // XQ4 PR483: synthesize a default-true start condition if none was given. + if (clause.windowConditions.isEmpty()) { + clause.windowConditions.add(new WindowCondition(context, false, null, null, null, null, null)); + } + } + // windowEndCondition (XQ4 PR483: "when" within is optional) ( { PathExpr endWhenExpr = new PathExpr(context); + boolean hasEndWhen = false; QName endCurrentItemName = null; QName endPreviousItemName = null; QName endNextItemName = null; @@ -1846,16 +3238,25 @@ throws PermissionDeniedException, EXistException, XPathException } )? ) - "when" - step=expr [endWhenExpr] + ( + "when" + step=expr [endWhenExpr] + { hasEndWhen = true; } + )? { WindowCondition endWindowCondition = new WindowCondition( - context, only, endCurrentItemName, windowEndPosVar, endPreviousItemName, endNextItemName, endWhenExpr + context, only, endCurrentItemName, windowEndPosVar, endPreviousItemName, endNextItemName, hasEndWhen ? endWhenExpr : null ); clause.windowConditions.add(endWindowCondition); } ) )? + { + // Sliding windows still require an end condition (XQ4 PR483 keeps this constraint). + if (clause.windowType == WindowExpr.WindowType.SLIDING_WINDOW && clause.windowConditions.size() < 2) { + throw new XPathException(wc.getLine(), wc.getColumn(), ErrorCodes.XPST0003, "Sliding window expression requires an end condition"); + } + } ) | // XQuery 3.0 group by clause @@ -1884,7 +3285,7 @@ throws PermissionDeniedException, EXistException, XPathException ( { groupSpecExpr = new PathExpr(context); - groupSpecExpr.setASTNode(expr_AST_in); + groupSpecExpr.setASTNode(exprFlowControl_AST_in); } step=expr [groupSpecExpr] ) @@ -1915,7 +3316,7 @@ throws PermissionDeniedException, EXistException, XPathException ( { PathExpr orderSpecExpr= new PathExpr(context); - orderSpecExpr.setASTNode(expr_AST_in); + orderSpecExpr.setASTNode(exprFlowControl_AST_in); } step=expr [orderSpecExpr] { @@ -1981,14 +3382,34 @@ throws PermissionDeniedException, EXistException, XPathException w:"where" { whereExpr= new PathExpr(context); - whereExpr.setASTNode(expr_AST_in); + whereExpr.setASTNode(exprFlowControl_AST_in); } step=expr [whereExpr] { ForLetClause clause = new ForLetClause(); - clause.ast = w; - clause.type = FLWORClause.ClauseType.WHERE; - clause.inputSequence = whereExpr; + clause.ast = w; + clause.type = FLWORClause.ClauseType.WHERE; + clause.inputSequence = whereExpr; + clauses.add(clause); + } + ) + | + #( + wh:"while" + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(wh, ErrorCodes.XPST0003, + "The 'while' clause requires xquery version \"4.0\""); + } + PathExpr whileExpr = new PathExpr(context); + whileExpr.setASTNode(exprFlowControl_AST_in); + } + step=expr [whileExpr] + { + ForLetClause clause = new ForLetClause(); + clause.ast = wh; + clause.type = FLWORClause.ClauseType.WHILE; + clause.inputSequence = whileExpr; clauses.add(clause); } ) @@ -2018,7 +3439,7 @@ throws PermissionDeniedException, EXistException, XPathException switch (clause.type) { case LET: expr = new LetExpr(context); - expr.setASTNode(expr_AST_in); + expr.setASTNode(exprFlowControl_AST_in); break; case GROUPBY: expr = new GroupByClause(context); @@ -2029,387 +3450,131 @@ throws PermissionDeniedException, EXistException, XPathException case WHERE: expr = new WhereClause(context, new DebuggableExpression(clause.inputSequence)); break; + case WHILE: + expr = new WhileClause(context, new DebuggableExpression(clause.inputSequence)); + break; case COUNT: expr = new CountClause(context, clause.varName); break; case WINDOW: expr = new WindowExpr(context, clause.windowType, clause.windowConditions.get(0), clause.windowConditions.size() > 1 ? clause.windowConditions.get(1) : null); break; - default: - expr = new ForExpr(context, clause.allowEmpty); + case FOR_MEMBER: + expr = new ForMemberExpr(context); break; - } - expr.setASTNode(clause.ast); - if (clause.type == FLWORClause.ClauseType.FOR || clause.type == FLWORClause.ClauseType.LET - || clause.type == FLWORClause.ClauseType.WINDOW) { - final BindingExpression bind = (BindingExpression)expr; - bind.setVariable(clause.varName); - bind.setSequenceType(clause.sequenceType); - bind.setInputSequence(clause.inputSequence); - if (clause.type == FLWORClause.ClauseType.FOR) { - ((ForExpr) bind).setPositionalVariable(clause.posVar); - } - } else if (clause.type == FLWORClause.ClauseType.GROUPBY) { - if (clause.groupSpecs != null) { - GroupSpec specs[] = new GroupSpec[clause.groupSpecs.size()]; - int k = 0; - for (GroupSpec groupSpec : clause.groupSpecs) { - specs[k++]= groupSpec; - } - ((GroupByClause)expr).setGroupSpecs(specs); - } - } - if (!(action instanceof FLWORClause)) - expr.setReturnExpression(new DebuggableExpression(action)); - else { - expr.setReturnExpression(action); - ((FLWORClause)action).setPreviousClause(expr); - } - - action= expr; - } - - path.add(action); - step = action; - } - ) - | - // instance of: - #( - "instance" - { - PathExpr expr = new PathExpr(context); - expr.setASTNode(expr_AST_in); - SequenceType type= new SequenceType(); - } - step=expr [expr] - sequenceType [type] - { - step = new InstanceOfExpression(context, expr, type); - step.setASTNode(expr_AST_in); - path.add(step); - } - ) - | - // treat as: - #( - "treat" - { - PathExpr expr = new PathExpr(context); - expr.setASTNode(expr_AST_in); - SequenceType type= new SequenceType(); - } - step=expr [expr] - sequenceType [type] - { - step = new TreatAsExpression(context, expr, type); - step.setASTNode(expr_AST_in); - path.add(step); - } - ) - | - // switch - #( - switchAST:"switch" - { - PathExpr operand = new PathExpr(context); - operand.setASTNode(expr_AST_in); - } - step=expr [operand] - { - SwitchExpression switchExpr = new SwitchExpression(context, operand); - switchExpr.setASTNode(switchAST); - path.add(switchExpr); - } - ( - { - List caseOperands = new ArrayList(2); - PathExpr returnExpr = new PathExpr(context); - returnExpr.setASTNode(expr_AST_in); - } - (( - { - PathExpr caseOperand = new PathExpr(context); - caseOperand.setASTNode(expr_AST_in); - } - "case" - expr [caseOperand] - { caseOperands.add(caseOperand); } - )+ - #( - "return" - step= expr [returnExpr] - { switchExpr.addCase(caseOperands, returnExpr); } - )) - )+ - ( - "default" - { - PathExpr returnExpr = new PathExpr(context); - returnExpr.setASTNode(expr_AST_in); - } - step=expr [returnExpr] - { - switchExpr.setDefault(returnExpr); - } - ) - { step = switchExpr; } - ) - | - // typeswitch - #( - "typeswitch" - { - PathExpr operand = new PathExpr(context); - operand.setASTNode(expr_AST_in); - } - step=expr [operand] - { - TypeswitchExpression tswitch = new TypeswitchExpression(context, operand); - tswitch.setASTNode(expr_AST_in); - path.add(tswitch); - } - ( - { - PathExpr returnExpr = new PathExpr(context); - returnExpr.setASTNode(expr_AST_in); - QName qn = null; - List types = new ArrayList(2); - SequenceType type = new SequenceType(); - } - #( - "case" - ( - var:VARIABLE_BINDING - { - try { - qn = QName.parse(staticContext, var.getText()); - } catch (final IllegalQNameException iqe) { - throw new XPathException(var.getLine(), var.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + var.getText()); - } - } - )? - ( - sequenceType[type] - { - types.add(type); - type = new SequenceType(); - } - )+ - // Need return as root in following to disambiguate - // e.g. ( case a xs:integer ( * 3 3 ) ) - // which gives xs:integer* and no operator left for 3 3 ... - // Now ( case a xs:integer ( return ( + 3 3 ) ) ) /ljo - #( - "return" - step= expr [returnExpr] - { - SequenceType[] atype = new SequenceType[types.size()]; - atype = types.toArray(atype); - tswitch.addCase(atype, qn, returnExpr); - } - ) - ) - - )+ - ( - "default" - { - PathExpr returnExpr = new PathExpr(context); - returnExpr.setASTNode(expr_AST_in); - QName qn = null; - } - ( - dvar:VARIABLE_BINDING - { - try { - qn = QName.parse(staticContext, dvar.getText()); - } catch (final IllegalQNameException iqe) { - throw new XPathException(dvar.getLine(), dvar.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + dvar.getText()); - } - } - )? - step=expr [returnExpr] - { - tswitch.setDefault(qn, returnExpr); - } - ) - { step = tswitch; } - ) - | - // logical operator: or - #( - "or" - { - PathExpr left= new PathExpr(context); - left.setASTNode(expr_AST_in); - } - step=expr [left] - { - PathExpr right= new PathExpr(context); - right.setASTNode(expr_AST_in); - } - step=expr [right] - ) - { - OpOr or= new OpOr(context); - or.addPath(left); - or.addPath(right); - path.addPath(or); - step = or; - } - | - // logical operator: and - #( - "and" - { - PathExpr left= new PathExpr(context); - left.setASTNode(expr_AST_in); - - PathExpr right= new PathExpr(context); - right.setASTNode(expr_AST_in); - } - step=expr [left] - step=expr [right] - ) - { - OpAnd and= new OpAnd(context); - and.addPath(left); - and.addPath(right); - path.addPath(and); - step = and; - } - | - // union expressions: | and union - #( - UNION - { - PathExpr left= new PathExpr(context); - left.setASTNode(expr_AST_in); - - PathExpr right= new PathExpr(context); - right.setASTNode(expr_AST_in); + case FOR_KEY: + expr = new ForKeyValueExpr(context, FLWORClause.ClauseType.FOR_KEY); + break; + case FOR_VALUE: + expr = new ForKeyValueExpr(context, FLWORClause.ClauseType.FOR_VALUE); + break; + case FOR_KEY_VALUE: + expr = new ForKeyValueExpr(context, FLWORClause.ClauseType.FOR_KEY_VALUE); + break; + case LET_SEQ_DESTRUCTURE: + case LET_ARRAY_DESTRUCTURE: + case LET_MAP_DESTRUCTURE: + { + LetDestructureExpr.DestructureMode dmode; + if (clause.type == FLWORClause.ClauseType.LET_SEQ_DESTRUCTURE) { + dmode = LetDestructureExpr.DestructureMode.SEQUENCE; + } else if (clause.type == FLWORClause.ClauseType.LET_ARRAY_DESTRUCTURE) { + dmode = LetDestructureExpr.DestructureMode.ARRAY; + } else { + dmode = LetDestructureExpr.DestructureMode.MAP; + } + LetDestructureExpr dexpr = new LetDestructureExpr(context, dmode); + dexpr.setASTNode(clause.ast); + for (int j = 0; j < clause.destructureVarNames.size(); j++) { + dexpr.addVariable( + (QName) clause.destructureVarNames.get(j), + clause.destructureVarTypes.size() > j ? + (SequenceType) clause.destructureVarTypes.get(j) : null); + } + dexpr.setInputSequence(clause.inputSequence); + if (clause.sequenceType != null) { + dexpr.setOverallType(clause.sequenceType); + } + expr = dexpr; + break; + } + default: + expr = new ForExpr(context, clause.allowEmpty); + break; + } + expr.setASTNode(clause.ast); + if (clause.type == FLWORClause.ClauseType.FOR || clause.type == FLWORClause.ClauseType.LET + || clause.type == FLWORClause.ClauseType.WINDOW + || clause.type == FLWORClause.ClauseType.FOR_MEMBER + || clause.type == FLWORClause.ClauseType.FOR_KEY + || clause.type == FLWORClause.ClauseType.FOR_VALUE + || clause.type == FLWORClause.ClauseType.FOR_KEY_VALUE) { + final BindingExpression bind = (BindingExpression)expr; + bind.setVariable(clause.varName); + bind.setSequenceType(clause.sequenceType); + bind.setInputSequence(clause.inputSequence); + if (clause.type == FLWORClause.ClauseType.FOR) { + ((ForExpr) bind).setPositionalVariable(clause.posVar); + if (clause.scoreVar != null) { + ((ForExpr) bind).setScoreVariable(clause.scoreVar); + } + } else if (clause.type == FLWORClause.ClauseType.FOR_MEMBER) { + ((ForMemberExpr) bind).setPositionalVariable(clause.posVar); + } else if (clause.type == FLWORClause.ClauseType.FOR_KEY + || clause.type == FLWORClause.ClauseType.FOR_VALUE + || clause.type == FLWORClause.ClauseType.FOR_KEY_VALUE) { + ((ForKeyValueExpr) bind).setPositionalVariable(clause.posVar); + if (clause.valueVarName != null) { + ((ForKeyValueExpr) bind).setValueVariable(clause.valueVarName); + if (clause.valueSequenceType != null) { + ((ForKeyValueExpr) bind).setValueSequenceType(clause.valueSequenceType); + } + } + } + if (clause.type == FLWORClause.ClauseType.LET && clause.scoreVar != null) { + ((LetExpr) bind).setScoreBinding(true); + } + } else if (clause.type == FLWORClause.ClauseType.GROUPBY) { + if (clause.groupSpecs != null) { + GroupSpec specs[] = new GroupSpec[clause.groupSpecs.size()]; + int k = 0; + for (GroupSpec groupSpec : clause.groupSpecs) { + specs[k++]= groupSpec; + } + ((GroupByClause)expr).setGroupSpecs(specs); + } + } + if (!(action instanceof FLWORClause)) + expr.setReturnExpression(new DebuggableExpression(action)); + else { + expr.setReturnExpression(action); + ((FLWORClause)action).setPreviousClause(expr); } - step=expr [left] - step=expr [right] - ) - { - Union union= new Union(context, left, right); - path.add(union); - step = union; - } - | - // intersections: - #( "intersect" - { - PathExpr left = new PathExpr(context); - left.setASTNode(expr_AST_in); - PathExpr right = new PathExpr(context); - right.setASTNode(expr_AST_in); - } - step=expr [left] - step=expr [right] - ) - { - Intersect intersect = new Intersect(context, left, right); - path.add(intersect); - step = intersect; - } - | - #( "except" - { - PathExpr left = new PathExpr(context); - left.setASTNode(expr_AST_in); + action= expr; + } - PathExpr right = new PathExpr(context); - right.setASTNode(expr_AST_in); - } - step=expr [left] - step=expr [right] - ) - { - Except intersect = new Except(context, left, right); - path.add(intersect); - step = intersect; - } - | - // absolute path expression starting with a / - #( - ABSOLUTE_SLASH - { - RootNode root= new RootNode(context); - path.add(root); - } - ( step=expr [path] )? - ) - | - // absolute path expression starting with // - #( - ABSOLUTE_DSLASH - { - RootNode root= new RootNode(context); - path.add(root); + path.add(action); + step = action; } - ( - step=expr [path] - { - if (step instanceof LocationStep) { - LocationStep s= (LocationStep) step; - if (s.getAxis() == Constants.ATTRIBUTE_AXIS || - (s.getTest().getType() == Type.ATTRIBUTE && s.getAxis() == Constants.CHILD_AXIS)) - // combines descendant-or-self::node()/attribute:* - s.setAxis(Constants.DESCENDANT_ATTRIBUTE_AXIS); - else { - s.setAxis(Constants.DESCENDANT_SELF_AXIS); - s.setAbbreviated(true); - } - } else - step.setPrimaryAxis(Constants.DESCENDANT_SELF_AXIS); - } - )? ) | - // range expression: to + // instance of: #( - "to" + "instance" { - PathExpr start= new PathExpr(context); - start.setASTNode(expr_AST_in); - - PathExpr end= new PathExpr(context); - end.setASTNode(expr_AST_in); - - List args= new ArrayList(2); - args.add(start); - args.add(end); + PathExpr expr = new PathExpr(context); + expr.setASTNode(exprFlowControl_AST_in); + SequenceType type= new SequenceType(); } - step=expr [start] - step=expr [end] + step=expr [expr] + sequenceType [type] { - RangeExpression range= new RangeExpression(context); - range.setASTNode(expr_AST_in); - range.setArguments(args); - path.addPath(range); - step = range; + step = new InstanceOfExpression(context, expr, type); + step.setASTNode(exprFlowControl_AST_in); + path.add(step); } ) - | - step=generalComp [path] - | - step=valueComp [path] - | - step=nodeComp [path] - | - step=primaryExpr [path] - | - step=pathExpr [path] - | - step=extensionExpr [path] - | - step=numericExpr [path] - | - step=updateExpr [path] ; /** @@ -2495,14 +3660,67 @@ throws PermissionDeniedException, EXistException, XPathException step=postfixExpr [step] { path.add(step); } | + ql:QNAME_LITERAL + { + final String qlText = ql.getText(); + final QName qlQName; + try { + qlQName = QName.parse(staticContext, qlText); + } catch (final IllegalQNameException iqe) { + throw new XPathException(ql.getLine(), ql.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + qlText); + } + step = new LiteralValue(context, new QNameValue(context, qlQName)); + step.setASTNode(ql); + } + step=postfixExpr [step] + { path.add(step); } + | step=inlineFunctionDecl [path] step=postfixExpr [step] { path.add(step); } | + step=focusFunctionDecl [path] + step=postfixExpr [step] + { path.add(step); } + | step = lookup [null] step=postfixExpr [step] { path.add(step); } | + #( + stAST:STRING_TEMPLATE + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(stAST, ErrorCodes.XPST0003, + "String templates require xquery version \"4.0\""); + } + StringConstructor st = new StringConstructor(context); + st.setASTNode(stAST); + } + ( + stContent:STRING_TEMPLATE_CONTENT + { + // Unescape {{ -> {, }} -> }, `` -> ` + String raw = stContent.getText(); + raw = raw.replace("{{", "{").replace("}}", "}").replace("``", "`"); + st.addContent(raw); + } + | + { + PathExpr stInterpolation = new PathExpr(context); + stInterpolation.setASTNode(primaryExpr_AST_in); + } + expr[stInterpolation] + { + st.addInterpolation(stInterpolation.simplify()); + } + )* + { + path.add(st); + step = st; + } + ) + | #( scAST:STRING_CONSTRUCTOR_START { @@ -2764,6 +3982,72 @@ throws PermissionDeniedException, EXistException, XPathException | #( "schema-element" EQNAME ) )? + // === XQuery 4.0 JNode Kind Tests in path steps (version-gated) === + | + jn1:JSON_NODE_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn1, ErrorCodes.XPST0003, "json-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_NODE); ast = jn1; + } + | + jn2:JSON_OBJECT_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn2, ErrorCodes.XPST0003, "object-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_OBJECT); ast = jn2; + } + | + jn3:JSON_ARRAY_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn3, ErrorCodes.XPST0003, "array-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_ARRAY); ast = jn3; + } + | + jn4:JSON_STRING_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn4, ErrorCodes.XPST0003, "string-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_STRING); ast = jn4; + } + | + jn5:JSON_NUMBER_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn5, ErrorCodes.XPST0003, "number-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_NUMBER); ast = jn5; + } + | + jn6:JSON_BOOLEAN_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn6, ErrorCodes.XPST0003, "boolean-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_BOOLEAN); ast = jn6; + } + | + jn7:JSON_NULL_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn7, ErrorCodes.XPST0003, "null-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_NULL); ast = jn7; + } + | + jn8:JSON_MEMBER_TEST + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(jn8, ErrorCodes.XPST0003, "member-node() requires xquery version \"4.0\""); + } + test = new TypeTest(Type.JSON_MEMBER); ast = jn8; + } + // === End XQuery 4.0 JNode Kind Tests === ) { step= new LocationStep(context, axis, test); @@ -2948,6 +4232,9 @@ throws PermissionDeniedException, EXistException, XPathException | #( SLASH step=expr [path] + { + path.setHasSlash(); + } ( rightStep=expr [path] { @@ -2972,6 +4259,9 @@ throws PermissionDeniedException, EXistException, XPathException | #( DSLASH step=expr [path] + { + path.setHasSlash(); + } ( rightStep=expr [path] { @@ -2984,6 +4274,13 @@ throws PermissionDeniedException, EXistException, XPathException rs.setAxis(Constants.DESCENDANT_AXIS); } else if (rs.getAxis() == Constants.SELF_AXIS) { rs.setAxis(Constants.DESCENDANT_SELF_AXIS); + } else if (rs.getAxis() <= Constants.PRECEDING_SIBLING_AXIS) { + // Reverse axis: cannot merge with descendant-or-self, + // insert explicit descendant-or-self::node() step before the reverse axis step + LocationStep descStep = new LocationStep(context, Constants.DESCENDANT_SELF_AXIS, new TypeTest(Type.NODE)); + descStep.setAbbreviated(true); + path.replaceLastExpression(descStep); + path.add(rightStep); } else { rs.setAxis(Constants.DESCENDANT_SELF_AXIS); rs.setAbbreviated(true); @@ -3032,21 +4329,30 @@ throws XPathException | i:INTEGER_LITERAL { - step= new LiteralValue(context, new IntegerValue(i.getText())); + String itext = i.getText().replace("_", ""); + java.math.BigInteger intVal; + if (itext.startsWith("0x") || itext.startsWith("0X")) { + intVal = new java.math.BigInteger(itext.substring(2), 16); + } else if (itext.startsWith("0b") || itext.startsWith("0B")) { + intVal = new java.math.BigInteger(itext.substring(2), 2); + } else { + intVal = new java.math.BigInteger(itext); + } + step= new LiteralValue(context, new IntegerValue(intVal)); step.setASTNode(i); } | ( dec:DECIMAL_LITERAL { - step= new LiteralValue(context, new DecimalValue(dec.getText())); + step= new LiteralValue(context, new DecimalValue(dec.getText().replace("_", ""))); step.setASTNode(dec); } | dbl:DOUBLE_LITERAL { step= new LiteralValue(context, - new DoubleValue(Double.parseDouble(dbl.getText()))); + new DoubleValue(Double.parseDouble(dbl.getText().replace("_", "")))); step.setASTNode(dbl); } ) @@ -3145,6 +4451,21 @@ throws PermissionDeniedException, EXistException, XPathException ( step = lookup [step] | + step = filterExprAM [step] + | + #( + fam:FILTER_AM + { + PathExpr filterPred = new PathExpr(context); + filterPred.setASTNode(postfixExpr_AST_in); + } + expr [filterPred] + { + step = new FilterExprAM(context, step, filterPred.simplify()); + step.setASTNode(fam); + } + ) + | #( PREDICATE { @@ -3206,6 +4527,24 @@ throws PermissionDeniedException, EXistException, XPathException ) ; +// === XQuery 4.0: Array/Map Filter Expression (?[expr]) === +filterExprAM [Expression leftExpr] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +: + #( + filterAM:FILTER_AM + { + PathExpr predExpr = new PathExpr(context); + } + ( expr [predExpr] )+ + { + step = new FilterExprAM(context, leftExpr, predExpr); + step.setASTNode(filterAM); + } + ) + ; + lookup [Expression leftExpr] returns [Expression step] throws PermissionDeniedException, EXistException, XPathException @@ -3220,6 +4559,55 @@ throws PermissionDeniedException, EXistException, XPathException ( pos:INTEGER_VALUE { position = Integer.parseInt(pos.getText()); } | + // XQ4: string literal as key selector (?"first value") + strKey:STRING_LITERAL + { + lookupExpr.add(new LiteralValue(context, new StringValue(strKey.getText()))); + } + | + // XQ4: decimal literal as key selector (?1.2) + decKey:DECIMAL_LITERAL + { + lookupExpr.add(new LiteralValue(context, new DecimalValue(decKey.getText().replace("_", "")))); + } + | + // XQ4: double literal as key selector (?1.2e0) + dblKey:DOUBLE_LITERAL + { + lookupExpr.add(new LiteralValue(context, new DoubleValue(Double.parseDouble(dblKey.getText().replace("_", ""))))); + } + | + // XQ4: variable reference as key selector (?$var) + varKey:VARIABLE_REF + { + final QName varQn; + try { + varQn = QName.parse(staticContext, varKey.getText(), null); + } catch (final IllegalQNameException iqe) { + throw new XPathException(varKey.getLine(), varKey.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + varKey.getText()); + } + lookupExpr.add(new VariableReference(context, varQn)); + } + | + // XQ4: context item as key selector (?.) + ctxKey:SELF + { + lookupExpr.add(new ContextItemExpression(context)); + } + | + // XQ4: QName literal as key selector (?#name) + qnKey:QNAME_LITERAL + { + final String qnText = qnKey.getText(); + final QName qnQName; + try { + qnQName = QName.parse(staticContext, qnText); + } catch (final IllegalQNameException iqe) { + throw new XPathException(qnKey.getLine(), qnKey.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + qnText); + } + lookupExpr.add(new LiteralValue(context, new QNameValue(context, qnQName))); + } + | ( expr [lookupExpr] )+ )? { @@ -3262,6 +4650,33 @@ throws PermissionDeniedException, EXistException, XPathException isPartial = true; } | + #( + kw:KEYWORD_ARG + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(kw, ErrorCodes.XPST0003, + "Keyword arguments require xquery version \"4.0\""); + } + } + ( + QUESTION { + // Keyword argument with placeholder value: name := ? + params.add(new KeywordArgumentExpression(context, kw.getText(), + new Function.Placeholder(context))); + isPartial = true; + } + | + { + PathExpr kwExpr = new PathExpr(context); + kwExpr.setASTNode(functionCall_AST_in); + } + expr [kwExpr] + { + params.add(new KeywordArgumentExpression(context, kw.getText(), kwExpr)); + } + ) + ) + | expr [pathExpr] { params.add(pathExpr); } ) )* @@ -3296,7 +4711,18 @@ throws PermissionDeniedException, EXistException, XPathException } catch (final IllegalQNameException iqe) { throw new XPathException(name.getLine(), name.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + name.getText()); } - NamedFunctionReference ref = new NamedFunctionReference(context, qname, Integer.parseInt(arity.getText())); + // XQ4 (PR2200): unprefixed function references prefer a no-namespace + // user-declared function over the default function namespace (fn:). + if (staticContext.getXQueryVersion() >= 40 + && !name.getText().contains(":") + && org.exist.Namespaces.XPATH_FUNCTIONS_NS.equals(qname.getNamespaceURI())) { + final QName noNsName = new QName(name.getText(), ""); + final int aritynum = Integer.parseInt(arity.getText().replace("_", "")); + if (context.resolveFunction(noNsName, aritynum) != null) { + qname = noNsName; + } + } + NamedFunctionReference ref = new NamedFunctionReference(context, qname, Integer.parseInt(arity.getText().replace("_", ""))); step = ref; } ) @@ -3318,17 +4744,33 @@ throws PermissionDeniedException, EXistException | "descendant-or-self" { axis= Constants.DESCENDANT_SELF_AXIS; } | + "following-sibling-or-self" { axis= Constants.FOLLOWING_SIBLING_OR_SELF_AXIS; } + | "following-sibling" { axis= Constants.FOLLOWING_SIBLING_AXIS; } | + "following-or-self" { axis= Constants.FOLLOWING_OR_SELF_AXIS; } + | "following" { axis= Constants.FOLLOWING_AXIS; } | + "preceding-sibling-or-self" { axis= Constants.PRECEDING_SIBLING_OR_SELF_AXIS; } + | "preceding-sibling" { axis= Constants.PRECEDING_SIBLING_AXIS; } | + "preceding-or-self" { axis= Constants.PRECEDING_OR_SELF_AXIS; } + | "preceding" { axis= Constants.PRECEDING_AXIS; } | "ancestor" { axis= Constants.ANCESTOR_AXIS; } | "ancestor-or-self" { axis= Constants.ANCESTOR_SELF_AXIS; } + | + "following-or-self" { axis= Constants.FOLLOWING_OR_SELF_AXIS; } + | + "preceding-or-self" { axis= Constants.PRECEDING_OR_SELF_AXIS; } + | + "following-sibling-or-self" { axis= Constants.FOLLOWING_SIBLING_OR_SELF_AXIS; } + | + "preceding-sibling-or-self" { axis= Constants.PRECEDING_SIBLING_OR_SELF_AXIS; } ; valueComp [PathExpr path] @@ -3394,130 +4836,737 @@ throws PermissionDeniedException, EXistException, XPathException ) | #( - ge:"ge" step=expr [left] - step=expr [right] + ge:"ge" step=expr [left] + step=expr [right] + { + step= new ValueComparison(context, left, right, Comparison.GTEQ); + step.setASTNode(ge); + path.add(step); + } + ) + ; + +generalComp [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step= null; + + PathExpr left= new PathExpr(context); + left.setASTNode(generalComp_AST_in); + + PathExpr right= new PathExpr(context); + right.setASTNode(generalComp_AST_in); + +} +: + #( + eq:EQ step=expr [left] + step=expr [right] + { + step= new GeneralComparison(context, left, right, Comparison.EQ); + step.setASTNode(eq); + path.add(step); + } + ) + | + #( + neq:NEQ step=expr [left] + step=expr [right] + { + step= new GeneralComparison(context, left, right, Comparison.NEQ); + step.setASTNode(neq); + path.add(step); + } + ) + | + #( + lt:LT step=expr [left] + step=expr [right] + { + step= new GeneralComparison(context, left, right, Comparison.LT); + step.setASTNode(lt); + path.add(step); + } + ) + | + #( + lteq:LTEQ step=expr [left] + step=expr [right] + { + step= new GeneralComparison(context, left, right, Comparison.LTEQ); + step.setASTNode(lteq); + path.add(step); + } + ) + | + #( + gt:GT step=expr [left] + step=expr [right] + { + step= new GeneralComparison(context, left, right, Comparison.GT); + step.setASTNode(gt); + path.add(step); + } + ) + | + #( + gteq:GTEQ step=expr [left] + step=expr [right] + { + step= new GeneralComparison(context, left, right, Comparison.GTEQ); + step.setASTNode(gteq); + path.add(step); + } + ) + ; + +nodeComp [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step= null; + + PathExpr left= new PathExpr(context); + left.setASTNode(nodeComp_AST_in); + + PathExpr right= new PathExpr(context); + right.setASTNode(nodeComp_AST_in); + +} +: + #( + is:"is" step=expr [left] step=expr [right] + { + step = new NodeComparison(context, left, right, NodeComparisonOperator.IS); + step.setASTNode(is); + path.add(step); + } + ) + | + #( + before:BEFORE step=expr[left] step=expr[right] + { + step = new NodeComparison(context, left, right, NodeComparisonOperator.BEFORE); + step.setASTNode(before); + path.add(step); + } + ) + | + #( + after:AFTER step=expr[left] step=expr[right] + { + step = new NodeComparison(context, left, right, NodeComparisonOperator.AFTER); + step.setASTNode(after); + path.add(step); + } + ) + | + // XQuery 4.0 node comparison operators + #( + isnot:"is-not" step=expr[left] step=expr[right] + { + step = new NodeComparison(context, left, right, NodeComparisonOperator.IS_NOT); + step.setASTNode(isnot); + path.add(step); + } + ) + | + #( + foi:"follows-or-is" step=expr[left] step=expr[right] + { + step = new NodeComparison(context, left, right, NodeComparisonOperator.FOLLOWS_OR_IS); + step.setASTNode(foi); + path.add(step); + } + ) + | + #( + poi:"precedes-or-is" step=expr[left] step=expr[right] + { + step = new NodeComparison(context, left, right, NodeComparisonOperator.PRECEDES_OR_IS); + step.setASTNode(poi); + path.add(step); + } + ) + ; + +// === Full Text (W3C XQuery and XPath Full Text 3.0) === + +ftContainsExpr [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + PathExpr source = new PathExpr(context); + source.setASTNode(ftContainsExpr_AST_in); + FTSelection ftSel = null; + Expression ignoreExpr = null; +} +: + #( + ft:FT_CONTAINS + step=expr [source] + ftSel=ftSelectionExpr + ( ignoreExpr=ftIgnoreExpr )? + { + FTContainsExpr ftContains = new FTContainsExpr(context); + ftContains.setASTNode(ft); + ftContains.setSearchSource(source); + ftContains.setFTSelection(ftSel); + ftContains.setIgnoreExpr(ignoreExpr); + path.add(ftContains); + step = ftContains; + } + ) + ; + +ftSelectionExpr +returns [FTSelection ftSel] +throws PermissionDeniedException, EXistException, XPathException +{ + ftSel = new FTSelection(context); + ftSel.setASTNode(ftSelectionExpr_AST_in); + Expression ftOr = null; + Expression posFilter = null; +} +: + #( + FT_SELECTION + ftOr=ftOrExpr + { ftSel.setFTOr(ftOr); } + ( posFilter=ftPosFilterExpr { ftSel.addPosFilter(posFilter); } )* + ) + ; + +ftOrExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + Expression operand = null; + FTOr ftOr = null; +} +: + #( + FT_OR + { + ftOr = new FTOr(context); + ftOr.setASTNode(ftOrExpr_AST_in); + } + ( operand=ftAndExpr { ftOr.addOperand(operand); } )+ + { step = ftOr; } + ) + | + step=ftAndExpr + ; + +ftAndExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + Expression operand = null; + FTAnd ftAnd = null; +} +: + #( + FT_AND + { + ftAnd = new FTAnd(context); + ftAnd.setASTNode(ftAndExpr_AST_in); + } + ( operand=ftMildNotExpr { ftAnd.addOperand(operand); } )+ + { step = ftAnd; } + ) + | + step=ftMildNotExpr + ; + +ftMildNotExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + Expression operand = null; + FTMildNot ftMildNot = null; +} +: + #( + FT_MILD_NOT + { + ftMildNot = new FTMildNot(context); + ftMildNot.setASTNode(ftMildNotExpr_AST_in); + } + ( operand=ftUnaryNotExpr { ftMildNot.addOperand(operand); } )+ + { step = ftMildNot; } + ) + | + step=ftUnaryNotExpr + ; + +ftUnaryNotExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + Expression operand = null; +} +: + #( + FT_UNARY_NOT + operand=ftPrimaryWithOptionsExpr + { + FTUnaryNot ftNot = new FTUnaryNot(context); + ftNot.setASTNode(ftUnaryNotExpr_AST_in); + ftNot.setOperand(operand); + step = ftNot; + } + ) + | + step=ftPrimaryWithOptionsExpr + ; + +ftPrimaryWithOptionsExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + Expression primary = null; + FTMatchOptions matchOpts = null; + Expression weightExpr = null; +} +: + #( + FT_PRIMARY_WITH_OPTIONS + primary=ftPrimaryExpr + ( matchOpts=ftMatchOptionsExpr )? + ( weightExpr=ftWeightExpr )? { - step= new ValueComparison(context, left, right, Comparison.GTEQ); - step.setASTNode(ge); - path.add(step); + FTPrimaryWithOptions pwo = new FTPrimaryWithOptions(context); + pwo.setASTNode(ftPrimaryWithOptionsExpr_AST_in); + pwo.setPrimary(primary); + pwo.setMatchOptions(matchOpts); + pwo.setWeight(weightExpr); + step = pwo; } ) + | + step=ftPrimaryExpr ; -generalComp [PathExpr path] +ftPrimaryExpr returns [Expression step] throws PermissionDeniedException, EXistException, XPathException { - step= null; - - PathExpr left= new PathExpr(context); - left.setASTNode(generalComp_AST_in); - - PathExpr right= new PathExpr(context); - right.setASTNode(generalComp_AST_in); + step = null; +} +: + step=ftWordsExpr + | + step=ftSelectionExpr + | + step=ftExtensionSelectionExpr + ; +ftWordsExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + PathExpr wordsValue = new PathExpr(context); + FTWords.AnyallMode mode = FTWords.AnyallMode.ANY; + FTTimes ftTimes = null; } : #( - eq:EQ step=expr [left] - step=expr [right] + FT_WORDS + step=expr [wordsValue] + ( aa:FT_ANYALL_OPTION { mode = FTWords.AnyallMode.fromString(aa.getText()); } )? + ( ftTimes=ftTimesExpr )? { - step= new GeneralComparison(context, left, right, Comparison.EQ); - step.setASTNode(eq); - path.add(step); + FTWords ftWords = new FTWords(context); + ftWords.setASTNode(ftWordsExpr_AST_in); + ftWords.setWordsValue(wordsValue); + ftWords.setMode(mode); + ftWords.setFTTimes(ftTimes); + step = ftWords; } ) - | + ; + +// XQFT 3.0 3.4.8: FTExtensionSelection -- pragmas wrapping an optional FTSelection. +// Pragmas are parsed but ignored (no FT-specific pragmas are recognized). +// If the body is empty, XQST0079 is raised. If the body is present, +// the pragmas are discarded and the inner FTSelection is returned. +// Namespace prefix validation is performed via context.getPragma(). +ftExtensionSelectionExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + FTSelection innerSel = null; +} +: #( - neq:NEQ step=expr [left] - step=expr [right] + FT_EXTENSION_SELECTION + // Validate pragma namespace prefixes (raises XPST0081 for undeclared prefixes). + // We don't recognize any FT-specific pragmas, so the result is always null. + ( + #( p:PRAGMA ( c:PRAGMA_END )? ) + { + // Validates namespace prefix; throws XPST0081 if prefix is undeclared + context.getPragma(p.getText(), c != null ? c.getText() : ""); + } + )* + ( innerSel=ftSelectionExpr )? { - step= new GeneralComparison(context, left, right, Comparison.NEQ); - step.setASTNode(neq); - path.add(step); + if (innerSel == null) { + // XQST0079: all pragmas unrecognized and no fallback body + throw new XPathException(ftExtensionSelectionExpr_AST_in, + ErrorCodes.XQST0079, + "No recognized pragmas in FTExtensionSelection and no fallback expression"); + } + step = innerSel; } ) - | + ; + +ftTimesExpr +returns [FTTimes step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + FTRange range = null; +} +: #( - lt:LT step=expr [left] - step=expr [right] + FT_TIMES + range=ftRangeExpr { - step= new GeneralComparison(context, left, right, Comparison.LT); - step.setASTNode(lt); - path.add(step); + step = new FTTimes(context); + step.setASTNode(ftTimesExpr_AST_in); + step.setRange(range); } ) - | + ; + +ftRangeExpr +returns [FTRange step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = new FTRange(context); + PathExpr e1 = new PathExpr(context); + PathExpr e2 = new PathExpr(context); + Expression tmp = null; +} +: #( - lteq:LTEQ step=expr [left] - step=expr [right] + r:FT_RANGE { - step= new GeneralComparison(context, left, right, Comparison.LTEQ); - step.setASTNode(lteq); - path.add(step); + String rangeMode = r.getText(); + switch (rangeMode) { + case "exactly": step.setMode(FTRange.RangeMode.EXACTLY); break; + case "at least": step.setMode(FTRange.RangeMode.AT_LEAST); break; + case "at most": step.setMode(FTRange.RangeMode.AT_MOST); break; + case "from": step.setMode(FTRange.RangeMode.FROM_TO); break; + } } + tmp=expr [e1] { step.setExpr1(e1); } + ( tmp=expr [e2] { step.setExpr2(e2); } )? ) + ; + +ftPosFilterExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; +} +: + o:FT_ORDER + { + FTOrder order = new FTOrder(context); + order.setASTNode(o); + step = order; + } | - #( - gt:GT step=expr [left] - step=expr [right] - { - step= new GeneralComparison(context, left, right, Comparison.GT); - step.setASTNode(gt); - path.add(step); + step=ftWindowExpr + | + step=ftDistanceExpr + | + s:FT_SCOPE + { + FTScope scope = new FTScope(context); + scope.setASTNode(s); + String scopeText = s.getText(); + if (scopeText.startsWith("same")) { + scope.setScopeType(FTScope.ScopeType.SAME); + } else { + scope.setScopeType(FTScope.ScopeType.DIFFERENT); } - ) + if (scopeText.endsWith("sentence")) { + scope.setBigUnit(FTScope.BigUnit.SENTENCE); + } else { + scope.setBigUnit(FTScope.BigUnit.PARAGRAPH); + } + step = scope; + } | + c:FT_CONTENT + { + FTContent content = new FTContent(context); + content.setASTNode(c); + switch (c.getText()) { + case "at start": content.setContentType(FTContent.ContentType.AT_START); break; + case "at end": content.setContentType(FTContent.ContentType.AT_END); break; + case "entire content": content.setContentType(FTContent.ContentType.ENTIRE_CONTENT); break; + } + step = content; + } + ; + +ftWindowExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + PathExpr winExpr = new PathExpr(context); + Expression tmp = null; +} +: #( - gteq:GTEQ step=expr [left] - step=expr [right] + w:FT_WINDOW + tmp=expr [winExpr] + u1:. // ftUnit token (words|sentences|paragraphs) { - step= new GeneralComparison(context, left, right, Comparison.GTEQ); - step.setASTNode(gteq); - path.add(step); + FTWindow win = new FTWindow(context); + win.setASTNode(w); + win.setWindowExpr(winExpr); + win.setUnit(FTUnit.fromString(u1.getText())); + step = win; } ) ; -nodeComp [PathExpr path] +ftDistanceExpr returns [Expression step] throws PermissionDeniedException, EXistException, XPathException { - step= null; - - PathExpr left= new PathExpr(context); - left.setASTNode(nodeComp_AST_in); - - PathExpr right= new PathExpr(context); - right.setASTNode(nodeComp_AST_in); - + step = null; + FTRange range = null; } : #( - is:"is" step=expr [left] step=expr [right] + d:FT_DISTANCE + range=ftRangeExpr + u2:. // ftUnit token (words|sentences|paragraphs) { - step = new NodeComparison(context, left, right, NodeComparisonOperator.IS); - step.setASTNode(is); - path.add(step); + FTDistance dist = new FTDistance(context); + dist.setASTNode(d); + dist.setRange(range); + dist.setUnit(FTUnit.fromString(u2.getText())); + step = dist; } ) - | - #( - before:BEFORE step=expr[left] step=expr[right] + ; + +ftMatchOptionsExpr +returns [FTMatchOptions opts] +throws PermissionDeniedException, EXistException, XPathException +{ + opts = new FTMatchOptions(); +} +: + ( + co:FT_CASE_OPTION { - step = new NodeComparison(context, left, right, NodeComparisonOperator.BEFORE); - step.setASTNode(before); - path.add(step); + switch (co.getText()) { + case "sensitive": opts.setCaseMode(FTMatchOptions.CaseMode.SENSITIVE); break; + case "insensitive": opts.setCaseMode(FTMatchOptions.CaseMode.INSENSITIVE); break; + case "lowercase": opts.setCaseMode(FTMatchOptions.CaseMode.LOWERCASE); break; + case "uppercase": opts.setCaseMode(FTMatchOptions.CaseMode.UPPERCASE); break; + } } - ) - | - #( - after:AFTER step=expr[left] step=expr[right] + | + di:FT_DIACRITICS_OPTION { - step = new NodeComparison(context, left, right, NodeComparisonOperator.AFTER); - step.setASTNode(after); - path.add(step); + switch (di.getText()) { + case "sensitive": opts.setDiacriticsMode(FTMatchOptions.DiacriticsMode.SENSITIVE); break; + case "insensitive": opts.setDiacriticsMode(FTMatchOptions.DiacriticsMode.INSENSITIVE); break; + } } + | + st:FT_STEM_OPTION + { opts.setStemming("stemming".equals(st.getText())); } + | + #( FT_LANGUAGE_OPTION lang:STRING_LITERAL { opts.setLanguage(lang.getText()); } ) + | + wc:FT_WILDCARD_OPTION + { opts.setWildcards("wildcards".equals(wc.getText())); } + | + #( thesOpt:FT_THESAURUS_OPTION + { + final String thesText = thesOpt.getText(); + if ("no thesaurus".equals(thesText)) { + opts.setNoThesaurus(true); + } else { + opts.setNoThesaurus(false); + AST thesChild = thesOpt.getFirstChild(); + while (thesChild != null) { + if (thesChild.getType() == FT_THESAURUS_ID) { + final String idText = thesChild.getText(); + if ("default".equals(idText)) { + opts.getThesaurusIDs().add( + new FTMatchOptions.ThesaurusID(null, null, 0, Integer.MAX_VALUE)); + } else { + // "at" -- children: STRING_LITERAL (uri), optional STRING_LITERAL (rel), optional FT_RANGE + String uri = null; + String relationship = null; + int minLevels = 0; + int maxLevels = Integer.MAX_VALUE; + AST idChild = thesChild.getFirstChild(); + if (idChild != null && idChild.getType() == STRING_LITERAL) { + uri = idChild.getText(); + idChild = idChild.getNextSibling(); + } + if (idChild != null && idChild.getType() == STRING_LITERAL) { + relationship = idChild.getText(); + idChild = idChild.getNextSibling(); + } + if (idChild != null && idChild.getType() == FT_RANGE) { + final String rangeType = idChild.getText(); + AST rangeChild = idChild.getFirstChild(); + if (rangeChild != null) { + final int val1 = Integer.parseInt(rangeChild.getText()); + switch (rangeType) { + case "exactly": + minLevels = val1; + maxLevels = val1; + break; + case "at least": + minLevels = val1; + break; + case "at most": + maxLevels = val1; + break; + case "from": + minLevels = val1; + AST rangeChild2 = rangeChild.getNextSibling(); + if (rangeChild2 != null) { + maxLevels = Integer.parseInt(rangeChild2.getText()); + } + break; + } + } + } + if (uri != null) { + opts.getThesaurusIDs().add( + new FTMatchOptions.ThesaurusID(uri, relationship, minLevels, maxLevels)); + opts.getThesaurusURIs().add(uri); + } + } + } + thesChild = thesChild.getNextSibling(); + } + } + } + ) + | + #( sw:FT_STOP_WORD_OPTION + { + final String swText = sw.getText(); + if ("no stop words".equals(swText)) { + opts.setNoStopWords(true); + } else { + if ("stop words default".equals(swText)) { + opts.setUseDefaultStopWords(true); + } + // Walk children to extract stop words (union and except) + AST swChild = sw.getFirstChild(); + while (swChild != null) { + if (swChild.getType() == FT_STOP_WORDS_EXCEPT) { + // Except wrapper -- inner child is FT_STOP_WORDS + AST exceptInner = swChild.getFirstChild(); + while (exceptInner != null) { + if (exceptInner.getType() == FT_STOP_WORDS) { + final String swMode = exceptInner.getText(); + AST swWordNode = exceptInner.getFirstChild(); + while (swWordNode != null) { + if ("at".equals(swMode)) { + opts.getExceptStopWordURIs().add(swWordNode.getText()); + } else { + opts.getExceptInlineStopWords().add(swWordNode.getText()); + } + swWordNode = swWordNode.getNextSibling(); + } + } + exceptInner = exceptInner.getNextSibling(); + } + } else if (swChild.getType() == FT_STOP_WORDS) { + // Union stop words (primary or union-added) + final String swMode = swChild.getText(); + AST swWordNode = swChild.getFirstChild(); + while (swWordNode != null) { + if ("at".equals(swMode)) { + opts.getStopWordURIs().add(swWordNode.getText()); + } else { + opts.getInlineStopWords().add(swWordNode.getText()); + } + swWordNode = swWordNode.getNextSibling(); + } + } + swChild = swChild.getNextSibling(); + } + } + } + ( . )* + ) + | + #( eo:FT_EXTENSION_OPTION ( . )* + { + // XQFT 3.0 §4.10: validate namespace prefix for extension option. + // Raises XPST0081 if the prefix is not declared. + final String extOptName = eo.getText(); + try { + QName.parse(staticContext, extOptName); + } catch (final QName.IllegalQNameException e) { + throw new XPathException(eo.getLine(), eo.getColumn(), + ErrorCodes.XPST0081, + "No namespace defined for prefix in extension option: " + extOptName); + } + } + ) + )+ + ; + +ftWeightExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + PathExpr weightPath = new PathExpr(context); +} +: + #( + FT_WEIGHT + step=expr [weightPath] + { step = weightPath; } + ) + ; + +ftIgnoreExpr +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; + PathExpr ignorePath = new PathExpr(context); +} +: + #( + FT_IGNORE_OPTION + step=expr [ignorePath] + { step = ignorePath; } ) ; @@ -3599,7 +5648,9 @@ throws PermissionDeniedException, EXistException, XPathException || ("".equals(qname.getNamespaceURI()) && qname.getLocalPart().equals(XMLConstants.XMLNS_ATTRIBUTE))) throw new XPathException(constructor_AST_in, ErrorCodes.XQDY0044, "The node-name property of the node constructed by a computed attribute constructor is in the namespace http://www.w3.org/2000/xmlns/ (corresponding to namespace prefix xmlns), or is in no namespace and has local name xmlns."); } catch (final IllegalQNameException iqe) { - throw new XPathException(qna.getLine(), qna.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + qna.getText()); + // Computed attribute constructors evaluate the name dynamically (XQuery 3.1 §3.9.3.1). + // An undeclared prefix is therefore a dynamic error XQDY0074, not the static XPST0081. + throw new XPathException(qna.getLine(), qna.getColumn(), ErrorCodes.XQDY0074, "'" + qna.getText() + "' is not a valid attribute name"); } } #( LCURLY @@ -3642,6 +5693,34 @@ throws PermissionDeniedException, EXistException, XPathException c.setASTNode(e); step= c; staticContext.pushInScopeNamespaces(); + // Per XQuery spec, all xmlns declarations on a direct element + // constructor are in scope for the entire element including its + // attribute value expressions. Pre-declare them so attribute + // expressions parsed below can resolve QNames using these prefixes. + for (AST nsScan = e.getFirstChild(); nsScan != null; nsScan = nsScan.getNextSibling()) { + if (nsScan.getType() != ATTRIBUTE) continue; + final String anm = nsScan.getText(); + if (anm == null) continue; + if (!anm.equals("xmlns") && !anm.startsWith("xmlns:")) continue; + StringBuilder uriBuf = new StringBuilder(); + boolean literalOnly = true; + for (AST piece = nsScan.getFirstChild(); piece != null; piece = piece.getNextSibling()) { + if (piece.getType() == ATTRIBUTE_CONTENT) { + uriBuf.append(piece.getText()); + } else { + literalOnly = false; + break; + } + } + if (!literalOnly) continue; + final String nsPrefix = anm.equals("xmlns") ? "" : anm.substring(6); + try { + final String uriStr = StringValue.expand(uriBuf.toString()); + staticContext.declareInScopeNamespace(nsPrefix, uriStr); + } catch (final XPathException xpe) { + // Defer error to the main attribute pass below + } + } } ( #( @@ -3774,32 +5853,83 @@ throws PermissionDeniedException, EXistException, XPathException EnclosedExpr subexpr= new EnclosedExpr(context); subexpr.setASTNode(l); } - step=expr [subexpr] - { step= subexpr; } + step=expr [subexpr] + { step= subexpr; } + ) + ; + +arrowOp [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step= null; +}: + #( + arrowAST:ARROW_OP + { + PathExpr leftExpr = new PathExpr(context); + leftExpr.setASTNode(arrowOp_AST_in); + } + expr [leftExpr] + { + ArrowOperator op = new ArrowOperator(context, leftExpr.simplify()); + op.setASTNode(arrowAST); + path.add(op); + step = op; + + PathExpr nameExpr = new PathExpr(context); + nameExpr.setASTNode(arrowOp_AST_in); + String name = null; + } + ( + eq:EQNAME + { name = eq.toString(); } + | + expr [nameExpr] + ) + { List params = new ArrayList(5); } + ( + { + PathExpr pathExpr = new PathExpr(context); + pathExpr.setASTNode(arrowOp_AST_in); + } + expr [pathExpr] { params.add(pathExpr.simplify()); } + )* + { + if (name == null) { + op.setArrowFunction(nameExpr, params); + } else { + op.setArrowFunction(name, params); + } + } ) ; -arrowOp [PathExpr path] +mappingArrowOp [PathExpr path] returns [Expression step] throws PermissionDeniedException, EXistException, XPathException { step= null; }: #( - arrowAST:ARROW_OP + mapArrowAST:MAPPING_ARROW_OP { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(mapArrowAST, ErrorCodes.XPST0003, + "The mapping arrow operator (=>!) requires xquery version \"4.0\""); + } PathExpr leftExpr = new PathExpr(context); - leftExpr.setASTNode(arrowOp_AST_in); + leftExpr.setASTNode(mappingArrowOp_AST_in); } expr [leftExpr] { - ArrowOperator op = new ArrowOperator(context, leftExpr.simplify()); - op.setASTNode(arrowAST); + MappingArrowOperator op = new MappingArrowOperator(context, leftExpr.simplify()); + op.setASTNode(mapArrowAST); path.add(op); step = op; PathExpr nameExpr = new PathExpr(context); - nameExpr.setASTNode(arrowOp_AST_in); + nameExpr.setASTNode(mappingArrowOp_AST_in); String name = null; } ( @@ -3812,7 +5942,7 @@ throws PermissionDeniedException, EXistException, XPathException ( { PathExpr pathExpr = new PathExpr(context); - pathExpr.setASTNode(arrowOp_AST_in); + pathExpr.setASTNode(mappingArrowOp_AST_in); } expr [pathExpr] { params.add(pathExpr.simplify()); } )* @@ -3826,6 +5956,105 @@ throws PermissionDeniedException, EXistException, XPathException ) ; +pipelineOp [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; +}: + #( + pipeAST:PIPELINE_OP + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(pipeAST, ErrorCodes.XPST0003, + "The pipeline operator (->) requires xquery version \"4.0\""); + } + PathExpr leftExpr = new PathExpr(context); + leftExpr.setASTNode(pipelineOp_AST_in); + } + expr [leftExpr] + { + PathExpr rightExpr = new PathExpr(context); + rightExpr.setASTNode(pipelineOp_AST_in); + } + expr [rightExpr] + { + step = new PipelineExpression(context, leftExpr.simplify(), rightExpr.simplify()); + step.setASTNode(pipeAST); + path.add(step); + } + ) + ; + +methodCallOp [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; +}: + #( + mcAST:METHOD_CALL_OP + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(mcAST, ErrorCodes.XPST0003, + "The method call operator (=?>) requires xquery version \"4.0\""); + } + PathExpr leftExpr = new PathExpr(context); + leftExpr.setASTNode(methodCallOp_AST_in); + } + expr [leftExpr] + mn:NCNAME + { + MethodCallOperator op = new MethodCallOperator(context, leftExpr.simplify()); + op.setASTNode(mcAST); + path.add(op); + step = op; + + List params = new ArrayList(5); + } + ( + { + PathExpr pathExpr = new PathExpr(context); + pathExpr.setASTNode(methodCallOp_AST_in); + } + expr [pathExpr] { params.add(pathExpr.simplify()); } + )* + { + op.setMethod(mn.getText(), params); + } + ) + ; + +otherwiseExpr [PathExpr path] +returns [Expression step] +throws PermissionDeniedException, EXistException, XPathException +{ + step = null; +}: + #( + owAST:LITERAL_otherwise + { + if (staticContext.getXQueryVersion() < 40) { + throw new XPathException(owAST, ErrorCodes.XPST0003, + "The 'otherwise' operator requires xquery version \"4.0\""); + } + PathExpr leftExpr = new PathExpr(context); + leftExpr.setASTNode(otherwiseExpr_AST_in); + } + expr [leftExpr] + { + PathExpr rightExpr = new PathExpr(context); + rightExpr.setASTNode(otherwiseExpr_AST_in); + } + expr [rightExpr] + { + step = new OtherwiseExpression(context, leftExpr.simplify(), rightExpr.simplify()); + step.setASTNode(owAST); + path.add(step); + } + ) + ; + typeCastExpr [PathExpr path] returns [Expression step] throws PermissionDeniedException, EXistException, XPathException @@ -3840,25 +6069,72 @@ throws PermissionDeniedException, EXistException, XPathException Cardinality cardinality= Cardinality.EXACTLY_ONE; } step=expr [expr] - t:ATOMIC_TYPE ( - QUESTION - { cardinality= Cardinality.ZERO_OR_ONE; } - )? - { - try { - QName qn= QName.parse(staticContext, t.getText()); - int code= Type.getType(qn); - CastExpression castExpr= new CastExpression(context, expr, code, cardinality); + #( + CHOICE_TYPE + { + List choiceTypes = new ArrayList(); + } + ( + ct:ATOMIC_TYPE + { + try { + QName qn = QName.parse(staticContext, ct.getText()); + choiceTypes.add(Type.getType(qn)); + } catch (final XPathException e) { + throw new XPathException(ct.getLine(), ct.getColumn(), ErrorCodes.XPST0051, "Unknown simple type " + ct.getText()); + } catch (final IllegalQNameException e) { + throw new XPathException(ct.getLine(), ct.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + ct.getText()); + } + } + )+ + ) + ( + QUESTION + { cardinality= Cardinality.ZERO_OR_ONE; } + )? + { + int[] types = new int[choiceTypes.size()]; + for (int ci = 0; ci < choiceTypes.size(); ci++) { types[ci] = choiceTypes.get(ci); } + ChoiceCastExpression castExpr = new ChoiceCastExpression(context, expr, types, cardinality); castExpr.setASTNode(castAST); path.add(castExpr); step = castExpr; - } catch (final XPathException e) { - throw new XPathException(t.getLine(), t.getColumn(), ErrorCodes.XPST0051, "Unknown simple type " + t.getText()); - } catch (final IllegalQNameException e) { - throw new XPathException(t.getLine(), t.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + t.getText()); } - } + | + t:ATOMIC_TYPE + ( + QUESTION + { cardinality= Cardinality.ZERO_OR_ONE; } + )? + { + try { + QName qn= QName.parse(staticContext, t.getText()); + int code= Type.getType(qn); + CastExpression castExpr= new CastExpression(context, expr, code, cardinality); + castExpr.setASTNode(castAST); + path.add(castExpr); + step = castExpr; + } catch (final XPathException e) { + throw new XPathException(t.getLine(), t.getColumn(), ErrorCodes.XPST0051, "Unknown simple type " + t.getText()); + } catch (final IllegalQNameException e) { + throw new XPathException(t.getLine(), t.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + t.getText()); + } + } + | + enumCast:ENUM_TYPE + ( + QUESTION + { cardinality= Cardinality.ZERO_OR_ONE; } + )? + { + String[] enumVals = enumCast.getText().split(",", -1); + EnumCastExpression enumCastExpr = new EnumCastExpression(context, expr, enumVals, cardinality, false); + enumCastExpr.setASTNode(castAST); + path.add(enumCastExpr); + step = enumCastExpr; + } + ) ) | #( @@ -3869,25 +6145,72 @@ throws PermissionDeniedException, EXistException, XPathException Cardinality cardinality= Cardinality.EXACTLY_ONE; } step=expr [expr] - t2:ATOMIC_TYPE ( - QUESTION - { cardinality= Cardinality.ZERO_OR_ONE; } - )? - { - try { - QName qn= QName.parse(staticContext, t2.getText()); - int code= Type.getType(qn); - CastableExpression castExpr= new CastableExpression(context, expr, code, cardinality); - castExpr.setASTNode(castAST); + #( + CHOICE_TYPE + { + List choiceTypes2 = new ArrayList(); + } + ( + ct2:ATOMIC_TYPE + { + try { + QName qn = QName.parse(staticContext, ct2.getText()); + choiceTypes2.add(Type.getType(qn)); + } catch (final XPathException e) { + throw new XPathException(ct2.getLine(), ct2.getColumn(), ErrorCodes.XPST0051, "Unknown simple type " + ct2.getText()); + } catch (final IllegalQNameException e) { + throw new XPathException(ct2.getLine(), ct2.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + ct2.getText()); + } + } + )+ + ) + ( + QUESTION + { cardinality= Cardinality.ZERO_OR_ONE; } + )? + { + int[] types2 = new int[choiceTypes2.size()]; + for (int ci = 0; ci < choiceTypes2.size(); ci++) { types2[ci] = choiceTypes2.get(ci); } + ChoiceCastableExpression castExpr = new ChoiceCastableExpression(context, expr, types2, cardinality); + castExpr.setASTNode(castableAST); path.add(castExpr); step = castExpr; - } catch (final XPathException e) { - throw new XPathException(t2.getLine(), t2.getColumn(), ErrorCodes.XPST0051, "Unknown simple type " + t2.getText()); - } catch (final IllegalQNameException e) { - throw new XPathException(t2.getLine(), t2.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + t2.getText()); } - } + | + t2:ATOMIC_TYPE + ( + QUESTION + { cardinality= Cardinality.ZERO_OR_ONE; } + )? + { + try { + QName qn= QName.parse(staticContext, t2.getText()); + int code= Type.getType(qn); + CastableExpression castExpr= new CastableExpression(context, expr, code, cardinality); + castExpr.setASTNode(castableAST); + path.add(castExpr); + step = castExpr; + } catch (final XPathException e) { + throw new XPathException(t2.getLine(), t2.getColumn(), ErrorCodes.XPST0051, "Unknown simple type " + t2.getText()); + } catch (final IllegalQNameException e) { + throw new XPathException(t2.getLine(), t2.getColumn(), ErrorCodes.XPST0081, "No namespace defined for prefix " + t2.getText()); + } + } + | + enumCastable:ENUM_TYPE + ( + QUESTION + { cardinality= Cardinality.ZERO_OR_ONE; } + )? + { + String[] enumVals2 = enumCastable.getText().split(",", -1); + EnumCastExpression enumCastExpr2 = new EnumCastExpression(context, expr, enumVals2, cardinality, true); + enumCastExpr2.setASTNode(castableAST); + path.add(enumCastExpr2); + step = enumCastExpr2; + } + ) ) ; @@ -3929,6 +6252,10 @@ throws XPathException, PermissionDeniedException, EXistException } ; +// === Legacy update (DEPRECATED - use W3C XQuery Update Facility 3.0 syntax instead) === +// To remove legacy update support, delete this rule and the updateExpr +// alternative in the expr dispatch above. + updateExpr [PathExpr path] returns [Expression step] throws XPathException, PermissionDeniedException, EXistException @@ -3936,6 +6263,8 @@ throws XPathException, PermissionDeniedException, EXistException }: #( updateAST:"update" { + context.markLegacyUpdate(updateAST); + PathExpr p1 = new PathExpr(context); p1.setASTNode(updateExpr_AST_in); @@ -3984,6 +6313,179 @@ throws XPathException, PermissionDeniedException, EXistException ) ; +// === W3C XQuery Update Facility 3.0 tree walker rules === + +xqufInsertExpr [PathExpr path] +returns [Expression step] +throws XPathException, PermissionDeniedException, EXistException +{ +}: + #( insertAST:"insert" + { + context.markXQUFUpdate(insertAST); + + PathExpr sourceExpr = new PathExpr(context); + sourceExpr.setASTNode(xqufInsertExpr_AST_in); + + PathExpr targetExpr = new PathExpr(context); + targetExpr.setASTNode(xqufInsertExpr_AST_in); + + int mode = XQUFInsertExpr.INSERT_INTO; + } + step=expr [sourceExpr] + ( + "first" { mode = XQUFInsertExpr.INSERT_INTO_AS_FIRST; } + | + "last" { mode = XQUFInsertExpr.INSERT_INTO_AS_LAST; } + | + "into" { mode = XQUFInsertExpr.INSERT_INTO; } + | + "before" { mode = XQUFInsertExpr.INSERT_BEFORE; } + | + "after" { mode = XQUFInsertExpr.INSERT_AFTER; } + ) + step=expr [targetExpr] + { + XQUFInsertExpr ins = new XQUFInsertExpr(context, sourceExpr, targetExpr, mode); + ins.setASTNode(insertAST); + path.add(ins); + step = ins; + } + ) + ; + +xqufDeleteExpr [PathExpr path] +returns [Expression step] +throws XPathException, PermissionDeniedException, EXistException +{ +}: + #( deleteAST:"delete" + { + context.markXQUFUpdate(deleteAST); + + PathExpr targetExpr = new PathExpr(context); + targetExpr.setASTNode(xqufDeleteExpr_AST_in); + } + step=expr [targetExpr] + { + XQUFDeleteExpr del = new XQUFDeleteExpr(context, targetExpr); + del.setASTNode(deleteAST); + path.add(del); + step = del; + } + ) + ; + +xqufReplaceExpr [PathExpr path] +returns [Expression step] +throws XPathException, PermissionDeniedException, EXistException +{ +}: + #( replaceAST:"replace" + { + context.markXQUFUpdate(replaceAST); + + PathExpr targetExpr = new PathExpr(context); + targetExpr.setASTNode(xqufReplaceExpr_AST_in); + + PathExpr withExpr = new PathExpr(context); + withExpr.setASTNode(xqufReplaceExpr_AST_in); + + boolean isValueOf = false; + } + ( + "value" { isValueOf = true; } + )? + step=expr [targetExpr] + step=expr [withExpr] + { + Expression replExpr; + if (isValueOf) { + replExpr = new XQUFReplaceValueExpr(context, targetExpr, withExpr); + } else { + replExpr = new XQUFReplaceNodeExpr(context, targetExpr, withExpr); + } + replExpr.setASTNode(replaceAST); + path.add(replExpr); + step = replExpr; + } + ) + ; + +xqufRenameExpr [PathExpr path] +returns [Expression step] +throws XPathException, PermissionDeniedException, EXistException +{ +}: + #( renameAST:"rename" + { + context.markXQUFUpdate(renameAST); + + PathExpr targetExpr = new PathExpr(context); + targetExpr.setASTNode(xqufRenameExpr_AST_in); + + PathExpr nameExpr = new PathExpr(context); + nameExpr.setASTNode(xqufRenameExpr_AST_in); + } + step=expr [targetExpr] + step=expr [nameExpr] + { + XQUFRenameExpr ren = new XQUFRenameExpr(context, targetExpr, nameExpr); + ren.setASTNode(renameAST); + path.add(ren); + step = ren; + } + ) + ; + +xqufTransformExpr [PathExpr path] +returns [Expression step] +throws XPathException, PermissionDeniedException, EXistException +{ +}: + #( copyAST:"copy" + { + context.markXQUFUpdate(copyAST); + + java.util.List copyBindings = new java.util.ArrayList(); + + PathExpr modifyExpr = new PathExpr(context); + modifyExpr.setASTNode(xqufTransformExpr_AST_in); + + PathExpr returnExpr = new PathExpr(context); + returnExpr.setASTNode(xqufTransformExpr_AST_in); + } + ( + #( VARIABLE_BINDING + { + PathExpr bindingExpr = new PathExpr(context); + bindingExpr.setASTNode(xqufTransformExpr_AST_in); + String varName = #VARIABLE_BINDING.getText(); + } + step=expr [bindingExpr] + { + final org.exist.dom.QName copyVarQName; + try { + copyVarQName = org.exist.dom.QName.parse(context, varName, null); + } catch (final org.exist.dom.QName.IllegalQNameException e) { + throw new XPathException(xqufTransformExpr_AST_in, ErrorCodes.XPST0081, + "Invalid variable name in copy binding: " + varName); + } + copyBindings.add(new XQUFTransformExpr.CopyBinding(copyVarQName, bindingExpr)); + } + ) + )+ + step=expr [modifyExpr] + step=expr [returnExpr] + { + XQUFTransformExpr trans = new XQUFTransformExpr(context, copyBindings, modifyExpr, returnExpr); + trans.setASTNode(copyAST); + path.add(trans); + step = trans; + } + ) + ; + mapConstr [PathExpr path] returns [Expression step] throws XPathException, PermissionDeniedException, EXistException @@ -4010,6 +6512,16 @@ throws XPathException, PermissionDeniedException, EXistException expr[value] { expr.map(key, value); } ) + | + #( + MAP_CONTENT + { + PathExpr content = new PathExpr(context); + content.setASTNode(mapConstr_AST_in); + } + expr[content] + { expr.content(content); } + ) )* ) ; diff --git a/exist-core/src/main/java/org/exist/Namespaces.java b/exist-core/src/main/java/org/exist/Namespaces.java index 593ab890857..9bfba3b2a26 100644 --- a/exist-core/src/main/java/org/exist/Namespaces.java +++ b/exist-core/src/main/java/org/exist/Namespaces.java @@ -46,7 +46,10 @@ public interface Namespaces { String XPATH_DATATYPES_NS = "http://www.w3.org/2003/05/xpath-datatypes"; String XPATH_FUNCTIONS_MATH_NS = "http://www.w3.org/2005/xpath-functions/math"; + String XPATH_FUNCTIONS_MAP_NS = "http://www.w3.org/2005/xpath-functions/map"; + String XPATH_FUNCTIONS_ARRAY_NS = "http://www.w3.org/2005/xpath-functions/array"; String XQUERY_OPTIONS_NS = "http://www.w3.org/2011/xquery-options"; + String XQUERY_NS = "http://www.w3.org/2012/xquery"; String XSLT_XQUERY_SERIALIZATION_NS = "http://www.w3.org/2010/xslt-xquery-serialization"; diff --git a/exist-core/src/main/java/org/exist/dom/QName.java b/exist-core/src/main/java/org/exist/dom/QName.java index 6604057f218..1ab3e4d5c5d 100644 --- a/exist-core/src/main/java/org/exist/dom/QName.java +++ b/exist-core/src/main/java/org/exist/dom/QName.java @@ -344,16 +344,20 @@ public static QName parse(final String namespaceURI, final String qname) throws public static QName parse(final Context context, final String qname, final String defaultNS) throws IllegalQNameException { - final char firstChar = qname.isEmpty() ? 0 : qname.charAt(0); + // Per xs:QName casting (whitespace facet "collapse"), strip leading/trailing + // whitespace from the lexical form before parsing. + final String trimmed = qname.isEmpty() ? qname : qname.strip(); + + final char firstChar = trimmed.isEmpty() ? 0 : trimmed.charAt(0); // quick test if qname is in clark notation if (firstChar == '{') { - final Matcher clarkNotation = PTN_CLARK_NOTATION.matcher(qname); + final Matcher clarkNotation = PTN_CLARK_NOTATION.matcher(trimmed); // more expensive check if (clarkNotation.matches()) { //parse as clark notation - final String ns = clarkNotation.group(1); + final String ns = collapseWhitespace(clarkNotation.group(1)); final String localPart = clarkNotation.group(2); return new QName(localPart, ns); } @@ -361,18 +365,18 @@ public static QName parse(final Context context, final String qname, final Strin // quick test if qname is in EqName notation if (firstChar == 'Q') { - final Matcher eqNameNotation = PTN_EQ_NAME_NOTATION.matcher(qname); + final Matcher eqNameNotation = PTN_EQ_NAME_NOTATION.matcher(trimmed); // more expensive check if (eqNameNotation.matches()) { //parse as clark notation - final String ns = eqNameNotation.group(1); + final String ns = collapseWhitespace(eqNameNotation.group(1)); final String localPart = eqNameNotation.group(2); return new QName(localPart, ns); } } - final String prefix = extractPrefix(qname); + final String prefix = extractPrefix(trimmed); String namespaceURI; if (prefix != null) { namespaceURI = context.getURIForPrefix(prefix); @@ -385,7 +389,37 @@ public static QName parse(final Context context, final String qname, final Strin if (namespaceURI == null) { namespaceURI = XMLConstants.NULL_NS_URI; } - return new QName(extractLocalName(qname), namespaceURI, prefix); + return new QName(extractLocalName(trimmed), namespaceURI, prefix); + } + + /** + * Replace each whitespace character with a space and collapse runs of spaces into one, + * then strip leading/trailing spaces. Mirrors the W3C XML Schema "collapse" whitespace + * facet, used here when normalizing the namespace portion of a Q{ns}local literal. + */ + private static String collapseWhitespace(final String s) { + if (s == null || s.isEmpty()) { + return s; + } + final StringBuilder sb = new StringBuilder(s.length()); + boolean lastSpace = true; // suppress leading whitespace + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + if (!lastSpace) { + sb.append(' '); + lastSpace = true; + } + } else { + sb.append(c); + lastSpace = false; + } + } + // strip trailing space + if (!sb.isEmpty() && sb.charAt(sb.length() - 1) == ' ') { + sb.setLength(sb.length() - 1); + } + return sb.toString(); } /** diff --git a/exist-core/src/main/java/org/exist/dom/memtree/DocumentImpl.java b/exist-core/src/main/java/org/exist/dom/memtree/DocumentImpl.java index ea7685a17c5..8da61c5ae0b 100644 --- a/exist-core/src/main/java/org/exist/dom/memtree/DocumentImpl.java +++ b/exist-core/src/main/java/org/exist/dom/memtree/DocumentImpl.java @@ -49,6 +49,8 @@ import javax.xml.XMLConstants; import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicLong; @@ -144,6 +146,11 @@ public class DocumentImpl extends NodeImpl implements Document { // end reference nodes + // Override for first-child lookup after in-memory mutations. + // Maps parent node number -> first child node number when the first child + // is no longer at the positional (parent + 1) slot due to insertions. + private Map firstChildOverride = null; + protected XQueryContext context; protected final boolean explicitlyCreated; protected final long docId; @@ -603,43 +610,174 @@ public int getNamespacesCountFor(final int nodeNumber) { return count; } + /** + * Strip unused namespace declarations from an element and all its descendants. + * A namespace declaration is "unused" if its prefix is not used by the element's + * own name or any of its attribute names. + * + *

This implements the W3C copy-namespaces {@code no-preserve} semantics: + * only namespace bindings that are used by element/attribute names are preserved.

+ * + *

Works by invalidating unused namespace array entries (setting parent to -2) + * and re-adding only used ones. The invalidated entries are dead space that + * is cleaned up on the next {@link #compact()} call.

+ * + * @param rootNodeNum the root element node number of the subtree to process + */ + public void stripUnusedNamespacesInSubtree(final int rootNodeNum) { + if (namespaceCode == null) { + return; + } + // Walk the subtree: process rootNodeNum and all descendants at deeper levels + final short rootLevel = treeLevel[rootNodeNum]; + for (int i = rootNodeNum; i < size; i++) { + if (i > rootNodeNum && treeLevel[i] <= rootLevel) { + break; // past the subtree + } + if (nodeKind[i] != Node.ELEMENT_NODE) { + continue; + } + stripUnusedNamespacesForElement(i); + } + } + + private void stripUnusedNamespacesForElement(final int nodeNum) { + int ns = alphaLen[nodeNum]; + if (ns < 0) { + return; // no namespace declarations + } + + // Collect used prefixes: element name + attribute names + final java.util.Set usedPrefixes = new java.util.HashSet<>(); + final QName elemName = nodeName[nodeNum]; + usedPrefixes.add(elemName.getPrefix() != null ? elemName.getPrefix() : ""); + int attr = alpha[nodeNum]; + if (attr >= 0) { + while (attr < nextAttr && attrParent[attr] == nodeNum) { + final QName aName = attrName[attr]; + if (aName.getPrefix() != null && !aName.getPrefix().isEmpty()) { + usedPrefixes.add(aName.getPrefix()); + } + attr++; + } + } + + // Collect used namespace declarations (to re-add later) + final java.util.List usedNs = new java.util.ArrayList<>(); + while (ns < nextNamespace && namespaceParent[ns] == nodeNum) { + final QName nsQName = namespaceCode[ns]; + if (usedPrefixes.contains(nsQName.getLocalPart())) { + usedNs.add(nsQName); + } + // Invalidate the old entry + namespaceParent[ns] = -2; + ns++; + } + + // Reset alphaLen so addNamespace can set it fresh + alphaLen[nodeNum] = -1; + + // Re-add only used namespace declarations + for (final QName nsQName : usedNs) { + addNamespace(nodeNum, nsQName); + } + } + public int getChildCountFor(final int nr) { int count = 0; + final short childLevel = (short) (treeLevel[nr] + 1); int nextNode = getFirstChildFor(nr); - while(nextNode > nr) { - ++count; - nextNode = next[nextNode]; + int steps = 0; + while (nextNode >= 0 && steps < size) { + if (nodeKind[nextNode] != -1 && treeLevel[nextNode] == childLevel) { + ++count; + } + final int following = getNextSiblingFor(nextNode); + if (following < 0) { + break; + } + nextNode = following; + steps++; } return count; } public int getFirstChildFor(final int nodeNumber) { + // Check for override from in-memory mutations (e.g. insert as first) + if (firstChildOverride != null) { + final Integer override = firstChildOverride.get(nodeNumber); + if (override != null) { + return override; + } + } + if (nodeNumber == 0) { // optimisation for document-node if (size > 1) { - return 1; + // skip soft-deleted nodes, but remember first deleted child + int n = 1; + int firstDeleted = -1; + while (n < size && nodeKind[n] == -1) { + if (firstDeleted < 0) { + firstDeleted = n; + } + n++; + } + return n < size ? n : firstDeleted; } else { return -1; } } final short level = treeLevel[nodeNumber]; - final int nextNode = nodeNumber + 1; - if((nextNode < size) && (treeLevel[nextNode] > level)) { - return nextNode; + int nextNode = nodeNumber + 1; + int firstDeletedChild = -1; + // Scan positional children (nodes immediately after parent in the array at a deeper level) + while (nextNode < size && treeLevel[nextNode] > level) { + if (nodeKind[nextNode] != -1) { + return nextNode; // found a non-deleted child + } + if (firstDeletedChild < 0) { + firstDeletedChild = nextNode; + } + nextNode++; } - return -1; + // No non-deleted positional child found. Return the first deleted child + // so callers can follow the next[] chain to find children that were + // appended beyond the positional range via insertChildren(). + return firstDeletedChild; } public int getNextSiblingFor(final int nodeNumber) { final int nextNr = next[nodeNumber]; - return nextNr < nodeNumber ? -1 : nextNr; + if (nextNr < 0) { + return -1; + } + if (nextNr < nodeNumber) { + // Backwards reference: after in-memory mutations, siblings may be at + // lower positions. Check tree level to distinguish sibling from parent. + if (treeLevel[nextNr] == treeLevel[nodeNumber]) { + return nextNr; + } + return -1; // lower level = parent pointer, no next sibling + } + return nextNr; } public int getParentNodeFor(final int nodeNumber) { + if (nodeNumber == 0) { + return -1; + } + final short level = treeLevel[nodeNumber]; int nextNode = next[nodeNumber]; - while(nextNode > nodeNumber) { + int steps = 0; + while (nextNode >= 0 && steps < size) { + if (treeLevel[nextNode] < level) { + return nextNode; // found a node at a lower level = parent + } + // same or higher level — keep walking the chain nextNode = next[nextNode]; + steps++; } return nextNode; } @@ -1635,4 +1773,1046 @@ public Node appendChild(final Node newChild) throws DOMException { throw unsupported(); } + + // === W3C XQuery Update Facility 3.0 - In-memory mutation methods === + + /** + * Rename a node in this document. + * + * @param nodeNum the node number to rename + * @param newName the new QName + */ + public void renameNode(final int nodeNum, final QName newName) { + final short kind = nodeKind[nodeNum]; + switch (kind) { + case Node.ELEMENT_NODE: + case Node.PROCESSING_INSTRUCTION_NODE: + nodeName[nodeNum] = namePool.getSharedName(newName); + break; + default: + throw new DOMException(DOMException.NOT_SUPPORTED_ERR, + "Cannot rename node of type " + kind); + } + } + + /** + * Rename an attribute node. The attrNum parameter is an index into the + * attribute arrays (attrName, attrValue, etc.), NOT the main node arrays. + * + * @param attrNum the attribute index + * @param newName the new QName + */ + public void renameAttribute(final int attrNum, final QName newName) { + attrName[attrNum] = namePool.getSharedName(newName); + } + + /** + * Replace the string value of a node. + * + * @param nodeNum the node number + * @param value the new string value + */ + public void replaceValue(final int nodeNum, final String value) { + final short kind = nodeKind[nodeNum]; + switch (kind) { + case Node.TEXT_NODE: + case Node.COMMENT_NODE: + case Node.CDATA_SECTION_NODE: + case Node.PROCESSING_INSTRUCTION_NODE: { + // Replace the character content + final char[] chars = value.toCharArray(); + if (characters == null) { + characters = new char[chars.length > CHAR_BUF_SIZE ? chars.length : CHAR_BUF_SIZE]; + } else if ((nextChar + chars.length) >= characters.length) { + int newLen = (characters.length * 3) / 2; + if (newLen < (nextChar + chars.length)) { + newLen = nextChar + chars.length; + } + final char[] nc = new char[newLen]; + System.arraycopy(characters, 0, nc, 0, characters.length); + characters = nc; + } + alpha[nodeNum] = nextChar; + alphaLen[nodeNum] = chars.length; + System.arraycopy(chars, 0, characters, nextChar, chars.length); + nextChar += chars.length; + break; + } + case Node.ELEMENT_NODE: { + // W3C replaceElementContent: replace all children with a single text node. + // We must be careful to only modify THIS element's children, not nodes + // belonging to sibling elements that happen to be adjacent in the array. + final short childLevel = (short) (treeLevel[nodeNum] + 1); + + // Determine the boundary of this element's positional subtree. + // Only nodes at positions nodeNum+1..subtreeEnd (where subtreeEnd is the + // first position at the same or lower level) are this element's children. + int subtreeEnd = nodeNum + 1; + while (subtreeEnd < size && treeLevel[subtreeEnd] > treeLevel[nodeNum]) { + subtreeEnd++; + } + + // Find and modify/create a text child within the positional range + int firstTextChild = -1; + for (int c = nodeNum + 1; c < subtreeEnd; c++) { + if (firstTextChild == -1 && treeLevel[c] == childLevel + && nodeKind[c] == Node.TEXT_NODE) { + firstTextChild = c; + } else if (c != firstTextChild) { + nodeKind[c] = -1; // delete other children + } + } + + // Also delete any chain-linked children (from previous insertions) + if (firstChildOverride != null && firstChildOverride.containsKey(nodeNum)) { + int chainChild = firstChildOverride.get(nodeNum); + while (chainChild >= 0 && chainChild != nodeNum) { + if (chainChild >= subtreeEnd && nodeKind[chainChild] != -1) { + nodeKind[chainChild] = -1; // delete appended children + } + final int nx = next[chainChild]; + if (nx < 0 || nx == nodeNum) break; + chainChild = nx; + } + firstChildOverride.remove(nodeNum); + } + + if (firstTextChild >= 0) { + // Modify existing text child in place + final char[] chars = value.toCharArray(); + if ((nextChar + chars.length) >= characters.length) { + int newLen = (characters.length * 3) / 2; + if (newLen < (nextChar + chars.length)) { + newLen = nextChar + chars.length; + } + final char[] nc = new char[newLen]; + System.arraycopy(characters, 0, nc, 0, characters.length); + characters = nc; + } + alpha[firstTextChild] = nextChar; + alphaLen[firstTextChild] = chars.length; + System.arraycopy(chars, 0, characters, nextChar, chars.length); + nextChar += chars.length; + } else if (nodeNum + 1 < subtreeEnd) { + // No text child but has positional children — convert first to text + final int firstChild = nodeNum + 1; + nodeKind[firstChild] = Node.TEXT_NODE; + nodeName[firstChild] = null; + final char[] chars = value.toCharArray(); + if ((nextChar + chars.length) >= characters.length) { + int newLen = (characters.length * 3) / 2; + if (newLen < (nextChar + chars.length)) { + newLen = nextChar + chars.length; + } + final char[] nc = new char[newLen]; + System.arraycopy(characters, 0, nc, 0, characters.length); + characters = nc; + } + alpha[firstChild] = nextChar; + alphaLen[firstChild] = chars.length; + System.arraycopy(chars, 0, characters, nextChar, chars.length); + nextChar += chars.length; + // Mark remaining positional children as deleted + for (int c = firstChild + 1; c < subtreeEnd; c++) { + nodeKind[c] = -1; + } + } else if (value != null && !value.isEmpty()) { + // Element has no positional children — insert via insertChildren + try { + final org.exist.xquery.value.StringValue textVal = + new org.exist.xquery.value.StringValue(value); + insertChildren(nodeNum, textVal, true); + } catch (final org.exist.xquery.XPathException e) { + throw new DOMException(DOMException.INVALID_STATE_ERR, + "Failed to insert text child: " + e.getMessage()); + } + } + break; + } + default: + throw new DOMException(DOMException.NOT_SUPPORTED_ERR, + "Cannot replace value of node of type " + kind); + } + } + + /** + * Replace the value of an attribute node. The attrNum parameter is an index + * into the attribute arrays (attrName, attrValue, etc.), NOT the main node arrays. + * + * @param attrNum the attribute index + * @param value the new value + */ + public void replaceAttributeValue(final int attrNum, final String value) { + attrValue[attrNum] = value; + } + + /** + * Remove an attribute from this document. + * Compacts the attribute arrays by shifting subsequent entries down. + * Also updates the alpha[] pointers for elements whose first attribute + * index is affected. + * + * @param attrNum the attribute index to remove + */ + /** + * Find an attribute index by QName on a given element. + * + * @param elementNodeNum the element node number + * @param qname the attribute QName to find + * @return the attribute index, or -1 if not found + */ + public int findAttribute(final int elementNodeNum, final QName qname) { + int a = alpha[elementNodeNum]; + if (a < 0) { + return -1; + } + while (a < nextAttr && attrParent[a] == elementNodeNum) { + if (attrName[a].getLocalPart().equals(qname.getLocalPart()) + && attrName[a].getNamespaceURI().equals(qname.getNamespaceURI())) { + return a; + } + a++; + } + return -1; + } + + public void removeAttribute(final int attrNum) { + if (attrNum < 0 || attrNum >= nextAttr) { + return; + } + + // Shift all attribute arrays down by one + final int remaining = nextAttr - attrNum - 1; + if (remaining > 0) { + System.arraycopy(attrName, attrNum + 1, attrName, attrNum, remaining); + System.arraycopy(attrNodeId, attrNum + 1, attrNodeId, attrNum, remaining); + System.arraycopy(attrParent, attrNum + 1, attrParent, attrNum, remaining); + System.arraycopy(attrValue, attrNum + 1, attrValue, attrNum, remaining); + System.arraycopy(attrType, attrNum + 1, attrType, attrNum, remaining); + } + nextAttr--; + + // Update alpha[] pointers: alpha[nodeNum] stores the first attribute index + // for each element. If the removed attribute index is <= the element's + // first attribute, we need to adjust. + for (int i = 0; i < size; i++) { + if (nodeKind[i] == Node.ELEMENT_NODE && alpha[i] >= 0) { + if (alpha[i] > attrNum) { + alpha[i]--; + } else if (alpha[i] == attrNum) { + // Check if this element still has attributes + if (attrNum < nextAttr && attrParent[attrNum] == i) { + // Still has attributes at the same index (shifted down) + } else { + alpha[i] = -1; // No more attributes for this element + } + } + } + } + } + + /** + * Find any node whose next[] pointer targets the given node. + * After in-memory mutations, predecessors may be at any array position, + * so we must scan all nodes, not just those before targetNodeNum. + * + * @param targetNodeNum the node to find a predecessor for + * @return the predecessor node number, or -1 if not found + */ + private int findPredecessor(final int targetNodeNum) { + final short targetLevel = treeLevel[targetNodeNum]; + // Search backward first (most common case for unmutated trees) + for (int i = targetNodeNum - 1; i >= 0; i--) { + if (next[i] == targetNodeNum && nodeKind[i] != -1 && treeLevel[i] == targetLevel) { + return i; + } + } + // Search forward (for nodes inserted after targetNodeNum in array order) + for (int i = targetNodeNum + 1; i < size; i++) { + if (next[i] == targetNodeNum && nodeKind[i] != -1 && treeLevel[i] == targetLevel) { + return i; + } + } + return -1; + } + + /** + * Remove a node from this document. + * This is a soft-delete: the node's kind is set to -1 to mark it as deleted. + * This is sufficient for the copy-modify pattern where the document is + * consumed once and not reused. + * + * @param nodeNum the node number to remove + */ + public void removeNode(final int nodeNum) { + if (nodeNum <= 0 || nodeNum >= size) { + return; + } + + // Find the parent and re-stitch the next[] chain to skip this node + final int origNext = next[nodeNum]; + final short level = treeLevel[nodeNum]; + + // Find the previous node that points to nodeNum + final int prev = findPredecessor(nodeNum); + + if (prev >= 0) { + // Find the next node after this node's subtree in the sibling chain. + // Walk the next[] chain from nodeNum to find the first node that's + // at the same or lower level (a sibling or the parent). + int chainNode = origNext; + int steps = 0; + while (chainNode >= 0 && steps < size) { + if (nodeKind[chainNode] == -1) { + // skip deleted nodes in chain + chainNode = next[chainNode]; + steps++; + continue; + } + if (treeLevel[chainNode] <= level) { + // Found a sibling or parent + break; + } + chainNode = next[chainNode]; + steps++; + } + next[prev] = chainNode >= 0 ? chainNode : origNext; + } + + // Mark the node and its subtree as deleted + final short nodeLevel = treeLevel[nodeNum]; + nodeKind[nodeNum] = -1; + for (int i = nodeNum + 1; i < size && treeLevel[i] > nodeLevel; i++) { + nodeKind[i] = -1; + } + } + + /** + * Merge adjacent text nodes throughout the document. + * Per the W3C XQuery Update Facility spec, after applying updates, + * adjacent text nodes among children of any element or document node + * must be merged. Empty text nodes are removed. + * + * This walks all non-deleted nodes and for each parent (document or element), + * finds runs of consecutive text node children and merges them. + */ + public void mergeAdjacentTextNodes() { + // Walk the document looking for parent nodes (document or element) + for (int parent = 0; parent < size; parent++) { + if (nodeKind[parent] == -1) { + continue; + } + if (nodeKind[parent] != Node.DOCUMENT_NODE && nodeKind[parent] != Node.ELEMENT_NODE) { + continue; + } + + // Iterate through children of this parent using the next[] chain + final short childLevel = (short) (treeLevel[parent] + 1); + int child = getFirstChildFor(parent); + if (child < 0) { + continue; + } + + int prevTextNode = -1; + while (child >= 0 && child < size && treeLevel[child] >= childLevel) { + if (nodeKind[child] == -1) { + // Skip deleted nodes — follow next[] chain + child = next[child]; + if (child <= parent) break; + continue; + } + if (treeLevel[child] > childLevel) { + // Descendant, not direct child — skip + child = next[child]; + if (child <= parent) break; + continue; + } + + // Direct child at childLevel + if (nodeKind[child] == Node.TEXT_NODE) { + if (prevTextNode >= 0) { + // Merge this text node into prevTextNode + final String prevText = new String(characters, alpha[prevTextNode], alphaLen[prevTextNode]); + final String thisText = new String(characters, alpha[child], alphaLen[child]); + final String merged = prevText + thisText; + + // Store merged text in prevTextNode + final char[] chars = merged.toCharArray(); + if ((nextChar + chars.length) >= characters.length) { + int newLen = (characters.length * 3) / 2; + if (newLen < (nextChar + chars.length)) { + newLen = nextChar + chars.length; + } + final char[] nc = new char[newLen]; + System.arraycopy(characters, 0, nc, 0, characters.length); + characters = nc; + } + alpha[prevTextNode] = nextChar; + alphaLen[prevTextNode] = chars.length; + System.arraycopy(chars, 0, characters, nextChar, chars.length); + nextChar += chars.length; + + // Soft-delete the merged text node and restitch + removeNode(child); + + // Continue from prevTextNode's next (don't advance prevTextNode) + child = next[prevTextNode]; + if (child <= parent) break; + } else { + // Check for empty text nodes + if (alphaLen[child] == 0) { + final int nextChild = next[child]; + removeNode(child); + child = nextChild; + if (child <= parent) break; + } else { + prevTextNode = child; + child = next[child]; + if (child <= parent) break; + } + } + } else { + prevTextNode = -1; + child = next[child]; + if (child <= parent) break; + } + } + } + + // Invalidate cached node IDs since the structure changed + if (nodeId != null) { + nodeId[0] = null; + } + } + + /** + * Insert children into an element node. + * Uses the serialization rebuild approach for correctness. + * + * @param parentNodeNum the node number of the parent element + * @param content the content to insert + * @param asFirst if true, insert as first children; if false, as last + * @throws XPathException if the content cannot be processed + */ + public void insertChildren(final int parentNodeNum, final Sequence content, final boolean asFirst) + throws XPathException { + if (content == null || content.isEmpty()) { + return; + } + + final short childLevel = (short) (treeLevel[parentNodeNum] + 1); + + if (asFirst) { + // Insert as first children: find the current first child and link new nodes before it + final int firstChild = getFirstChildFor(parentNodeNum); + + int lastInserted = -1; + int firstInserted = -1; + for (final org.exist.xquery.value.SequenceIterator i = content.iterate(); i.hasNext(); ) { + final org.exist.xquery.value.Item item = i.nextItem(); + final java.util.List inserted = copyItemIntoDocument(item, parentNodeNum, childLevel); + for (final int newNodeNum : inserted) { + if (firstInserted == -1) { + firstInserted = newNodeNum; + } + if (lastInserted >= 0) { + next[lastInserted] = newNodeNum; + } + lastInserted = newNodeNum; + } + } + // Link last inserted to the old first child (or parent if no children) + if (lastInserted >= 0) { + next[lastInserted] = firstChild >= 0 ? firstChild : parentNodeNum; + } + // Override the first-child lookup so navigation finds the new nodes first + if (firstInserted >= 0) { + if (firstChildOverride == null) { + firstChildOverride = new HashMap<>(); + } + firstChildOverride.put(parentNodeNum, firstInserted); + } + } else { + // Insert as last children: find the last child and link after it + // Walk the sibling chain from first child to find the last one + int lastChild = -1; + final int firstChild = getFirstChildFor(parentNodeNum); + if (firstChild >= 0) { + lastChild = firstChild; + int nextSib = getNextSiblingFor(lastChild); + while (nextSib >= 0) { + lastChild = nextSib; + nextSib = getNextSiblingFor(lastChild); + } + } + + int firstInsertedAsLast = -1; + for (final org.exist.xquery.value.SequenceIterator i = content.iterate(); i.hasNext(); ) { + final org.exist.xquery.value.Item item = i.nextItem(); + final java.util.List inserted = copyItemIntoDocument(item, parentNodeNum, childLevel); + for (final int newNodeNum : inserted) { + if (firstInsertedAsLast == -1) { + firstInsertedAsLast = newNodeNum; + } + if (lastChild >= 0) { + next[lastChild] = newNodeNum; + } + lastChild = newNodeNum; + } + } + // If the parent had no visible children, the appended nodes are beyond + // the positional scan range. Set firstChildOverride so they can be found. + if (firstChild < 0 && firstInsertedAsLast >= 0) { + if (firstChildOverride == null) { + firstChildOverride = new HashMap<>(); + } + firstChildOverride.put(parentNodeNum, firstInsertedAsLast); + } + } + } + + /** + * Insert sibling nodes before or after a reference node. + * + * @param refNodeNum the reference node number + * @param content the content to insert + * @param before if true, insert before; if false, insert after + * @throws XPathException if the content cannot be processed + */ + public void insertSiblings(final int refNodeNum, final Sequence content, final boolean before) + throws XPathException { + if (content == null || content.isEmpty()) { + return; + } + + final short level = treeLevel[refNodeNum]; + // Find the parent using level-aware parent finding + final int parentNum = getParentNodeFor(refNodeNum); + if (parentNum < 0) { + // Cannot insert siblings of the document node (no parent) + return; + } + + if (before) { + // Insert before: find the node whose next[] points to refNodeNum and re-link + final int prevNode = findPredecessor(refNodeNum); + + int lastInserted = -1; + int firstInserted = -1; + for (final org.exist.xquery.value.SequenceIterator i = content.iterate(); i.hasNext(); ) { + final org.exist.xquery.value.Item item = i.nextItem(); + final java.util.List inserted = copyItemIntoDocument(item, parentNum, level); + for (final int newNodeNum : inserted) { + if (firstInserted == -1) { + firstInserted = newNodeNum; + } + if (prevNode >= 0 && lastInserted == -1) { + next[prevNode] = newNodeNum; + } + if (lastInserted >= 0) { + next[lastInserted] = newNodeNum; + } + lastInserted = newNodeNum; + } + } + // Link last inserted to refNode + if (lastInserted >= 0) { + next[lastInserted] = refNodeNum; + } + // If no predecessor found, refNode was the first child (found positionally). + // Set override so navigation finds the new nodes first. + if (prevNode < 0 && firstInserted >= 0 && parentNum >= 0) { + if (firstChildOverride == null) { + firstChildOverride = new HashMap<>(); + } + firstChildOverride.put(parentNum, firstInserted); + } + } else { + // Insert after: link new nodes after refNode + final int origNext = next[refNodeNum]; + int lastInserted = refNodeNum; + for (final org.exist.xquery.value.SequenceIterator i = content.iterate(); i.hasNext(); ) { + final org.exist.xquery.value.Item item = i.nextItem(); + final java.util.List inserted = copyItemIntoDocument(item, parentNum, level); + for (final int newNodeNum : inserted) { + next[lastInserted] = newNodeNum; + lastInserted = newNodeNum; + } + } + // Last inserted points to where refNode originally pointed + if (lastInserted != refNodeNum) { + next[lastInserted] = origNext; + } + } + } + + /** + * Insert attributes into an element. + * + * @param elementNodeNum the element node number + * @param content the attribute nodes to insert + * @throws XPathException if the content cannot be processed + */ + public void insertAttributes(final int elementNodeNum, final Sequence content) throws XPathException { + insertAttributes(elementNodeNum, content, true); + } + + /** + * Insert attributes into an element. + * + * @param elementNodeNum the target element's node number + * @param content the attributes to insert + * @param replaceExisting if true, replace existing attributes with the same name; + * if false, always add as new attributes (for PUL application + * where a DELETE may separately remove the original) + */ + public void insertAttributes(final int elementNodeNum, final Sequence content, + final boolean replaceExisting) throws XPathException { + if (content == null || content.isEmpty()) { + return; + } + + // Collect new attributes to insert + final java.util.List newAttrs = new java.util.ArrayList<>(); + for (final org.exist.xquery.value.SequenceIterator i = content.iterate(); i.hasNext(); ) { + final org.exist.xquery.value.Item item = i.nextItem(); + if (org.exist.xquery.value.Type.subTypeOf(item.getType(), org.exist.xquery.value.Type.NODE)) { + final Node node = ((org.exist.xquery.value.NodeValue) item).getNode(); + if (node.getNodeType() == Node.ATTRIBUTE_NODE) { + final Attr attr = (Attr) node; + final QName qname = new QName( + attr.getLocalName() != null ? attr.getLocalName() : attr.getName(), + attr.getNamespaceURI() != null ? attr.getNamespaceURI() : "", + attr.getPrefix() != null ? attr.getPrefix() : ""); + newAttrs.add(new Object[]{qname, attr.getValue()}); + } + } + } + + if (newAttrs.isEmpty()) { + return; + } + + // Check for duplicates and replace existing values (only when not in PUL mode) + if (replaceExisting) { + final java.util.Iterator it = newAttrs.iterator(); + while (it.hasNext()) { + final Object[] entry = it.next(); + final QName qname = (QName) entry[0]; + final String value = (String) entry[1]; + if (alpha[elementNodeNum] >= 0) { + int a = alpha[elementNodeNum]; + while (a < nextAttr && attrParent[a] == elementNodeNum) { + if (attrName[a].equals(qname)) { + // Replace existing attribute value + attrValue[a] = value; + it.remove(); + break; + } + a++; + } + } + } + } + + if (newAttrs.isEmpty()) { + return; + } + + final int count = newAttrs.size(); + + // Find insertion point: right after the last contiguous attribute of this element + int insertPos; + if (alpha[elementNodeNum] >= 0) { + insertPos = alpha[elementNodeNum]; + while (insertPos < nextAttr && attrParent[insertPos] == elementNodeNum) { + insertPos++; + } + } else { + // Element has no attrs yet — insert at nextAttr (already contiguous) + insertPos = nextAttr; + } + + // Ensure capacity + while (nextAttr + count > attrName.length) { + growAttributes(); + } + + // Shift everything from insertPos onwards to make room + if (insertPos < nextAttr) { + System.arraycopy(attrParent, insertPos, attrParent, insertPos + count, nextAttr - insertPos); + System.arraycopy(attrName, insertPos, attrName, insertPos + count, nextAttr - insertPos); + System.arraycopy(attrValue, insertPos, attrValue, insertPos + count, nextAttr - insertPos); + System.arraycopy(attrType, insertPos, attrType, insertPos + count, nextAttr - insertPos); + + // Update alpha pointers for elements whose attrs shifted + for (int n = 0; n < size; n++) { + if (nodeKind[n] == Node.ELEMENT_NODE && alpha[n] >= insertPos && n != elementNodeNum) { + alpha[n] += count; + } + } + } + + // Insert new attributes at the contiguous position + for (int j = 0; j < count; j++) { + final Object[] entry = newAttrs.get(j); + final QName qname = (QName) entry[0]; + final String value = (String) entry[1]; + final QName attrQname = new QName(qname.getLocalPart(), qname.getNamespaceURI(), qname.getPrefix(), ElementValue.ATTRIBUTE); + attrParent[insertPos + j] = elementNodeNum; + this.attrName[insertPos + j] = namePool.getSharedName(attrQname); + attrValue[insertPos + j] = value; + attrType[insertPos + j] = AttrImpl.ATTR_CDATA_TYPE; + } + + // Set alpha if element didn't have attrs before + if (alpha[elementNodeNum] < 0) { + alpha[elementNodeNum] = insertPos; + } + + nextAttr += count; + } + + /** + * Replace a node with new content. + * + * @param nodeNum the node number to replace + * @param content the replacement content + * @throws XPathException if the content cannot be processed + */ + public void replaceNode(final int nodeNum, final Sequence content) throws XPathException { + if (content == null || content.isEmpty()) { + removeNode(nodeNum); + return; + } + + final short level = treeLevel[nodeNum]; + final int parentNum = getParentNodeFor(nodeNum); + + // Find the predecessor that points to nodeNum + final int prev = findPredecessor(nodeNum); + + // Find the next node after nodeNum's subtree (the node nodeNum's chain leads to + // at the same or lower level) + int afterNode = next[nodeNum]; + int steps = 0; + while (afterNode >= 0 && steps < size) { + if (nodeKind[afterNode] != -1 && treeLevel[afterNode] <= level) { + break; + } + afterNode = next[afterNode]; + steps++; + } + + // Copy new content nodes and link them into the chain. + // Uses copyItemIntoDocument to handle document nodes and atomic values. + int firstNew = -1; + int lastNew = -1; + try { + for (final org.exist.xquery.value.SequenceIterator i = content.iterate(); i.hasNext(); ) { + final org.exist.xquery.value.Item item = i.nextItem(); + final java.util.List newNodes = copyItemIntoDocument(item, parentNum, level); + for (final int newNodeNum : newNodes) { + if (firstNew == -1) { + firstNew = newNodeNum; + } + if (lastNew >= 0) { + next[lastNew] = newNodeNum; + } + lastNew = newNodeNum; + } + } + } catch (final org.exist.xquery.XPathException e) { + throw new DOMException(DOMException.INVALID_STATE_ERR, e.getMessage()); + } + + // Link new nodes into the chain + if (prev >= 0 && firstNew >= 0) { + next[prev] = firstNew; + } else if (prev < 0 && firstNew >= 0 && parentNum >= 0) { + // No same-level predecessor: the replaced node was the first child. + // Set firstChildOverride so getFirstChildFor() can find the new nodes + // (they're appended at the end of the array, beyond positional scan). + if (firstChildOverride == null) { + firstChildOverride = new HashMap<>(); + } + firstChildOverride.put(parentNum, firstNew); + } + if (lastNew >= 0) { + next[lastNew] = afterNode >= 0 ? afterNode : parentNum; + } + + // Soft-delete the original node and its subtree + final short nodeLevel = treeLevel[nodeNum]; + nodeKind[nodeNum] = -1; + for (int i = nodeNum + 1; i < size && treeLevel[i] > nodeLevel; i++) { + nodeKind[i] = -1; + } + } + + /** + * Copy a DOM node into this document's arrays. + * This is a simplified version for the copy-modify pattern. + * + * @return the node number of the top-level copied node + */ + /** + * Copy a content item into the document arrays, handling atomic values, + * document nodes, and regular nodes per the W3C XQuery Update Facility spec. + * + * @param item the content item to copy + * @param parentNodeNum the parent node number + * @param level the tree level for the new node(s) + * @return list of top-level node numbers that were inserted + */ + private java.util.List copyItemIntoDocument(final org.exist.xquery.value.Item item, + final int parentNodeNum, final short level) + throws XPathException { + // When no-inherit is active, pass an empty scope map to materialize namespaces + // within inserted subtrees (so FunInScopePrefixes self-only mode still finds them) + final java.util.Map scopeNs = + (context != null && !context.inheritNamespaces()) + ? new java.util.LinkedHashMap<>() : null; + + final java.util.List result = new java.util.ArrayList<>(); + if (org.exist.xquery.value.Type.subTypeOf(item.getType(), org.exist.xquery.value.Type.NODE)) { + final Node node = ((org.exist.xquery.value.NodeValue) item).getNode(); + if (node.getNodeType() == Node.DOCUMENT_NODE) { + // For document nodes: insert the document's children, not the document itself + Node child = node.getFirstChild(); + while (child != null) { + result.add(copyNodeIntoDocument(child, parentNodeNum, level, scopeNs)); + child = child.getNextSibling(); + } + } else { + result.add(copyNodeIntoDocument(node, parentNodeNum, level, scopeNs)); + } + } else { + // Atomic value: convert to text node per W3C spec + final String text = item.getStringValue(); + if (!text.isEmpty()) { + final int nodeNum = addNode(Node.TEXT_NODE, level, null); + addChars(nodeNum, text.toCharArray(), 0, text.length()); + next[nodeNum] = parentNodeNum; + result.add(nodeNum); + } + } + return result; + } + + private int copyNodeIntoDocument(final Node node, final int parentNodeNum, final short level) { + return copyNodeIntoDocument(node, parentNodeNum, level, null); + } + + /** + * Copy a node into this document. + * + * @param node the source node + * @param parentNodeNum the parent in this document + * @param level tree level for the new node + * @param scopeNamespaces when non-null, namespace bindings accumulated from ancestors + * within the current subtree (for no-inherit materialization). Each element gets + * explicit declarations for ancestor bindings not already declared on self. + * Pass null to skip materialization (normal copy behavior). + */ + private int copyNodeIntoDocument(final Node node, final int parentNodeNum, final short level, + final java.util.Map scopeNamespaces) { + switch (node.getNodeType()) { + case Node.ELEMENT_NODE: { + final String localName = node.getLocalName() != null ? node.getLocalName() : node.getNodeName(); + final String nsUri = node.getNamespaceURI() != null ? node.getNamespaceURI() : ""; + final String prefix = node.getPrefix() != null ? node.getPrefix() : ""; + final QName qname = new QName(localName, nsUri, prefix); + final int nodeNum = addNode(Node.ELEMENT_NODE, level, qname); + next[nodeNum] = parentNodeNum; + + // Collect attribute prefixes (needed for no-preserve filtering) + final NamedNodeMap attrs = node.getAttributes(); + final java.util.Set usedPrefixes = new java.util.HashSet<>(); + usedPrefixes.add(prefix); // element prefix is always "used" + + // Copy attributes (skip xmlns declarations — handled separately below) + if (attrs != null) { + for (int i = 0; i < attrs.getLength(); i++) { + final Attr attr = (Attr) attrs.item(i); + // Skip namespace declarations + if (javax.xml.XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(attr.getNamespaceURI())) { + continue; + } + final String attrLocal = attr.getLocalName() != null ? attr.getLocalName() : attr.getName(); + final String attrNs = attr.getNamespaceURI() != null ? attr.getNamespaceURI() : ""; + final String attrPrefix = attr.getPrefix() != null ? attr.getPrefix() : ""; + usedPrefixes.add(attrPrefix); + addAttribute(nodeNum, new QName(attrLocal, attrNs, attrPrefix), + attr.getValue(), AttrImpl.ATTR_CDATA_TYPE); + } + } + + // Check if no-preserve mode should strip unused namespace declarations + final boolean noPreserve = context != null && !context.preserveNamespaces(); + + // Collect this element's own namespace declarations + final java.util.Map selfNsDecls = new java.util.LinkedHashMap<>(); + + // Copy namespace declarations (filtered by no-preserve if applicable) + if (node instanceof ElementImpl memElement) { + // Memtree element: copy from namespace arrays + final java.util.Map nsMap = memElement.getNamespaceMap(); + for (final java.util.Map.Entry e : nsMap.entrySet()) { + if (noPreserve && !usedPrefixes.contains(e.getKey())) { + continue; // strip unused namespace declaration + } + selfNsDecls.put(e.getKey(), e.getValue()); + final QName nsQName = new QName(e.getKey(), e.getValue(), + javax.xml.XMLConstants.XMLNS_ATTRIBUTE); + addNamespace(nodeNum, nsQName); + } + } else if (attrs != null) { + // DOM element: extract xmlns attributes + for (int i = 0; i < attrs.getLength(); i++) { + final Attr attr = (Attr) attrs.item(i); + if (javax.xml.XMLConstants.XMLNS_ATTRIBUTE_NS_URI.equals(attr.getNamespaceURI())) { + final String nsPrefix = attr.getLocalName() != null + && !javax.xml.XMLConstants.XMLNS_ATTRIBUTE.equals(attr.getLocalName()) + ? attr.getLocalName() : ""; + if (noPreserve && !usedPrefixes.contains(nsPrefix)) { + continue; // strip unused namespace declaration + } + selfNsDecls.put(nsPrefix, attr.getValue()); + final QName nsQName = new QName(nsPrefix, attr.getValue(), + javax.xml.XMLConstants.XMLNS_ATTRIBUTE); + addNamespace(nodeNum, nsQName); + } + } + } + + // No-inherit materialization: add ancestor namespace bindings from within + // the subtree that are not already declared on this element + if (scopeNamespaces != null) { + for (final java.util.Map.Entry e : scopeNamespaces.entrySet()) { + if (!selfNsDecls.containsKey(e.getKey())) { + if (!noPreserve || usedPrefixes.contains(e.getKey())) { + final QName nsQName = new QName(e.getKey(), e.getValue(), + javax.xml.XMLConstants.XMLNS_ATTRIBUTE); + addNamespace(nodeNum, nsQName); + selfNsDecls.put(e.getKey(), e.getValue()); + } + } + } + } + + // Build effective namespace scope for children + final java.util.Map childScope; + if (scopeNamespaces != null) { + childScope = new java.util.LinkedHashMap<>(scopeNamespaces); + childScope.putAll(selfNsDecls); + } else { + childScope = null; + } + + // Copy children recursively, linking siblings together + int prevChild = -1; + Node child = node.getFirstChild(); + while (child != null) { + final int childNum = copyNodeIntoDocument(child, nodeNum, (short) (level + 1), childScope); + if (prevChild >= 0) { + next[prevChild] = childNum; + } + prevChild = childNum; + child = child.getNextSibling(); + } + return nodeNum; + } + case Node.TEXT_NODE: { + final String text = node.getTextContent(); + final int nodeNum = addNode(Node.TEXT_NODE, level, null); + addChars(nodeNum, text.toCharArray(), 0, text.length()); + next[nodeNum] = parentNodeNum; + return nodeNum; + } + case Node.COMMENT_NODE: { + final String text = node.getTextContent(); + final int nodeNum = addNode(Node.COMMENT_NODE, level, null); + addChars(nodeNum, text.toCharArray(), 0, text.length()); + next[nodeNum] = parentNodeNum; + return nodeNum; + } + case Node.PROCESSING_INSTRUCTION_NODE: { + final String target = node.getNodeName(); + final String data = node.getNodeValue() != null ? node.getNodeValue() : ""; + final QName qname = new QName(target, "", ""); + final int nodeNum = addNode(Node.PROCESSING_INSTRUCTION_NODE, level, qname); + addChars(nodeNum, data.toCharArray(), 0, data.length()); + next[nodeNum] = parentNodeNum; + return nodeNum; + } + case Node.CDATA_SECTION_NODE: { + final String text = node.getTextContent(); + final int nodeNum = addNode(Node.CDATA_SECTION_NODE, level, null); + addChars(nodeNum, text.toCharArray(), 0, text.length()); + next[nodeNum] = parentNodeNum; + return nodeNum; + } + default: + return -1; + } + } + + /** + * Compact the document by rebuilding all internal arrays from the logical + * tree structure. After in-memory mutations (insert, delete, replace), + * nodes may be appended at the end of the arrays, breaking the positional + * invariant that the XQuery engine relies on for document order. This method + * serializes the mutated tree into a fresh document and replaces the internal + * arrays, restoring correct positional ordering. + * + * Must be called after all mutations and text merging are complete. + */ + public void compact() { + try { + final MemTreeBuilder builder = new MemTreeBuilder(context); + builder.startDocument(); + final DocumentBuilderReceiver receiver = new DocumentBuilderReceiver(builder, true); + receiver.setSuppressWhitespace(false); + + // Walk the document tree in logical order using chain-aware traversal + int child = getFirstChildFor(0); + while (child >= 0) { + if (nodeKind[child] != -1) { + final NodeImpl node = getNode(child); + copyTo(node, receiver, false); + } + child = getNextSiblingFor(child); + } + + builder.endDocument(); + final DocumentImpl newDoc = builder.getDocument(); + + // Replace internal arrays with the rebuilt document's arrays + this.nodeKind = newDoc.nodeKind; + this.treeLevel = newDoc.treeLevel; + this.next = newDoc.next; + this.nodeName = newDoc.nodeName; + this.nodeId = newDoc.nodeId; + this.alpha = newDoc.alpha; + this.alphaLen = newDoc.alphaLen; + this.characters = newDoc.characters; + this.nextChar = newDoc.nextChar; + this.attrName = newDoc.attrName; + this.attrType = newDoc.attrType; + this.attrNodeId = newDoc.attrNodeId; + this.attrParent = newDoc.attrParent; + this.attrValue = newDoc.attrValue; + this.nextAttr = newDoc.nextAttr; + this.namespaceParent = newDoc.namespaceParent; + this.namespaceCode = newDoc.namespaceCode; + this.nextNamespace = newDoc.nextNamespace; + this.size = newDoc.size; + this.references = newDoc.references; + this.nextReferenceIdx = newDoc.nextReferenceIdx; + this.firstChildOverride = null; + } catch (final SAXException e) { + throw new RuntimeException("Failed to compact document after mutations", e); + } + } } diff --git a/exist-core/src/main/java/org/exist/dom/memtree/ElementImpl.java b/exist-core/src/main/java/org/exist/dom/memtree/ElementImpl.java index 514d4d9e0b9..c8d6e21507f 100644 --- a/exist-core/src/main/java/org/exist/dom/memtree/ElementImpl.java +++ b/exist-core/src/main/java/org/exist/dom/memtree/ElementImpl.java @@ -64,15 +64,21 @@ public String getTagName() { @Override public boolean hasChildNodes() { - return (nodeNumber + 1) < document.size && document.treeLevel[nodeNumber + 1] > document.treeLevel[nodeNumber]; + return getFirstChild() != null; } @Override public Node getFirstChild() { - final short level = document.treeLevel[nodeNumber]; - final int nextNode = nodeNumber + 1; - if(nextNode < document.size && document.treeLevel[nextNode] > level) { - return document.getNode(nextNode); + int firstChild = document.getFirstChildFor(nodeNumber); + // Skip deleted nodes (nodeKind == -1) after in-memory mutations + while (firstChild >= 0 && document.nodeKind[firstChild] == -1) { + firstChild = document.next[firstChild]; + if (firstChild < 0 || firstChild <= nodeNumber) { + return null; + } + } + if (firstChild >= 0) { + return document.getNode(firstChild); } return null; } @@ -83,9 +89,11 @@ public NodeList getChildNodes() { final NodeListImpl nl = new NodeListImpl(1); // nil elements are rare, so we use 1 here int nextNode = document.getFirstChildFor(nodeNumber); while(nextNode > nodeNumber) { - final Node n = document.getNode(nextNode); - if(n.getNodeType() != Node.ATTRIBUTE_NODE) { - nl.add(n); + if (document.nodeKind[nextNode] != -1) { + final Node n = document.getNode(nextNode); + if(n.getNodeType() != Node.ATTRIBUTE_NODE) { + nl.add(n); + } } nextNode = document.next[nextNode]; } @@ -300,15 +308,22 @@ public void selectAttributes(final NodeTest test, final Sequence result) throws @Override public void selectDescendantAttributes(final NodeTest test, final Sequence result) throws XPathException { - final int treeLevel = document.treeLevel[nodeNumber]; - int nextNode = nodeNumber; - NodeImpl n = document.getNode(nextNode); - n.selectAttributes(test, result); - while(++nextNode < document.size && document.treeLevel[nextNode] > treeLevel) { - n = document.getNode(nextNode); - if(n.getNodeType() == Node.ELEMENT_NODE) { + // Use chain-based traversal to find descendant attributes, + // including nodes appended by in-memory mutations. + selectAttributes(test, result); + selectDescendantAttributesWalk(nodeNumber, test, result); + } + + private void selectDescendantAttributesWalk(final int parentNum, final NodeTest test, final Sequence result) + throws XPathException { + int child = document.getFirstChildFor(parentNum); + while (child >= 0) { + if (document.nodeKind[child] != -1 && document.nodeKind[child] == Node.ELEMENT_NODE) { + final NodeImpl n = document.getNode(child); n.selectAttributes(test, result); + selectDescendantAttributesWalk(child, test, result); } + child = document.getNextSiblingFor(child); } } @@ -316,9 +331,11 @@ public void selectDescendantAttributes(final NodeTest test, final Sequence resul public void selectChildren(final NodeTest test, final Sequence result) throws XPathException { int nextNode = document.getFirstChildFor(nodeNumber); while(nextNode > nodeNumber) { - final NodeImpl n = document.getNode(nextNode); - if(test.matches(n)) { - result.add(n); + if (document.nodeKind[nextNode] != -1) { + final NodeImpl n = document.getNode(nextNode); + if(test.matches(n)) { + result.add(n); + } } nextNode = document.next[nextNode]; } @@ -333,21 +350,34 @@ public NodeImpl getFirstChild(final NodeTest test) throws XPathException { @Override public void selectDescendants(final boolean includeSelf, final NodeTest test, final Sequence result) throws XPathException { - final int treeLevel = document.treeLevel[nodeNumber]; - int nextNode = nodeNumber; - - if(includeSelf) { - final NodeImpl n = document.getNode(nextNode); - if(test.matches(n)) { + if (includeSelf) { + final NodeImpl n = document.getNode(nodeNumber); + if (test.matches(n)) { result.add(n); } } + // Use chain-based tree walking instead of flat array scanning. + // Flat scanning from nodeNumber+1 misses nodes appended by in-memory + // mutations (insert as first, insert before, etc.) since those are placed + // at positions beyond the original tree. + selectDescendantsWalk(nodeNumber, test, result); + } - while(++nextNode < document.size && document.treeLevel[nextNode] > treeLevel) { - final NodeImpl n = document.getNode(nextNode); - if(test.matches(n)) { - result.add(n); + private void selectDescendantsWalk(final int parentNum, final NodeTest test, final Sequence result) + throws XPathException { + int child = document.getFirstChildFor(parentNum); + while (child >= 0) { + if (document.nodeKind[child] != -1) { + final NodeImpl n = document.getNode(child); + if (test.matches(n)) { + result.add(n); + } + // Recurse into element children + if (document.nodeKind[child] == Node.ELEMENT_NODE) { + selectDescendantsWalk(child, test, result); + } } + child = document.getNextSiblingFor(child); } } diff --git a/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java b/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java index 47f03d4096b..487f346e9e5 100644 --- a/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java +++ b/exist-core/src/main/java/org/exist/dom/memtree/NodeImpl.java @@ -221,14 +221,14 @@ public short getNodeType() { @Override public Node getParentNode() { - int next = document.next[nodeNumber]; - while (next > nodeNumber) { - next = document.next[next]; + if (nodeNumber == 0) { + return null; } - if (next < 0) { + final int parentNum = document.getParentNodeFor(nodeNumber); + if (parentNum < 0) { return null; } - final NodeImpl parent = document.getNode(next); + final NodeImpl parent = document.getNode(parentNum); if (parent.getNodeType() == DOCUMENT_NODE && !((DocumentImpl) parent).isExplicitlyCreated()) { /* All nodes in the MemTree will return an Owner document due to how the MemTree is implemented, @@ -246,17 +246,14 @@ public Node selectParentNode() { if(nodeNumber == 0) { return null; } - int next = document.next[nodeNumber]; - while(next > nodeNumber) { - next = document.next[next]; - } - if(next < 0) { //Is this even possible ? + final int parentNum = document.getParentNodeFor(nodeNumber); + if(parentNum < 0) { return null; } - if(next == 0) { + if(parentNum == 0) { return this.document.explicitlyCreated ? this.document : null; } - return document.getNode(next); + return document.getNode(parentNum); } @Override @@ -273,6 +270,11 @@ public boolean equals(final Object other) { getNodeType() == o.getNodeType(); } + @Override + public int hashCode() { + return System.identityHashCode(document) * 31 + nodeNumber; + } + @Override public boolean equals(final NodeValue other) throws XPathException { if(other.getImplementationType() != NodeValue.IN_MEMORY_NODE) { @@ -309,10 +311,16 @@ public int compareTo(final NodeImpl other) { } else { return Constants.SUPERIOR; } - } else if(document.docId < other.document.docId) { - return Constants.INFERIOR; } else { - return Constants.SUPERIOR; + final long thisDocId = document != null ? document.docId : 0; + final long otherDocId = other.document != null ? other.document.docId : 0; + if (thisDocId < otherDocId) { + return Constants.INFERIOR; + } else if (thisDocId > otherDocId) { + return Constants.SUPERIOR; + } else { + return Constants.EQUAL; + } } } @@ -355,8 +363,23 @@ public Node getPreviousSibling() { @Override public Node getNextSibling() { - final int nextNr = document.next[nodeNumber]; - return nextNr < nodeNumber ? null : document.getNode(nextNr); + int nextNr = document.next[nodeNumber]; + // Skip deleted nodes (nodeKind == -1) in the sibling chain + while (nextNr >= 0 && document.nodeKind[nextNr] == -1) { + nextNr = document.next[nextNr]; + } + if (nextNr < 0) { + return null; + } + if (nextNr < nodeNumber) { + // Backwards reference: check tree level to distinguish sibling from parent. + // After in-memory mutations, siblings may be at lower positions than this node. + if (document.treeLevel[nextNr] == document.treeLevel[nodeNumber]) { + return document.getNode(nextNr); + } + return null; // lower level = parent, no next sibling + } + return document.getNode(nextNr); } @Override @@ -755,6 +778,10 @@ public void selectPreceding(final NodeTest test, final Sequence result, final in int count = 0; for(int i = nodeNumber - 1; i > 0; i--) { + // Skip deleted nodes (soft-deleted by removeNode, nodeKind set to -1) + if(document.nodeKind[i] == -1) { + continue; + } final NodeImpl n = document.getNode(i); if(!myNodeId.isDescendantOf(n.getNodeId()) && test.matches(n)) { if((position < 0) || (++count == position)) { @@ -784,17 +811,15 @@ public void selectFollowing(final NodeTest test, final Sequence result, final in throws XPathException { final int parent = document.getParentNodeFor(nodeNumber); if(parent == 0) { - // parent is the document node - if(getNodeType() == Node.ELEMENT_NODE) { - return; - } + // parent is the document node — walk document-level siblings after this node + final boolean isDocElement = (getNodeType() == Node.ELEMENT_NODE); NodeImpl next = (NodeImpl) getNextSibling(); while(next != null) { - if(test.matches(next)) { + if(!isDocElement && next.getNodeType() == Node.ELEMENT_NODE) { + // Context is before the doc element — include element and its descendants next.selectDescendants(true, test, result); - } - if(next.getNodeType() == Node.ELEMENT_NODE) { - break; + } else if(next.getNodeType() != Node.ELEMENT_NODE && test.matches(next)) { + result.add(next); } next = (NodeImpl) next.getNextSibling(); } @@ -803,6 +828,11 @@ public void selectFollowing(final NodeTest test, final Sequence result, final in int count = 0; int nextNode = nodeNumber + 1; while(nextNode < document.size) { + // Skip deleted nodes (soft-deleted by removeNode, nodeKind set to -1) + if(document.nodeKind[nextNode] == -1) { + nextNode++; + continue; + } final NodeImpl n = document.getNode(nextNode); if(!n.getNodeId().isDescendantOf(myNodeId) && test.matches(n)) { if((position < 0) || (++count == position)) { diff --git a/exist-core/src/main/java/org/exist/dom/persistent/ElementImpl.java b/exist-core/src/main/java/org/exist/dom/persistent/ElementImpl.java index c490a4dfaff..ec98af10fd1 100644 --- a/exist-core/src/main/java/org/exist/dom/persistent/ElementImpl.java +++ b/exist-core/src/main/java/org/exist/dom/persistent/ElementImpl.java @@ -822,6 +822,7 @@ public Attr getAttributeNodeNS(final String namespaceURI, final String localName @Override public NamedNodeMap getAttributes() { final org.exist.dom.NamedNodeMapImpl map = new NamedNodeMapImpl(ownerDocument, true); + if(hasAttributes()) { try(final DBBroker broker = ownerDocument.getBrokerPool().getBroker(); final INodeIterator iterator = broker.getNodeIterator(this)) { @@ -837,6 +838,14 @@ public NamedNodeMap getAttributes() { if(next.getNodeType() != Node.ATTRIBUTE_NODE) { break; } + // Skip namespace declarations for the XML namespace — the xml prefix + // is always implicitly bound and Saxon 12 rejects any explicit + // declaration involving http://www.w3.org/XML/1998/namespace + if (next.getNodeType() == Node.ATTRIBUTE_NODE + && Namespaces.XMLNS_NS.equals(next.getNamespaceURI()) + && XMLConstants.XML_NS_URI.equals(next.getNodeValue())) { + continue; + } map.setNamedItem(next); } } catch(final EXistException | IOException e) { @@ -847,6 +856,13 @@ public NamedNodeMap getAttributes() { for (final Map.Entry entry : namespaceMappings.entrySet()) { final String prefix = entry.getKey(); final String ns = entry.getValue(); + // Skip namespace declarations involving the XML namespace URI — + // Saxon 12 rejects any explicit declaration of the xml prefix + // or binding of the XML namespace to a non-xml prefix + if (XMLConstants.XML_NS_PREFIX.equals(prefix) + || XMLConstants.XML_NS_URI.equals(ns)) { + continue; + } final QName attrName = new QName(prefix, Namespaces.XMLNS_NS, XMLConstants.XMLNS_ATTRIBUTE); final AttrImpl attr = new AttrImpl(getExpression(), attrName, ns, null); attr.setOwnerDocument(ownerDocument); diff --git a/exist-core/src/main/java/org/exist/dom/persistent/NewArrayNodeSet.java b/exist-core/src/main/java/org/exist/dom/persistent/NewArrayNodeSet.java index ed5f630028d..a765adc29d8 100644 --- a/exist-core/src/main/java/org/exist/dom/persistent/NewArrayNodeSet.java +++ b/exist-core/src/main/java/org/exist/dom/persistent/NewArrayNodeSet.java @@ -792,6 +792,7 @@ public NodeSet selectFollowing(final NodeSet pl, final int position, final int c if(!reference.getNodeId().isDescendantOf(nodes[j].getNodeId())) { if(position < 0 || ++n == position) { if (contextId != Expression.IGNORE_CONTEXT + && contextId != Expression.NO_CONTEXT_ID && nodes[j].getContext() != null && reference.getContext() != null && nodes[j].getContext().getContextId() == reference.getContext().getContextId()) { @@ -846,6 +847,7 @@ public NodeSet selectPreceding(final NodeSet pl, final int position, if(!reference.getNodeId().isDescendantOf(nodes[j].getNodeId())) { if(position < 0 || ++n == position) { if (contextId != Expression.IGNORE_CONTEXT + && contextId != Expression.NO_CONTEXT_ID && nodes[j].getContext() != null && reference.getContext() != null && nodes[j].getContext().getContextId() == reference.getContext().getContextId()) { diff --git a/exist-core/src/main/java/org/exist/dom/persistent/SortedNodeSet.java b/exist-core/src/main/java/org/exist/dom/persistent/SortedNodeSet.java index 88ecfb38641..a0a87afe9dd 100644 --- a/exist-core/src/main/java/org/exist/dom/persistent/SortedNodeSet.java +++ b/exist-core/src/main/java/org/exist/dom/persistent/SortedNodeSet.java @@ -86,20 +86,28 @@ public void addAll(final NodeSet other) { try(final DBBroker broker = pool.get(Optional.ofNullable(user))) { final XQueryContext context = new XQueryContext(pool); - final XQueryLexer lexer = new XQueryLexer(context, new StringReader(sortExpr)); - final XQueryParser parser = new XQueryParser(lexer); - final XQueryTreeParser treeParser = new XQueryTreeParser(context); - parser.xpath(); - if(parser.foundErrors()) { - //TODO : error ? - LOG.debug(parser.getErrorMessage()); - } - final AST ast = parser.getAST(); - LOG.debug("generated AST: {}", ast.toStringTree()); - final PathExpr expr = new PathExpr(context); - treeParser.xpath(ast, expr); - if(treeParser.foundErrors()) { - LOG.debug(treeParser.getErrorMessage()); + final PathExpr expr; + if (org.exist.xquery.XQuery.useRdParser()) { + final org.exist.xquery.parser.next.XQueryParser rdParser = + new org.exist.xquery.parser.next.XQueryParser(context, sortExpr); + final Expression rootExpr = rdParser.parse(); + expr = rootExpr instanceof PathExpr ? (PathExpr) rootExpr : new PathExpr(context); + if (!(rootExpr instanceof PathExpr)) { expr.add(rootExpr); } + } else { + expr = new PathExpr(context); + final XQueryLexer lexer = new XQueryLexer(context, new StringReader(sortExpr)); + final XQueryParser parser = new XQueryParser(lexer); + final XQueryTreeParser treeParser = new XQueryTreeParser(context); + parser.xpath(); + if (parser.foundErrors()) { + LOG.debug(parser.getErrorMessage()); + } + final AST ast = parser.getAST(); + LOG.debug("generated AST: {}", ast.toStringTree()); + treeParser.xpath(ast, expr); + if (treeParser.foundErrors()) { + LOG.debug(treeParser.getErrorMessage()); + } } expr.analyze(new AnalyzeContextInfo()); for(final SequenceIterator i = other.iterate(); i.hasNext(); ) { diff --git a/exist-core/src/main/java/org/exist/http/restxq/AnnotationParser.java b/exist-core/src/main/java/org/exist/http/restxq/AnnotationParser.java new file mode 100644 index 00000000000..b9129ed8977 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/AnnotationParser.java @@ -0,0 +1,644 @@ +/* + * 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.http.restxq; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.dom.QName; +import org.exist.xquery.*; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.SequenceType; + +import java.util.*; + +/** + * Parses RESTXQ annotations ({@code %rest:*}, {@code %output:*}) from + * compiled XQuery function signatures and produces {@link Route} objects. + * + *

This replaces the EXQuery library's annotation processing with a + * native implementation that works directly with eXist's type system.

+ */ +public class AnnotationParser { + + private static final Logger LOG = LogManager.getLogger(AnnotationParser.class); + + private static final Set HTTP_METHOD_ANNOTATIONS = Set.of( + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS" + ); + + /** + * Result of parsing a module: both path routes and error handlers. + */ + public static class ParseResult { + public final List routes; + public final List errorRoutes; + + public ParseResult(List routes, List errorRoutes) { + this.routes = routes; + this.errorRoutes = errorRoutes; + } + } + + /** + * Inspects all local functions in the given compiled XQuery and returns + * Routes for any functions that have RESTXQ annotations. + * + * @param compiled the compiled XQuery + * @param moduleUri the database URI of the XQuery module + * @return list of routes found (may be empty) + */ + public static List parseModule(final CompiledXQuery compiled, final String moduleUri) + throws RestXqAnnotationException { + return parseModuleFull(compiled, moduleUri).routes; + } + + /** + * Inspects all local functions and returns both path routes and error handlers. + */ + public static ParseResult parseModuleFull(final CompiledXQuery compiled, final String moduleUri) + throws RestXqAnnotationException { + final List routes = new ArrayList<>(); + final List errorRoutes = new ArrayList<>(); + final Iterator functions = compiled.getContext().localFunctions(); + + while (functions.hasNext()) { + final UserDefinedFunction function = functions.next(); + final Route route = parseFunction(function, moduleUri); + if (route != null) { + routes.add(route); + LOG.debug("Registered RESTXQ route: {}", route); + } + final ErrorRoute errorRoute = parseErrorFunction(function, moduleUri); + if (errorRoute != null) { + // Check for duplicate error handlers in the same module + for (final ErrorRoute existing : errorRoutes) { + for (final ErrorRoute.ErrorCode newCode : errorRoute.getErrorCodes()) { + for (final ErrorRoute.ErrorCode existingCode : existing.getErrorCodes()) { + if (newCode.toString().equals(existingCode.toString())) { + throw new RestXqAnnotationException( + "Duplicate error handler for " + newCode + + " in module " + moduleUri); + } + } + } + } + errorRoutes.add(errorRoute); + LOG.debug("Registered RESTXQ error handler: {}", errorRoute.getFunctionName()); + } + } + + return new ParseResult(routes, errorRoutes); + } + + private static final Set KNOWN_OUTPUT_PARAMS = Set.of( + "method", "media-type", "encoding", "indent", "omit-xml-declaration", + "standalone", "version", "cdata-section-elements", "doctype-public", + "doctype-system", "byte-order-mark", "escape-uri-attributes", + "include-content-type", "normalization-form", "suppress-indentation", + "undeclare-prefixes", "use-character-maps", "html-version", + "item-separator", "json-node-output-method" + ); + + private static final Set KNOWN_REST_ANNOTATIONS = Set.of( + "path", "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", + "method", "consumes", "produces", + "query-param", "form-param", "header-param", "cookie-param", + "error", "error-param", "single" + ); + + /** + * Parses annotations from a single function. Returns null if the + * function has no RESTXQ annotations. Throws on invalid annotations. + */ + static Route parseFunction(final UserDefinedFunction function, final String moduleUri) + throws RestXqAnnotationException { + final Annotation[] annotations = function.getSignature().getAnnotations(); + if (annotations == null || annotations.length == 0) { + return null; + } + + // Check if this function has any RESTXQ annotations at all + boolean hasRestAnnotation = false; + boolean hasOutputAnnotation = false; + boolean hasInputAnnotation = false; + for (final Annotation a : annotations) { + final String ns = a.getName().getNamespaceURI(); + if (RestXqNamespaces.REST_NS.equals(ns)) { + hasRestAnnotation = true; + } else if (RestXqNamespaces.OUTPUT_NS.equals(ns)) { + hasOutputAnnotation = true; + } else if (RestXqNamespaces.INPUT_NS.equals(ns)) { + hasInputAnnotation = true; + } + } + if (!hasRestAnnotation && !hasOutputAnnotation && !hasInputAnnotation) { + return null; + } + + String pathTemplate = null; + int pathAnnotationCount = 0; + final Set methods = new LinkedHashSet<>(); + final Set rawMethodAnnotations = new LinkedHashSet<>(); + boolean hasMethodAnnotation = false; + final Properties outputProperties = new Properties(); + final List consumes = new ArrayList<>(); + final List produces = new ArrayList<>(); + final Map queryParams = new LinkedHashMap<>(); + final Map formParams = new LinkedHashMap<>(); + final Map headerParams = new LinkedHashMap<>(); + final Map cookieParams = new LinkedHashMap<>(); + String bodyVariable = null; + final Properties inputOptions = new Properties(); + final String funcName = function.getSignature().getName().getLocalPart(); + + for (final Annotation annotation : annotations) { + final QName name = annotation.getName(); + final String ns = name.getNamespaceURI(); + final String local = name.getLocalPart(); + final LiteralValue[] values = annotation.getValue(); + + if (RestXqNamespaces.REST_NS.equals(ns)) { + // Validate known annotation names + if (!"error".equals(local) && !"error-param".equals(local) + && !KNOWN_REST_ANNOTATIONS.contains(local)) { + throw new RestXqAnnotationException( + "Unknown RESTXQ annotation %rest:" + local + + " on function " + funcName); + } + + if ("path".equals(local)) { + pathAnnotationCount++; + if (pathAnnotationCount > 1) { + throw new RestXqAnnotationException( + "Duplicate %rest:path annotation on function " + funcName); + } + if (values.length == 0) { + throw new RestXqAnnotationException( + "%rest:path requires a path argument on function " + funcName); + } + if (values.length > 1) { + throw new RestXqAnnotationException( + "%rest:path must have exactly one argument on function " + funcName); + } + pathTemplate = getLiteralString(values, 0); + } else if (HTTP_METHOD_ANNOTATIONS.contains(local.toUpperCase(Locale.ROOT))) { + final String methodUpper = local.toUpperCase(Locale.ROOT); + if (!rawMethodAnnotations.add(local)) { + throw new RestXqAnnotationException( + "Duplicate %rest:" + local + " annotation on function " + funcName); + } + hasMethodAnnotation = true; + if (!methods.add(methodUpper)) { + throw new RestXqAnnotationException( + "Duplicate method " + methodUpper + " on function " + funcName); + } + if (values.length > 0) { + bodyVariable = extractVariableName(getLiteralString(values, 0)); + } + } else if ("method".equals(local)) { + hasMethodAnnotation = true; + if (values.length > 0) { + final String methodName = getLiteralString(values, 0).toUpperCase(Locale.ROOT); + // Check for duplicate method names (across all annotation types) + if (!methods.add(methodName)) { + throw new RestXqAnnotationException( + "Duplicate method " + methodName + " on function " + funcName); + } + } + if (values.length > 1) { + bodyVariable = extractVariableName(getLiteralString(values, 1)); + } + } else if ("consumes".equals(local)) { + for (final LiteralValue v : values) { + consumes.add(literalToString(v)); + } + } else if ("produces".equals(local)) { + for (final LiteralValue v : values) { + produces.add(literalToString(v)); + } + } else if ("query-param".equals(local)) { + parseParamBinding(values, queryParams); + } else if ("form-param".equals(local)) { + parseParamBinding(values, formParams); + } else if ("header-param".equals(local)) { + parseParamBinding(values, headerParams); + } else if ("cookie-param".equals(local)) { + parseParamBinding(values, cookieParams); + } + } else if (RestXqNamespaces.OUTPUT_NS.equals(ns)) { + if (values.length == 0) { + throw new RestXqAnnotationException( + "%output:" + local + " requires a value on function " + funcName); + } + if (values.length > 1) { + throw new RestXqAnnotationException( + "%output:" + local + " must have exactly one value on function " + funcName); + } + // Validate known serialization parameter names + if (!KNOWN_OUTPUT_PARAMS.contains(local)) { + throw new RestXqAnnotationException( + "Unknown serialization parameter %output:" + local + " on function " + funcName); + } + outputProperties.setProperty(local, getLiteralString(values, 0)); + } else if (RestXqNamespaces.INPUT_NS.equals(ns)) { + // %input:json('lax=no'), %input:csv('header=yes'), %input:html('nons=true') + // Parse key=value pairs from annotation values and store as input.type.key=value + for (final LiteralValue v : values) { + final String optStr = literalToString(v); + if (optStr != null) { + parseInputOptions(local, optStr, inputOptions); + } + } + } + } + + // If function has %rest:GET (or other method) but no %rest:path, it's an error + if (hasRestAnnotation && pathTemplate == null) { + // Only error if there's a method annotation without path — pure error handlers are OK + if (hasMethodAnnotation) { + throw new RestXqAnnotationException( + "Function " + funcName + " has HTTP method annotation but no %rest:path"); + } + return null; + } + + if (pathTemplate == null) { + return null; + } + + // Check for %rest:method conflicts with explicit method annotations + for (final String m : methods) { + if (rawMethodAnnotations.contains(m) || rawMethodAnnotations.contains(m.toLowerCase(Locale.ROOT))) { + // Already handled above in the method parsing + } + } + + // If no explicit HTTP method annotation, default to GET + if (methods.isEmpty()) { + methods.add("GET"); + } + + // Validate: GET, HEAD, DELETE, OPTIONS should not have body variable + if (bodyVariable != null) { + final Set noBodyMethods = Set.of("GET", "HEAD", "DELETE", "OPTIONS"); + for (final String m : methods) { + if (noBodyMethods.contains(m)) { + throw new RestXqAnnotationException( + "HTTP method " + m + " must not have a body variable on function " + funcName); + } + } + } + + // Parse and validate the path template + final PathMatcher pathMatcher = PathMatcher.parse(pathTemplate); + + // Validate template variables against function parameters + final SequenceType[] argTypes = function.getSignature().getArgumentTypes(); + final int arity = argTypes != null ? argTypes.length : 0; + final List templateVars = pathMatcher.getVarNames(); + + // Collect all declared variable names from annotations + final Set annotationVars = new LinkedHashSet<>(templateVars); + for (final Route.ParamBinding b : queryParams.values()) { + annotationVars.add(b.getVariableName()); + } + for (final Route.ParamBinding b : formParams.values()) { + annotationVars.add(b.getVariableName()); + } + for (final Route.ParamBinding b : headerParams.values()) { + annotationVars.add(b.getVariableName()); + } + for (final Route.ParamBinding b : cookieParams.values()) { + annotationVars.add(b.getVariableName()); + } + if (bodyVariable != null) { + annotationVars.add(bodyVariable); + } + + // Check that each template variable has a corresponding function parameter + if (argTypes != null) { + final Set paramNames = new LinkedHashSet<>(); + for (final SequenceType st : argTypes) { + if (st instanceof FunctionParameterSequenceType fpst) { + paramNames.add(fpst.getAttributeName()); + } + } + + for (final String tv : templateVars) { + if (!paramNames.contains(tv)) { + throw new RestXqAnnotationException( + "Path template variable {$" + tv + "} has no corresponding function parameter " + + "on function " + funcName); + } + } + + // Check that every function parameter is bound by some annotation + for (final String pn : paramNames) { + if (!annotationVars.contains(pn)) { + throw new RestXqAnnotationException( + "Function parameter $" + pn + " is not bound by any annotation " + + "on function " + funcName); + } + } + + // Check that every annotation variable has a corresponding function parameter + for (final String av : annotationVars) { + if (!paramNames.contains(av)) { + throw new RestXqAnnotationException( + "Annotation variable $" + av + " has no corresponding function parameter " + + "on function " + funcName); + } + } + } else if (!templateVars.isEmpty()) { + throw new RestXqAnnotationException( + "Path template has variables but function " + funcName + " has no parameters"); + } + + return new Route( + moduleUri, + function.getSignature().getName(), + arity, + pathMatcher, + Collections.unmodifiableSet(methods), + outputProperties, + Collections.unmodifiableList(consumes), + Collections.unmodifiableList(produces), + Collections.unmodifiableMap(queryParams), + Collections.unmodifiableMap(formParams), + Collections.unmodifiableMap(headerParams), + Collections.unmodifiableMap(cookieParams), + bodyVariable, + inputOptions + ); + } + + /** + * Parses a %rest:*-param annotation: ("paramName", "{$varName}", default?) + */ + private static void parseParamBinding(final LiteralValue[] values, + final Map target) + throws RestXqAnnotationException { + if (values.length < 2) { + return; + } + // First arg must be a string (the external parameter name) + final String paramName = getLiteralString(values, 0); + if (paramName == null) { + throw new RestXqAnnotationException("Parameter name must be a string"); + } + // Validate first arg is string type (not integer etc.) + try { + if (values[0].getValue().getType() != org.exist.xquery.value.Type.STRING) { + throw new RestXqAnnotationException( + "Parameter name must be a string, got: " + values[0].getValue().getStringValue()); + } + } catch (final XPathException e) { + // ignore type check failures + } + + // Second arg must use {$var} template syntax + final String varTemplate = getLiteralString(values, 1); + if (varTemplate == null || !varTemplate.contains("{") || !varTemplate.contains("$")) { + throw new RestXqAnnotationException( + "Parameter variable must use {$var} template syntax, got: " + varTemplate); + } + final String varName = extractVariableName(varTemplate); + if (varName == null) { + throw new RestXqAnnotationException( + "Invalid variable template: " + varTemplate); + } + + // Check for duplicate param bindings + if (target.containsKey(paramName)) { + throw new RestXqAnnotationException( + "Duplicate parameter binding for '" + paramName + "'"); + } + + final List defaults = new ArrayList<>(); + for (int i = 2; i < values.length; i++) { + final String dv = getLiteralString(values, i); + if (dv != null) { + defaults.add(dv); + } + } + target.put(paramName, new Route.ParamBinding(paramName, varName, defaults)); + } + + /** + * Extracts a variable name from "{$varName}" syntax. + * Returns the name without the $ prefix and curly braces. + */ + static String extractVariableName(final String spec) { + if (spec == null) { + return null; + } + String s = spec.trim(); + if (s.startsWith("{") && s.endsWith("}")) { + s = s.substring(1, s.length() - 1).trim(); + } + if (s.startsWith("$")) { + s = s.substring(1); + } + return s.isEmpty() ? null : s; + } + + private static String getLiteralString(final LiteralValue[] values, final int index) { + if (index >= values.length) { + return null; + } + return literalToString(values[index]); + } + + /** + * Parses input option strings like "lax=no" or "header=yes" from + * %input:json, %input:csv, %input:html annotations. + * Stores as "input.{type}.{key}={value}" in the options Properties. + */ + private static void parseInputOptions(final String type, final String optStr, + final Properties options) { + // Option format: "key=value" or "key=value,key2=value2" + for (final String part : optStr.split(",")) { + final String trimmed = part.trim(); + final int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + final String key = trimmed.substring(0, eqIdx).trim(); + final String value = trimmed.substring(eqIdx + 1).trim(); + options.setProperty("input." + type + "." + key, value); + } + } + } + + private static String literalToString(final LiteralValue value) { + try { + return value.getValue().getStringValue(); + } catch (final XPathException e) { + LOG.warn("Failed to get string value from annotation literal", e); + return null; + } + } + + /** + * Parses %rest:error annotations from a function. + * Returns null if the function has no %rest:error annotation. + */ + static ErrorRoute parseErrorFunction(final UserDefinedFunction function, final String moduleUri) + throws RestXqAnnotationException { + final Annotation[] annotations = function.getSignature().getAnnotations(); + if (annotations == null || annotations.length == 0) { + return null; + } + + final List errorCodes = new ArrayList<>(); + final Map errorParams = new LinkedHashMap<>(); + + for (final Annotation annotation : annotations) { + final QName name = annotation.getName(); + if (!RestXqNamespaces.REST_NS.equals(name.getNamespaceURI())) { + continue; + } + final String local = name.getLocalPart(); + final LiteralValue[] values = annotation.getValue(); + + if ("error".equals(local)) { + for (final LiteralValue value : values) { + final String codeStr = literalToString(value); + if (codeStr != null) { + final ErrorRoute.ErrorCode code = parseErrorCode(codeStr, function); + if (code == null) { + throw new RestXqAnnotationException( + "Invalid error code: " + codeStr); + } + // Check for duplicate error codes + for (final ErrorRoute.ErrorCode existing : errorCodes) { + if (existing.toString().equals(code.toString())) { + throw new RestXqAnnotationException( + "Duplicate error code: " + codeStr); + } + } + errorCodes.add(code); + } + } + } else if ("error-param".equals(local)) { + parseParamBinding(values, errorParams); + } + } + + if (errorCodes.isEmpty()) { + return null; + } + + final SequenceType[] argTypes = function.getSignature().getArgumentTypes(); + final int arity = argTypes != null ? argTypes.length : 0; + + return new ErrorRoute(moduleUri, function.getSignature().getName(), arity, + errorCodes, errorParams); + } + + /** + * Parses an error code pattern string into an ErrorCode. + * Supports: "*", "prefix:*", "*:local", "prefix:local", "Q{uri}local", "Q{uri}*" + */ + private static ErrorRoute.ErrorCode parseErrorCode(final String codeStr, + final UserDefinedFunction function) + throws RestXqAnnotationException { + if ("*".equals(codeStr)) { + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.CATCH_ALL, null, null); + } + + // Q{uri}local or Q{uri}* + if (codeStr.startsWith("Q{")) { + final int closeBrace = codeStr.indexOf('}'); + if (closeBrace > 2) { + final String uri = codeStr.substring(2, closeBrace); + final String localPart = codeStr.substring(closeBrace + 1); + if (localPart.isEmpty()) { + throw new RestXqAnnotationException( + "Invalid EQName in %rest:error — missing local part: " + codeStr); + } + if ("*".equals(localPart)) { + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.NAMESPACE_WILD, uri, null); + } else { + validateNCName(localPart, codeStr); + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.EXACT, uri, localPart); + } + } + throw new RestXqAnnotationException( + "Invalid EQName syntax in %rest:error: " + codeStr); + } + + // prefix:* or *:local or prefix:local + final int colonIdx = codeStr.indexOf(':'); + if (colonIdx > 0) { + final String prefix = codeStr.substring(0, colonIdx); + final String localPart = codeStr.substring(colonIdx + 1); + + if ("*".equals(prefix)) { + // *:local + validateNCName(localPart, codeStr); + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.LOCAL_WILD, null, localPart); + } else if ("*".equals(localPart)) { + // prefix:* — resolve prefix to namespace URI (use prefix as fallback) + final String nsUri = resolvePrefix(prefix, function); + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.NAMESPACE_WILD, + (nsUri != null && !nsUri.isEmpty()) ? nsUri : prefix, null); + } else { + // prefix:local — resolve prefix to namespace URI + validateNCName(localPart, codeStr); + final String nsUri = resolvePrefix(prefix, function); + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.EXACT, + (nsUri != null && !nsUri.isEmpty()) ? nsUri : prefix, localPart); + } + } + + // Bare name — no namespace, validate as NCName + validateNCName(codeStr, codeStr); + return new ErrorRoute.ErrorCode(ErrorRoute.MatchType.EXACT, "", codeStr); + } + + /** + * Validates that a string is a valid XML NCName (no colons, no spaces, + * starts with letter or underscore). + */ + private static void validateNCName(final String name, final String context) + throws RestXqAnnotationException { + if (name == null || name.isEmpty()) { + throw new RestXqAnnotationException( + "Empty name in %rest:error: " + context); + } + if (name.contains(" ")) { + throw new RestXqAnnotationException( + "Invalid name (contains spaces) in %rest:error: " + context); + } + final char first = name.charAt(0); + if (!Character.isLetter(first) && first != '_') { + throw new RestXqAnnotationException( + "Invalid name (must start with letter or _) in %rest:error: " + context); + } + } + + /** + * Resolves a namespace prefix using the function's XQuery context. + */ + private static String resolvePrefix(final String prefix, final UserDefinedFunction function) { + return function.getContext().getURIForPrefix(prefix); + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/CachingHttpServletRequest.java b/exist-core/src/main/java/org/exist/http/restxq/CachingHttpServletRequest.java new file mode 100644 index 00000000000..453aab359f6 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/CachingHttpServletRequest.java @@ -0,0 +1,85 @@ +/* + * 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.http.restxq; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** + * HttpServletRequest wrapper that caches the request body so it can be + * read multiple times. Used for RESTXQ server-side forwards where the + * POST/PUT body must be preserved across route dispatches. + */ +class CachingHttpServletRequest extends HttpServletRequestWrapper { + + private byte[] cachedBody; + + CachingHttpServletRequest(final HttpServletRequest request) { + super(request); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + if (cachedBody == null) { + cachedBody = super.getInputStream().readAllBytes(); + } + return new CachedServletInputStream(cachedBody); + } + + private static class CachedServletInputStream extends ServletInputStream { + private final ByteArrayInputStream delegate; + + CachedServletInputStream(final byte[] data) { + this.delegate = new ByteArrayInputStream(data); + } + + @Override + public int read() { + return delegate.read(); + } + + @Override + public int read(final byte[] b, final int off, final int len) { + return delegate.read(b, off, len); + } + + @Override + public boolean isFinished() { + return delegate.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(final ReadListener readListener) { + // not supported for cached streams + } + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/ErrorRoute.java b/exist-core/src/main/java/org/exist/http/restxq/ErrorRoute.java new file mode 100644 index 00000000000..4ce35dc5767 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/ErrorRoute.java @@ -0,0 +1,125 @@ +/* + * 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.http.restxq; + +import org.exist.dom.QName; + +import java.util.*; + +/** + * Represents a RESTXQ error handler function annotated with {@code %rest:error}. + * + *

Error handlers match XQuery errors by QName with four precedence levels:

+ *
    + *
  1. Exact QName: {@code %rest:error('err:FORG0001')}
  2. + *
  3. Namespace wildcard: {@code %rest:error('err:*')}
  4. + *
  5. Local-name wildcard: {@code %rest:error('*:FORG0001')}
  6. + *
  7. Catch-all: {@code %rest:error('*')}
  8. + *
+ */ +public class ErrorRoute { + + /** Precedence: exact > namespace > local > catch-all */ + public enum MatchType { + EXACT(0), + NAMESPACE_WILD(1), + LOCAL_WILD(2), + CATCH_ALL(3); + + final int priority; + MatchType(int priority) { this.priority = priority; } + } + + /** An error code pattern from a %rest:error annotation. */ + public static class ErrorCode { + private final MatchType matchType; + private final String namespaceURI; // null for catch-all and local-wild + private final String localName; // null for catch-all and namespace-wild + + public ErrorCode(MatchType matchType, String namespaceURI, String localName) { + this.matchType = matchType; + this.namespaceURI = namespaceURI; + this.localName = localName; + } + + public MatchType getMatchType() { return matchType; } + public String getNamespaceURI() { return namespaceURI; } + public String getLocalName() { return localName; } + + public boolean matches(final QName errorQName) { + return switch (matchType) { + case CATCH_ALL -> true; + case EXACT -> localName.equals(errorQName.getLocalPart()) + && Objects.equals(namespaceURI, errorQName.getNamespaceURI()); + case NAMESPACE_WILD -> Objects.equals(namespaceURI, errorQName.getNamespaceURI()); + case LOCAL_WILD -> localName.equals(errorQName.getLocalPart()); + }; + } + + @Override + public String toString() { + return switch (matchType) { + case CATCH_ALL -> "*"; + case EXACT -> (namespaceURI != null ? "Q{" + namespaceURI + "}" : "") + localName; + case NAMESPACE_WILD -> "Q{" + namespaceURI + "}*"; + case LOCAL_WILD -> "*:" + localName; + }; + } + } + + private final String moduleUri; + private final QName functionName; + private final int arity; + private final List errorCodes; + private final Map errorParams; + + public ErrorRoute(final String moduleUri, final QName functionName, final int arity, + final List errorCodes, + final Map errorParams) { + this.moduleUri = moduleUri; + this.functionName = functionName; + this.arity = arity; + this.errorCodes = errorCodes; + this.errorParams = errorParams; + } + + public String getModuleUri() { return moduleUri; } + public QName getFunctionName() { return functionName; } + public int getArity() { return arity; } + public List getErrorCodes() { return errorCodes; } + public Map getErrorParams() { return errorParams; } + + /** + * Returns the best matching error code for the given QName, or null. + */ + public ErrorCode bestMatch(final QName errorQName) { + ErrorCode best = null; + for (final ErrorCode code : errorCodes) { + if (code.matches(errorQName)) { + if (best == null || code.matchType.priority < best.matchType.priority) { + best = code; + } + } + } + return best; + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/NativeRestXqServlet.java b/exist-core/src/main/java/org/exist/http/restxq/NativeRestXqServlet.java new file mode 100644 index 00000000000..67cf8cbb260 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/NativeRestXqServlet.java @@ -0,0 +1,540 @@ +/* + * 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.http.restxq; + +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.http.servlets.AbstractExistHttpServlet; +import org.exist.security.EffectiveSubject; +import org.exist.security.Permission; +import org.exist.security.PermissionDeniedException; +import org.exist.security.Subject; +import org.exist.source.DBSource; +import org.exist.storage.DBBroker; +import org.exist.storage.ProcessMonitor; +import org.exist.dom.persistent.BinaryDocument; +import org.exist.xmldb.XmldbURI; +import org.exist.http.restxq.xquery.WebFunctions; +import org.exist.xquery.*; +import org.exist.xquery.value.*; + +import java.io.IOException; +import java.util.*; + + + +/** + * Native RESTXQ servlet that dispatches HTTP requests to XQuery functions + * annotated with {@code %rest:*} annotations. + * + *

This servlet replaces the old {@code RestXqServlet} from the EXQuery + * extension, eliminating the 10-JAR EXQuery library dependency and the + * adapter layer between EXQuery and eXist types.

+ * + *

Servlet Configuration

+ *

Add to web.xml:

+ *
{@code
+ * 
+ *     NativeRestXqServlet
+ *     org.exist.http.restxq.NativeRestXqServlet
+ *     
+ *         scan-root
+ *         /db/apps
+ *     
+ * 
+ * }
+ */ +public class NativeRestXqServlet extends AbstractExistHttpServlet { + + private static final long serialVersionUID = 1L; + private static final Logger LOG = LogManager.getLogger(NativeRestXqServlet.class); + + /** Default database path to scan for RESTXQ modules. */ + private static final String DEFAULT_SCAN_ROOT = "/db/apps"; + + /** Init parameter for the scan root collection path. */ + private static final String PARAM_SCAN_ROOT = "scan-root"; + + /** Init parameter to scan at startup (default true). */ + private static final String PARAM_SCAN_ON_STARTUP = "scan-on-startup"; + + private RouteRegistry registry; + + @Override + public Logger getLog() { + return LOG; + } + + @Override + public void init(final ServletConfig config) throws ServletException { + super.init(config); + + final String scanRoot = Optional.ofNullable(config.getInitParameter(PARAM_SCAN_ROOT)) + .orElse(DEFAULT_SCAN_ROOT); + + registry = new RouteRegistry(getPool(), scanRoot); + + final boolean scanOnStartup = !"false".equalsIgnoreCase( + config.getInitParameter(PARAM_SCAN_ON_STARTUP)); + + if (scanOnStartup) { + try (final DBBroker broker = getPool().get(Optional.empty())) { + LOG.info("NativeRestXqServlet: pre-scanning RESTXQ modules at startup"); + registry.fullScan(broker); + } catch (final EXistException e) { + LOG.warn("Failed to pre-scan RESTXQ modules at startup: {}", e.getMessage()); + } + } + + LOG.info("NativeRestXqServlet initialized; scan-root={}, scan-on-startup={}", + scanRoot, scanOnStartup); + } + + @Override + protected void service(final HttpServletRequest request, + final HttpServletResponse response) + throws ServletException, IOException { + + // Authenticate + final Subject user = authenticate(request, response); + if (user == null) { + return; // Authentication challenge sent + } + + // Wrap request to cache body for potential forward dispatch + final HttpServletRequest wrappedRequest = + hasBody(request) ? new CachingHttpServletRequest(request) : request; + + // Handle /.init — cache invalidation endpoint + final String pathInfo = getRestXqPath(wrappedRequest); + if ("/.init".equals(pathInfo)) { + registry.invalidate(); + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + return; + } + + final long startTime = System.nanoTime(); + + try (final DBBroker broker = getPool().get(Optional.of(user))) { + + // Ensure the route registry is initialized + registry.ensureInitialized(broker); + + // Find matching route + final String method = wrappedRequest.getMethod().toUpperCase(Locale.ROOT); + Route route = registry.findRoute( + method, pathInfo, + wrappedRequest.getContentType(), + wrappedRequest.getHeader("Accept")); + + boolean headFromGet = false; + + // Auto-handle HEAD: if no explicit HEAD route, try GET + if (route == null && "HEAD".equals(method)) { + route = registry.findRoute("GET", pathInfo, + wrappedRequest.getContentType(), + wrappedRequest.getHeader("Accept")); + if (route != null) { + headFromGet = true; + } + } + + // Auto-handle OPTIONS: if no explicit OPTIONS route, return Allow header + if (route == null && "OPTIONS".equals(method)) { + final Set allowed = registry.allowedMethods(pathInfo); + if (!allowed.isEmpty()) { + allowed.add("OPTIONS"); + allowed.add("HEAD"); + response.setHeader("Allow", String.join(", ", allowed)); + response.setStatus(HttpServletResponse.SC_OK); + return; + } + } + + if (route == null) { + // If modules failed and there are no working routes at all, + // return 500 (the user likely just uploaded a broken module) + final Map failures = registry.getFailedModules(); + if (!failures.isEmpty() && registry.getRouteCount() == 0 + && registry.getModuleCount() == 0) { + final String firstError = failures.values().iterator().next(); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "RESTXQ module error: " + firstError); + return; + } + // Check if path matches with different method → 405 + final Set allowed = registry.allowedMethods(pathInfo, + wrappedRequest.getContentType(), wrappedRequest.getHeader("Accept")); + if (!allowed.isEmpty()) { + response.setHeader("Allow", String.join(", ", allowed)); + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } else { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + return; + } + + if (headFromGet) { + executeRoute(broker, route, wrappedRequest, response, true); + } else { + executeRoute(broker, route, wrappedRequest, response, false); + } + + // Add Server-Timing header + final long durationMs = (System.nanoTime() - startTime) / 1_000_000; + response.addHeader("Server-Timing", "total;dur=" + durationMs); + + } catch (final EXistException e) { + LOG.error("Database error processing RESTXQ request", e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Database error: " + e.getMessage()); + } + } + + /** + * Executes the matched route's XQuery function and writes the result + * to the HTTP response. + */ + private void executeRoute(final DBBroker broker, final Route route, + final HttpServletRequest request, + final HttpServletResponse response, + final boolean headOnly) throws IOException { + + CompiledXQuery xquery = null; + ProcessMonitor processMonitor = null; + + try { + // Compile or retrieve the XQuery module + final XmldbURI moduleUri = XmldbURI.create(route.getModuleUri()); + final BinaryDocument binDoc = (BinaryDocument) broker.getResource(moduleUri, + Permission.READ | Permission.EXECUTE); + if (binDoc == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, + "RESTXQ module not found: " + route.getModuleUri()); + return; + } + + final DBSource source = new DBSource(getPool(), binDoc, true); + final XQuery xqueryService = getPool().getXQueryService(); + final XQueryContext context = new XQueryContext(getPool()); + + context.setModuleLoadPath(XmldbURI.EMBEDDED_SERVER_URI_PREFIX + + moduleUri.removeLastSegment().toString()); + + xquery = xqueryService.compile(context, source); + + // Check eXist security annotations (%auth:*) before execution + final String authDenial = SecurityAnnotationHandler.checkAccess( + broker.getCurrentSubject(), route, xquery); + if (authDenial != null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, authDenial); + return; + } + + // Resolve the function + final UserDefinedFunction fn = context.resolveFunction( + route.getFunctionName(), route.getArity()); + if (fn == null) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "RESTXQ function not found: " + route.getFunctionName() + + "#" + route.getArity()); + return; + } + + // Evaluate global variable declarations (workaround for context.reset()) + final Expression rootExpr = context.getRootExpression(); + for (int i = 0; i < rootExpr.getSubExpressionCount(); i++) { + final Expression subExpr = rootExpr.getSubExpression(i); + if (subExpr instanceof VariableDeclaration) { + subExpr.eval(null, null); + } + } + + // Set up process monitoring + processMonitor = broker.getBrokerPool().getProcessMonitor(); + context.getProfiler().traceQueryStart(); + processMonitor.queryStarted(context.getWatchDog()); + + // Bind parameters + final String restxqPath = getRestXqPath(request); + final SequenceType[] argTypes = fn.getSignature().getArgumentTypes(); + final Sequence[] args = ParameterBinder.bind( + context, route, request, restxqPath, argTypes); + + // Execute the function + try (final FunctionReference fnRef = new FunctionReference( + new FunctionCall(context, fn))) { + + fnRef.analyze(new AnalyzeContextInfo()); + + // Handle setUid/setGid + final Optional effectiveSubject = getEffectiveSubject(xquery); + try { + effectiveSubject.ifPresent(broker::pushSubject); + + final Sequence result = fnRef.evalFunction(null, null, args); + + // Check if this is an explicit HEAD route (not auto-from-GET) + final boolean isExplicitHead = route.getMethods().contains("HEAD") + && "HEAD".equals(request.getMethod().toUpperCase(Locale.ROOT)); + + if (isExplicitHead) { + // Explicit HEAD handler: must return rest:response element + if (result.isEmpty()) { + throw new XPathException((Expression) null, + "HEAD handler must return a rest:response element, got empty sequence"); + } + final Item firstItem = result.itemAt(0); + if (!Type.subTypeOf(firstItem.getType(), Type.ELEMENT)) { + throw new XPathException((Expression) null, + "HEAD handler must return a rest:response element"); + } + final org.w3c.dom.Node node = ((NodeValue) firstItem).getNode(); + if (!"response".equals(node.getLocalName()) + || !RestXqNamespaces.REST_NS.equals(node.getNamespaceURI())) { + throw new XPathException((Expression) null, + "HEAD handler must return a rest:response element, got: " + + node.getLocalName()); + } + // HEAD handler: 200 OK, no body + response.setStatus(HttpServletResponse.SC_OK); + } else if (!headOnly) { + // Normal route execution + if (!response.isCommitted()) { + response.setStatus(HttpServletResponse.SC_OK); + } + ResponseWriter.write(broker, route, result, response); + } else { + // Auto-HEAD from GET: set status and headers but skip body + response.setStatus(HttpServletResponse.SC_OK); + if (response.getContentType() == null) { + response.setContentType(route.getResponseContentType()); + } + } + + } finally { + effectiveSubject.ifPresent(es -> broker.popSubject()); + } + } + + } catch (final WebFunctions.WebErrorException e) { + // web:error() — return clean HTTP error, no stack trace + if (!response.isCommitted()) { + response.sendError(e.getHttpStatusCode(), e.getDetailMessage()); + } + } catch (final XPathException e) { + // Try to find a matching error handler + final org.exist.dom.QName errorQName = e.getErrorCode() != null + ? e.getErrorCode().getErrorQName() + : new org.exist.dom.QName("FOER0000", "http://www.w3.org/2005/xqt-errors", "err"); + final ErrorRoute errorHandler = registry.findErrorHandler(errorQName); + if (errorHandler != null && !response.isCommitted()) { + try { + executeErrorHandler(broker, errorHandler, e, response); + return; + } catch (final Exception ex) { + LOG.error("Error executing RESTXQ error handler", ex); + } + } + LOG.error("XQuery error executing RESTXQ function {}: {}", + route.getFunctionName(), e.getMessage()); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "XQuery error: " + e.getMessage()); + } + } catch (final RestXqForwardException e) { + // Server-side forward: dispatch to the target route + final String forwardPath = "/" + e.getForwardPath(); + final Route forwardRoute = registry.findRoute( + request.getMethod().toUpperCase(Locale.ROOT), forwardPath, + request.getContentType(), request.getHeader("Accept")); + if (forwardRoute != null && !response.isCommitted()) { + executeRoute(broker, forwardRoute, request, response, headOnly); + } else if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, + "Forward target not found: " + forwardPath); + } + } catch (final PermissionDeniedException e) { + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage()); + } + } catch (final Exception e) { + LOG.error("Unexpected error executing RESTXQ function", e); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + e.getMessage()); + } + } finally { + if (processMonitor != null && xquery != null) { + xquery.getContext().getProfiler().traceQueryEnd(xquery.getContext()); + processMonitor.queryCompleted(xquery.getContext().getWatchDog()); + } + } + } + + /** + * Executes a RESTXQ error handler function, binding error parameters. + */ + private void executeErrorHandler(final DBBroker broker, final ErrorRoute errorHandler, + final XPathException error, + final HttpServletResponse response) throws Exception { + + final XmldbURI moduleUri = XmldbURI.create(errorHandler.getModuleUri()); + final BinaryDocument binDoc = (BinaryDocument) broker.getResource(moduleUri, + Permission.READ | Permission.EXECUTE); + if (binDoc == null) { + throw new IOException("Error handler module not found: " + errorHandler.getModuleUri()); + } + + final DBSource source = new DBSource(getPool(), binDoc, true); + final XQuery xqueryService = getPool().getXQueryService(); + final XQueryContext context = new XQueryContext(getPool()); + context.setModuleLoadPath(XmldbURI.EMBEDDED_SERVER_URI_PREFIX + + moduleUri.removeLastSegment().toString()); + + final CompiledXQuery xquery = xqueryService.compile(context, source); + final UserDefinedFunction fn = context.resolveFunction( + errorHandler.getFunctionName(), errorHandler.getArity()); + + // Evaluate global variables + final Expression rootExpr = context.getRootExpression(); + for (int i = 0; i < rootExpr.getSubExpressionCount(); i++) { + final Expression subExpr = rootExpr.getSubExpression(i); + if (subExpr instanceof VariableDeclaration) { + subExpr.eval(null, null); + } + } + + // Bind error parameters + final SequenceType[] argTypes = fn.getSignature().getArgumentTypes(); + final Sequence[] args = new Sequence[argTypes != null ? argTypes.length : 0]; + + // Build error param bindings + final Map errorBindings = new LinkedHashMap<>(); + final org.exist.dom.QName errorQName = error.getErrorCode() != null + ? error.getErrorCode().getErrorQName() + : new org.exist.dom.QName("FOER0000", "http://www.w3.org/2005/xqt-errors", "err"); + errorBindings.put("code", new StringValue("#" + + (errorQName.getPrefix() != null && !errorQName.getPrefix().isEmpty() + ? errorQName.getPrefix() + ":" : "") + + errorQName.getLocalPart())); + errorBindings.put("description", new StringValue( + error.getDetailMessage() != null ? error.getDetailMessage() : "")); + errorBindings.put("module", new StringValue( + error.getSource() != null ? error.getSource().path() : "")); + errorBindings.put("line-number", new IntegerValue(error.getLine())); + errorBindings.put("column-number", new IntegerValue(error.getColumn())); + if (error.getErrorVal() != null) { + errorBindings.put("value", error.getErrorVal()); + } + + for (final Map.Entry entry : errorHandler.getErrorParams().entrySet()) { + final String varName = entry.getValue().getVariableName(); + final String paramName = entry.getValue().getParamName(); + final Sequence val = errorBindings.get(paramName); + if (val != null) { + errorBindings.put(varName, val); + } + } + + // Map to function args + if (argTypes != null) { + for (int i = 0; i < argTypes.length; i++) { + final FunctionParameterSequenceType paramType = (FunctionParameterSequenceType) argTypes[i]; + final Sequence val = errorBindings.get(paramType.getAttributeName()); + args[i] = val != null ? val : Sequence.EMPTY_SEQUENCE; + } + } + + try (final FunctionReference fnRef = new FunctionReference(new FunctionCall(context, fn))) { + fnRef.analyze(new AnalyzeContextInfo()); + final Sequence result = fnRef.evalFunction(null, null, args); + + response.setStatus(HttpServletResponse.SC_OK); + // Use a minimal route for serialization + final Route dummyRoute = new Route(errorHandler.getModuleUri(), + errorHandler.getFunctionName(), errorHandler.getArity(), + PathMatcher.parse("/"), Set.of("GET"), new java.util.Properties(), + List.of(), List.of(), + Map.of(), Map.of(), Map.of(), Map.of(), null, new java.util.Properties()); + ResponseWriter.write(broker, dummyRoute, result, response); + } + } + + /** + * Extracts the RESTXQ-relevant path from the request. + * Strips the servlet context path and any prefix like "/apps". + */ + private String getRestXqPath(final HttpServletRequest request) { + String path = request.getPathInfo(); + if (path == null) { + path = request.getServletPath(); + } + if (path == null || path.isEmpty()) { + path = "/"; + } + return path; + } + + /** + * If the compiled XQuery is setUid and/or setGid, returns the + * EffectiveSubject to use for execution. + */ + private Optional getEffectiveSubject(final CompiledXQuery xquery) { + final org.exist.source.Source src = xquery.getContext().getSource(); + if (src instanceof DBSource dbSrc) { + final Permission perm = dbSrc.getPermissions(); + if (perm.isSetUid()) { + if (perm.isSetGid()) { + return Optional.of(new EffectiveSubject(perm.getOwner(), perm.getGroup())); + } else { + return Optional.of(new EffectiveSubject(perm.getOwner())); + } + } else if (perm.isSetGid()) { + return Optional.of(new EffectiveSubject( + xquery.getContext().getBroker().getCurrentSubject(), perm.getGroup())); + } + } + return Optional.empty(); + } + + /** + * Returns the route registry, for use by XQuery modules like + * rest:resource-functions() and rest:init(). + */ + public RouteRegistry getRouteRegistry() { + return registry; + } + + /** + * Returns true if the request method typically carries a body. + */ + private static boolean hasBody(final HttpServletRequest request) { + final String method = request.getMethod().toUpperCase(Locale.ROOT); + return "POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method); + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/ParameterBinder.java b/exist-core/src/main/java/org/exist/http/restxq/ParameterBinder.java new file mode 100644 index 00000000000..a5839918cbf --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/ParameterBinder.java @@ -0,0 +1,499 @@ +/* + * 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.http.restxq; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.dom.memtree.SAXAdapter; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.*; + +import org.xml.sax.InputSource; +import org.xml.sax.XMLReader; + +import javax.xml.parsers.SAXParserFactory; +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +/** + * Binds HTTP request data to XQuery function parameters based on + * RESTXQ annotations. Handles path variables, query parameters, + * form parameters, header parameters, cookie parameters, and request body. + * + *

Parameter values are automatically cast to the declared XQuery + * function parameter types where possible.

+ */ +public class ParameterBinder { + + private static final Logger LOG = LogManager.getLogger(ParameterBinder.class); + + /** + * Binds all available request data to function arguments according to + * the route's parameter annotations. + * + * @param context the XQuery context + * @param route the matched route + * @param request the HTTP request + * @param requestPath the matched request path (after prefix stripping) + * @param argTypes the function's declared parameter types + * @return array of Sequence values to pass as function arguments + */ + public static Sequence[] bind(final XQueryContext context, + final Route route, + final HttpServletRequest request, + final String requestPath, + final SequenceType[] argTypes) throws XPathException { + + if (argTypes == null || argTypes.length == 0) { + return new Sequence[0]; + } + + // Build a map of variable name → value from all sources + final Map bindings = new LinkedHashMap<>(); + + // 1. Path template variables + final Map pathVars = route.getPathMatcher().extractVariables(requestPath); + for (final Map.Entry entry : pathVars.entrySet()) { + bindings.put(entry.getKey(), new StringValue(entry.getValue())); + } + + // 2. Query parameters + bindParams(route.getQueryParams(), request.getParameterMap(), bindings); + + // 3. Form parameters (only for POST with form content type) + if ("POST".equalsIgnoreCase(request.getMethod()) + && request.getContentType() != null + && request.getContentType().startsWith("application/x-www-form-urlencoded")) { + bindParams(route.getFormParams(), request.getParameterMap(), bindings); + } + + // 4. Header parameters + for (final Map.Entry entry : route.getHeaderParams().entrySet()) { + final String headerName = entry.getValue().getParamName(); + final String varName = entry.getValue().getVariableName(); + final String headerValue = request.getHeader(headerName); + if (headerValue != null) { + bindings.put(varName, new StringValue(headerValue)); + } else if (entry.getValue().getDefaultValue() != null) { + bindings.put(varName, new StringValue(entry.getValue().getDefaultValue())); + } + } + + // 5. Cookie parameters + for (final Map.Entry entry : route.getCookieParams().entrySet()) { + final String cookieName = entry.getValue().getParamName(); + final String varName = entry.getValue().getVariableName(); + String cookieValue = null; + final Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (final Cookie cookie : cookies) { + if (cookieName.equals(cookie.getName())) { + cookieValue = cookie.getValue(); + break; + } + } + } + if (cookieValue != null) { + bindings.put(varName, new StringValue(cookieValue)); + } else if (entry.getValue().getDefaultValue() != null) { + bindings.put(varName, new StringValue(entry.getValue().getDefaultValue())); + } + } + + // 6. Request body (for POST/PUT/PATCH with body variable binding) + if (route.getBodyVariable() != null) { + try { + final Sequence bodyValue = readRequestBody(context, request, route); + if (bodyValue != null) { + bindings.put(route.getBodyVariable(), bodyValue); + } + } catch (final IOException e) { + throw new XPathException((org.exist.xquery.Expression) null, + "Failed to read request body: " + e.getMessage()); + } + } + + // Map bindings to function argument positions + final Sequence[] args = new Sequence[argTypes.length]; + for (int i = 0; i < argTypes.length; i++) { + final FunctionParameterSequenceType paramType = (FunctionParameterSequenceType) argTypes[i]; + final String paramName = paramType.getAttributeName(); + + final Sequence value = bindings.get(paramName); + if (value != null) { + args[i] = castValue(value, paramType.getPrimaryType()); + } else { + args[i] = Sequence.EMPTY_SEQUENCE; + } + } + + return args; + } + + /** + * Binds named parameters from the request parameter map using the + * %rest:*-param annotation bindings. + */ + private static void bindParams(final Map paramBindings, + final Map requestParams, + final Map bindings) { + for (final Map.Entry entry : paramBindings.entrySet()) { + final Route.ParamBinding binding = entry.getValue(); + final String[] values = requestParams.get(binding.getParamName()); + if (values != null && values.length > 0) { + if (values.length == 1) { + bindings.put(binding.getVariableName(), new UntypedAtomicValue(values[0])); + } else { + final ValueSequence seq = new ValueSequence(); + for (final String v : values) { + seq.add(new UntypedAtomicValue(v)); + } + bindings.put(binding.getVariableName(), seq); + } + } else if (!binding.getDefaultValues().isEmpty()) { + final java.util.List defaults = binding.getDefaultValues(); + if (defaults.size() == 1) { + bindings.put(binding.getVariableName(), new UntypedAtomicValue(defaults.get(0))); + } else { + final ValueSequence seq = new ValueSequence(); + for (final String dv : defaults) { + seq.add(new UntypedAtomicValue(dv)); + } + bindings.put(binding.getVariableName(), seq); + } + } + } + } + + /** + * Reads the request body and returns an appropriate XQuery value + * based on the Content-Type. + */ + private static Sequence readRequestBody(final XQueryContext context, + final HttpServletRequest request, + final Route route) + throws IOException, XPathException { + final String contentType = request.getContentType(); + if (contentType == null) { + return null; + } + + final String baseType = contentType.contains(";") + ? contentType.substring(0, contentType.indexOf(';')).trim() + : contentType.trim(); + + // Extract content-type parameters (e.g., "lax=false" from "application/json;lax=false") + final java.util.Properties ctParams = parseContentTypeParams(contentType); + + try (final InputStream is = request.getInputStream()) { + if ("application/xml".equals(baseType) || "text/xml".equals(baseType) + || baseType.endsWith("+xml")) { + return parseXmlBody(context, is); + } else if ("application/json".equals(baseType) || baseType.endsWith("+json")) { + final String encoding = request.getCharacterEncoding() != null + ? request.getCharacterEncoding() : "UTF-8"; + final String jsonStr = new String(is.readAllBytes(), encoding); + // Determine lax mode: %input:json annotation > content-type param > default (no) + final boolean lax = resolveJsonLax(route, ctParams); + return parseJsonToXml(context, jsonStr, lax); + } else if ("text/csv".equals(baseType)) { + final String encoding = request.getCharacterEncoding() != null + ? request.getCharacterEncoding() : "UTF-8"; + final String csvStr = new String(is.readAllBytes(), encoding); + // Determine header mode: %input:csv annotation > content-type param > default (no) + final boolean header = resolveCsvHeader(route, ctParams); + return parseCsvToXml(context, csvStr, header); + } else if (baseType.startsWith("text/")) { + return new StringValue(new String(is.readAllBytes(), request.getCharacterEncoding() != null + ? request.getCharacterEncoding() : "UTF-8")); + } else { + // Binary body + return BinaryValueFromInputStream.getInstance(context, + new Base64BinaryValueType(), is, null); + } + } + } + + private static Sequence parseXmlBody(final XQueryContext context, + final InputStream is) throws XPathException { + try { + final SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + final XMLReader reader = factory.newSAXParser().getXMLReader(); + final SAXAdapter adapter = new SAXAdapter(context); + reader.setContentHandler(adapter); + reader.parse(new InputSource(is)); + return adapter.getDocument(); + } catch (final Exception e) { + throw new XPathException((org.exist.xquery.Expression) null, + "Failed to parse XML request body: " + e.getMessage()); + } + } + + /** + * Casts a string value to the target XQuery type if needed. + */ + private static Sequence castValue(final Sequence value, final int targetType) throws XPathException { + if (targetType == Type.ITEM || targetType == Type.STRING || targetType == Type.ANY_TYPE) { + return value; + } + + // If it's already the right type, return as-is + if (value.hasOne()) { + final Item item = value.itemAt(0); + if (item.getType() == targetType || Type.subTypeOf(item.getType(), targetType)) { + return value; + } + // Try automatic casting + if (item instanceof AtomicValue) { + return ((AtomicValue) item).convertTo(targetType); + } + } + + return value; + } + + /** + * Parses content-type parameters (everything after the semicolon). + * E.g., "application/json;lax=false" → {"lax": "false"} + */ + private static java.util.Properties parseContentTypeParams(final String contentType) { + final java.util.Properties params = new java.util.Properties(); + if (contentType == null || !contentType.contains(";")) { + return params; + } + final String paramPart = contentType.substring(contentType.indexOf(';') + 1); + for (final String part : paramPart.split(";")) { + final String trimmed = part.trim(); + final int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + params.setProperty( + trimmed.substring(0, eqIdx).trim().toLowerCase(java.util.Locale.ROOT), + trimmed.substring(eqIdx + 1).trim()); + } + } + return params; + } + + /** + * Resolves JSON lax mode from %input:json annotation, content-type params, or default. + */ + private static boolean resolveJsonLax(final Route route, final java.util.Properties ctParams) { + // 1. Check %input:json('lax=...') annotation + final String annotationLax = route.getInputOptions().getProperty("input.json.lax"); + if (annotationLax != null) { + return "yes".equalsIgnoreCase(annotationLax) || "true".equalsIgnoreCase(annotationLax); + } + // 2. Check content-type parameter (e.g., application/json;lax=yes) + final String ctLax = ctParams.getProperty("lax"); + if (ctLax != null) { + return "yes".equalsIgnoreCase(ctLax) || "true".equalsIgnoreCase(ctLax); + } + // 3. Default: lax=no (strict mode — underscores doubled) + return false; + } + + /** + * Resolves CSV header mode from %input:csv annotation, content-type params, or default. + */ + private static boolean resolveCsvHeader(final Route route, final java.util.Properties ctParams) { + // 1. Check %input:csv('header=...') annotation + final String annotationHeader = route.getInputOptions().getProperty("input.csv.header"); + if (annotationHeader != null) { + return "yes".equalsIgnoreCase(annotationHeader) || "true".equalsIgnoreCase(annotationHeader); + } + // 2. Check content-type parameter (e.g., text/csv;header=yes) + final String ctHeader = ctParams.getProperty("header"); + if (ctHeader != null) { + return "yes".equalsIgnoreCase(ctHeader) || "true".equalsIgnoreCase(ctHeader); + } + // 3. Default: header=no + return false; + } + + /** + * Parses JSON string to XML using the BaseX-compatible "direct" format. + * JSON keys become element names. With lax=false (default), characters + * invalid in XML names are escaped by doubling underscores. + */ + private static Sequence parseJsonToXml(final XQueryContext context, + final String json, final boolean lax) + throws XPathException { + try { + context.pushDocumentContext(); + final org.exist.dom.memtree.MemTreeBuilder builder = context.getDocumentBuilder(); + builder.startDocument(); + + final com.fasterxml.jackson.core.JsonFactory factory = new com.fasterxml.jackson.core.JsonFactory(); + try (final com.fasterxml.jackson.core.JsonParser parser = factory.createParser(json)) { + parser.nextToken(); // Move to first token + jsonTokenToXml(builder, "json", parser, lax); + } + + builder.endDocument(); + return builder.getDocument(); + } catch (final IOException e) { + throw new XPathException((org.exist.xquery.Expression) null, + "Failed to parse JSON body: " + e.getMessage()); + } finally { + context.popDocumentContext(); + } + } + + private static void jsonTokenToXml( + final org.exist.dom.memtree.MemTreeBuilder builder, + final String name, + final com.fasterxml.jackson.core.JsonParser parser, + final boolean lax) throws IOException, XPathException { + final String xmlName = lax ? name : escapeJsonName(name); + final org.exist.dom.QName qname = qname(xmlName); + + final com.fasterxml.jackson.core.JsonToken token = parser.currentToken(); + if (token == com.fasterxml.jackson.core.JsonToken.START_OBJECT) { + builder.startElement(qname, null); + while (parser.nextToken() != com.fasterxml.jackson.core.JsonToken.END_OBJECT) { + final String fieldName = parser.currentName(); + parser.nextToken(); + jsonTokenToXml(builder, fieldName, parser, lax); + } + builder.endElement(); + } else if (token == com.fasterxml.jackson.core.JsonToken.START_ARRAY) { + builder.startElement(qname, null); + while (parser.nextToken() != com.fasterxml.jackson.core.JsonToken.END_ARRAY) { + jsonTokenToXml(builder, "_", parser, lax); + } + builder.endElement(); + } else if (token == com.fasterxml.jackson.core.JsonToken.VALUE_STRING) { + builder.startElement(qname, null); + final String text = parser.getText(); + if (!text.isEmpty()) { + builder.characters(text); + } + builder.endElement(); + } else if (token == com.fasterxml.jackson.core.JsonToken.VALUE_NUMBER_INT + || token == com.fasterxml.jackson.core.JsonToken.VALUE_NUMBER_FLOAT) { + builder.startElement(qname, null); + builder.characters(parser.getText()); + builder.endElement(); + } else if (token == com.fasterxml.jackson.core.JsonToken.VALUE_TRUE + || token == com.fasterxml.jackson.core.JsonToken.VALUE_FALSE) { + builder.startElement(qname, null); + builder.characters(parser.getText()); + builder.endElement(); + } else { + // null + builder.startElement(qname, null); + builder.endElement(); + } + } + + /** + * Escapes a JSON key for use as an XML element name (non-lax mode). + * Underscores are doubled, other invalid chars replaced with underscore+hex. + */ + private static String escapeJsonName(final String name) { + final StringBuilder result = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + final char c = name.charAt(i); + if (c == '_') { + result.append("__"); + } else if (i == 0 && !Character.isLetter(c) && c != '_') { + result.append('_').append(String.format("%04x", (int) c)); + } else if (!Character.isLetterOrDigit(c) && c != '_' && c != '-' && c != '.') { + result.append('_').append(String.format("%04x", (int) c)); + } else { + result.append(c); + } + } + return result.toString(); + } + + /** + * Parses CSV string to XML using the BaseX-compatible format. + * With header=true, first row values become element names. + * With header=false, values are wrapped in generic <entry> elements. + */ + private static Sequence parseCsvToXml(final XQueryContext context, + final String csv, final boolean header) + throws XPathException { + context.pushDocumentContext(); + try { + final org.exist.dom.memtree.MemTreeBuilder builder = context.getDocumentBuilder(); + builder.startDocument(); + builder.startElement(qname("csv"), null); + + final String[] lines = csv.split("\r?\n"); + String[] headers = null; + int startLine = 0; + + if (header && lines.length > 0) { + headers = parseCsvLine(lines[0]); + startLine = 1; + } + + for (int i = startLine; i < lines.length; i++) { + if (lines[i].trim().isEmpty()) { + continue; + } + builder.startElement(qname("record"), null); + final String[] fields = parseCsvLine(lines[i]); + for (int f = 0; f < fields.length; f++) { + final String elemName = (headers != null && f < headers.length) + ? headers[f] : "entry"; + builder.startElement(qname(elemName), null); + builder.characters(fields[f]); + builder.endElement(); + } + builder.endElement(); + } + + builder.endElement(); // csv + builder.endDocument(); + return builder.getDocument(); + } finally { + context.popDocumentContext(); + } + } + + /** + * Simple CSV line parser. Handles basic comma-separated values. + */ + private static String[] parseCsvLine(final String line) { + return line.split(",", -1); + } + + /** + * Creates a QName, wrapping the checked exception. + */ + private static org.exist.dom.QName qname(final String localName) throws XPathException { + try { + return new org.exist.dom.QName(localName); + } catch (final org.exist.dom.QName.IllegalQNameException e) { + throw new XPathException((org.exist.xquery.Expression) null, + "Invalid element name: " + localName); + } + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/PathMatcher.java b/exist-core/src/main/java/org/exist/http/restxq/PathMatcher.java new file mode 100644 index 00000000000..b33b86173ff --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/PathMatcher.java @@ -0,0 +1,299 @@ +/* + * 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.http.restxq; + +import java.math.BigInteger; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Matches HTTP request paths against RESTXQ path templates and extracts + * template variable values. + * + *

Supports three kinds of path segments:

+ *
    + *
  • Literal segments: {@code /users/list}
  • + *
  • Template variables: {@code /users/{$id}}
  • + *
  • Regex-constrained variables: {@code /users/{$id=[0-9]+}}
  • + *
+ * + *

Implements {@link Comparable} for specificity-based route precedence: + * more segments wins; at equal segment count, literal segments beat + * template variables.

+ * + *

The path matching approach follows the BaseX model for cross-engine + * RESTXQ compatibility.

+ */ +public class PathMatcher implements Comparable { + + private final String template; + private final Pattern pattern; + private final List varNames; + private final int segmentCount; + private final BigInteger templatePositions; + + private PathMatcher(final String template, final Pattern pattern, + final List varNames, final int segmentCount, + final BigInteger templatePositions) { + this.template = template; + this.pattern = pattern; + this.varNames = varNames; + this.segmentCount = segmentCount; + this.templatePositions = templatePositions; + } + + /** + * Parses a RESTXQ path template into a PathMatcher. + * + * @param pathTemplate the path template string, e.g. "/users/{$id=[0-9]+}/posts" + * @return a compiled PathMatcher + * @throws IllegalArgumentException if the template is malformed + */ + public static PathMatcher parse(final String pathTemplate) { + if (pathTemplate == null) { + throw new IllegalArgumentException("Path template must not be null"); + } + + // Empty path or "/" both match the root + if (pathTemplate.isEmpty() || "/".equals(pathTemplate)) { + final Pattern rootPattern = Pattern.compile("^/?$"); + return new PathMatcher(pathTemplate, rootPattern, + Collections.emptyList(), 0, BigInteger.ZERO); + } + + final List varNames = new ArrayList<>(); + final StringBuilder regex = new StringBuilder(); + int segmentCount = 0; + BigInteger templatePositions = BigInteger.ZERO; + + // Ensure leading slash + final String path = pathTemplate.startsWith("/") ? pathTemplate : "/" + pathTemplate; + + int i = 0; + while (i < path.length()) { + final char c = path.charAt(i); + + if (c == '{') { + // Template variable + final int close = path.indexOf('}', i); + if (close == -1) { + throw new IllegalArgumentException( + "Unclosed template variable at position " + i + " in: " + pathTemplate); + } + + final String rawVarSpec = path.substring(i + 1, close); + + // Must start with $ (RESTXQ variable syntax) + if (!rawVarSpec.startsWith("$")) { + throw new IllegalArgumentException( + "Template variable must start with $ in: " + pathTemplate); + } + + String varSpec = rawVarSpec.substring(1); + + // Validate no spaces in variable spec + if (varSpec.contains(" ")) { + throw new IllegalArgumentException( + "Template variable must not contain spaces in: " + pathTemplate); + } + + String varName; + String varRegex; + + final int eqIdx = varSpec.indexOf('='); + if (eqIdx >= 0) { + // Regex-constrained variable: {$id=[0-9]+} + varName = varSpec.substring(0, eqIdx); + varRegex = varSpec.substring(eqIdx + 1); + } else { + varName = varSpec; + varRegex = "[^/]+?"; + } + + // Validate variable name (XQuery QName rules: no colons within local part, + // but allow namespace prefix like m:x) + if (varName.isEmpty()) { + throw new IllegalArgumentException( + "Empty variable name in template: " + pathTemplate); + } + if (varName.contains("::") || varName.contains(" ")) { + throw new IllegalArgumentException( + "Invalid variable name '" + varName + "' in template: " + pathTemplate); + } + + // Check for duplicate variable names + if (varNames.contains(varName)) { + throw new IllegalArgumentException( + "Duplicate variable {$" + varName + "} in template: " + pathTemplate); + } + + varNames.add(varName); + regex.append('(').append(varRegex).append(')'); + + // Mark this segment as a template + templatePositions = templatePositions.setBit(segmentCount > 0 ? segmentCount - 1 : 0); + + i = close + 1; + } else if (c == '/') { + regex.append('/'); + segmentCount++; + i++; + } else { + // Literal segment — accumulate, decode, and escape for regex + int j = i; + while (j < path.length() && path.charAt(j) != '/' && path.charAt(j) != '{') { + j++; + } + final String literal = path.substring(i, j); + // URL-decode the literal segment (e.g., %7b → {, %20 → space, + → space) + final String decoded = decodePath(literal); + regex.append(Pattern.quote(decoded)); + i = j; + } + } + + final Pattern compiled = Pattern.compile("^" + regex + "$"); + return new PathMatcher(pathTemplate, compiled, varNames, segmentCount, templatePositions); + } + + /** + * Tests whether the given request path matches this template. + * + * @param requestPath the request path (must start with "/") + * @return true if the path matches + */ + public boolean matches(final String requestPath) { + return pattern.matcher(requestPath).matches(); + } + + /** + * Extracts template variable values from a matching request path. + * + * @param requestPath the request path + * @return map of variable name to captured value, or empty map if no match + */ + public Map extractVariables(final String requestPath) { + final Matcher m = pattern.matcher(requestPath); + if (!m.matches()) { + return Collections.emptyMap(); + } + + final Map result = new LinkedHashMap<>(); + for (int g = 0; g < varNames.size(); g++) { + result.put(varNames.get(g), m.group(g + 1)); + } + return result; + } + + /** + * Returns the original path template string. + */ + public String getTemplate() { + return template; + } + + /** + * Returns the variable names declared in the template, in order. + */ + public List getVarNames() { + return Collections.unmodifiableList(varNames); + } + + /** + * Returns the number of path segments (separated by '/'). + */ + public int getSegmentCount() { + return segmentCount; + } + + /** + * Specificity comparison for route precedence. + * + *

More segments = more specific. At equal segment count, + * literal segments beat template variables (checked left-to-right).

+ */ + @Override + public int compareTo(final PathMatcher other) { + // More segments = more specific (sort first) + final int segDiff = other.segmentCount - this.segmentCount; + if (segDiff != 0) { + return segDiff; + } + + // Same segment count: compare segment-by-segment + // A template position (bit set) is LESS specific than a literal (bit unset) + for (int s = 0; s < this.segmentCount; s++) { + final boolean thisIsTemplate = this.templatePositions.testBit(s); + final boolean otherIsTemplate = other.templatePositions.testBit(s); + if (thisIsTemplate != otherIsTemplate) { + // literal (not template) is more specific → sort first + return thisIsTemplate ? 1 : -1; + } + } + + return 0; + } + + @Override + public String toString() { + return template; + } + + /** + * URL-decodes a path string, converting percent-encoded characters and + * {@code +} to space. Throws IllegalArgumentException for invalid encodings. + */ + static String decodePath(final String path) { + if (path == null || path.isEmpty()) { + return path; + } + // Check for invalid percent encoding and decode only percent sequences + // (NOT +, which is literal in URI paths — only means space in query strings) + final StringBuilder result = new StringBuilder(path.length()); + for (int i = 0; i < path.length(); i++) { + final char c = path.charAt(i); + if (c == '%') { + if (i + 2 >= path.length()) { + throw new IllegalArgumentException("Invalid percent encoding in path: " + path); + } + final char c1 = path.charAt(i + 1); + final char c2 = path.charAt(i + 2); + if (!isHexDigit(c1) || !isHexDigit(c2)) { + throw new IllegalArgumentException("Invalid percent encoding in path: " + path); + } + result.append((char) Integer.parseInt(path.substring(i + 1, i + 3), 16)); + i += 2; + } else if (c == '+') { + // In RESTXQ path templates, + means space (following URL convention) + result.append(' '); + } else { + result.append(c); + } + } + return result.toString(); + } + + private static boolean isHexDigit(final char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/ResponseWriter.java b/exist-core/src/main/java/org/exist/http/restxq/ResponseWriter.java new file mode 100644 index 00000000000..7f651c3dbd7 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/ResponseWriter.java @@ -0,0 +1,342 @@ +/* + * 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.http.restxq; + +import jakarta.servlet.http.HttpServletResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.storage.DBBroker; +import org.exist.util.serializer.XQuerySerializer; +import org.exist.xquery.XPathException; +import org.exist.xquery.value.*; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import java.util.Set; + +/** + * Serializes XQuery function results to HTTP responses, handling the + * RESTXQ response protocol including {@code } elements, + * {@code }, and binary output. + */ +public class ResponseWriter { + + private static final Logger LOG = LogManager.getLogger(ResponseWriter.class); + + /** + * Writes the XQuery result sequence to the HTTP response, applying + * serialization properties from the route's %output:* annotations. + * + *

Handles these special cases:

+ *
    + *
  • {@code } — sets custom status code and headers
  • + *
  • {@code } — server-side forward (not yet implemented)
  • + *
  • Binary results — written directly to output stream
  • + *
  • Node/atomic results — serialized via XQuerySerializer
  • + *
+ */ + public static void write(final DBBroker broker, final Route route, + final Sequence result, + final HttpServletResponse response) throws IOException { + + final Properties outputProperties = new Properties(route.getOutputProperties()); + + // Check first item for rest:response or rest:forward + Sequence bodySequence = result; + boolean statusExplicitlySet = false; + + if (result.getItemCount() > 0) { + try { + final Item firstItem = result.itemAt(0); + if (isRestResponseElement(firstItem)) { + statusExplicitlySet = processRestResponse((NodeValue) firstItem, response, outputProperties); + // Body is everything after the rest:response element + if (result.getItemCount() > 1) { + final ValueSequence remaining = new ValueSequence(); + for (int i = 1; i < result.getItemCount(); i++) { + remaining.add(result.itemAt(i)); + } + bodySequence = remaining; + } else { + bodySequence = Sequence.EMPTY_SEQUENCE; + } + } else if (isRestForwardElement(firstItem)) { + // Return the forward path — the servlet handles internal dispatch + final String forwardPath = firstItem.getStringValue().trim(); + throw new RestXqForwardException(forwardPath); + } + } catch (final XPathException e) { + LOG.error("Error processing RESTXQ response", e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + return; + } + } + + if (bodySequence.isEmpty()) { + if (!response.isCommitted() && !statusExplicitlySet) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + return; + } + + // Set Content-Type if not already set by rest:response + if (response.getContentType() == null) { + response.setContentType(route.getResponseContentType()); + } + + // Serialize the body + try { + serializeSequence(broker, bodySequence, outputProperties, response); + } catch (final XPathException | SAXException e) { + LOG.error("Error serializing RESTXQ response", e); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Serialization error: " + e.getMessage()); + } + } + } + + private static boolean isRestResponseElement(final Item item) throws XPathException { + if (Type.subTypeOf(item.getType(), Type.ELEMENT)) { + final NodeValue node = (NodeValue) item; + final Node n = node.getNode(); + return "response".equals(n.getLocalName()) + && RestXqNamespaces.REST_NS.equals(n.getNamespaceURI()); + } + return false; + } + + private static boolean isRestForwardElement(final Item item) throws XPathException { + if (Type.subTypeOf(item.getType(), Type.ELEMENT)) { + final NodeValue node = (NodeValue) item; + final Node n = node.getNode(); + return "forward".equals(n.getLocalName()) + && RestXqNamespaces.REST_NS.equals(n.getNamespaceURI()); + } + return false; + } + + /** + * Validates and processes a {@code } element, extracting + * HTTP status, headers, and serialization parameters. + * + * @return true if an HTTP status was explicitly set + * @throws IOException if the rest:response element has invalid structure + */ + private static boolean processRestResponse(final NodeValue responseNode, + final HttpServletResponse response, + final Properties outputProperties) + throws IOException { + final Node node = responseNode.getNode(); + + // Validate: no unknown attributes on rest:response + final org.w3c.dom.NamedNodeMap attrs = node.getAttributes(); + if (attrs != null) { + for (int a = 0; a < attrs.getLength(); a++) { + final Node attr = attrs.item(a); + final String attrNs = attr.getNamespaceURI(); + // Allow xmlns declarations, reject anything else + if (attrNs == null || !attrNs.equals("http://www.w3.org/2000/xmlns/")) { + final String attrName = attr.getLocalName() != null ? attr.getLocalName() : attr.getNodeName(); + if (!"xmlns".equals(attrName) && !attrName.startsWith("xmlns:")) { + throw new IOException( + "Invalid attribute '" + attrName + "' on rest:response element"); + } + } + } + } + + final NodeList children = node.getChildNodes(); + boolean statusSet = false; + + for (int i = 0; i < children.getLength(); i++) { + final Node child = children.item(i); + + // Validate: no text content in rest:response + if (child.getNodeType() == Node.TEXT_NODE) { + final String text = child.getTextContent(); + if (text != null && !text.trim().isEmpty()) { + throw new IOException( + "rest:response must not contain text content"); + } + continue; + } + + if (child.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + + final String childNs = child.getNamespaceURI(); + final String childLocal = child.getLocalName(); + + if (RestXqNamespaces.HTTP_NS.equals(childNs) && "response".equals(childLocal)) { + statusSet = processHttpResponse((Element) child, response); + } else if (RestXqNamespaces.OUTPUT_NS.equals(childNs) + && "serialization-parameters".equals(childLocal)) { + processSerializationParams((Element) child, outputProperties); + } else { + // Validate: only http:response and output:serialization-parameters allowed + throw new IOException( + "Invalid child element in rest:response: " + + (childNs != null ? "{" + childNs + "}" : "") + childLocal); + } + } + return statusSet; + } + + /** + * @return true if status was explicitly set + */ + private static final Set VALID_HTTP_RESPONSE_ATTRS = Set.of( + "status", "reason", "message" + ); + + private static boolean processHttpResponse(final Element httpResponse, + final HttpServletResponse response) + throws IOException { + // Validate attributes: only status and reason/message allowed + final org.w3c.dom.NamedNodeMap attrs = httpResponse.getAttributes(); + if (attrs != null) { + for (int a = 0; a < attrs.getLength(); a++) { + final Node attr = attrs.item(a); + final String attrNs = attr.getNamespaceURI(); + final String attrName = attr.getLocalName() != null ? attr.getLocalName() : attr.getNodeName(); + if (attrNs != null && attrNs.equals("http://www.w3.org/2000/xmlns/")) { + continue; + } + if ("xmlns".equals(attrName) || attrName.startsWith("xmlns:")) { + continue; + } + if (!VALID_HTTP_RESPONSE_ATTRS.contains(attrName)) { + throw new IOException( + "Invalid attribute '" + attrName + "' on http:response element"); + } + } + } + + boolean statusSet = false; + final String status = httpResponse.getAttribute("status"); + if (status != null && !status.isEmpty()) { + try { + response.setStatus(Integer.parseInt(status)); + statusSet = true; + } catch (final NumberFormatException e) { + LOG.warn("Invalid HTTP status in rest:response: {}", status); + } + } + + // Process children: only http:header elements allowed, no text content + final NodeList children = httpResponse.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + final Node child = children.item(i); + + if (child.getNodeType() == Node.TEXT_NODE) { + final String text = child.getTextContent(); + if (text != null && !text.trim().isEmpty()) { + throw new IOException( + "http:response must not contain text content"); + } + continue; + } + + if (child.getNodeType() == Node.ELEMENT_NODE + && "header".equals(child.getLocalName()) + && RestXqNamespaces.HTTP_NS.equals(child.getNamespaceURI())) { + final Element headerElem = (Element) child; + final String name = headerElem.getAttribute("name"); + final String value = headerElem.getAttribute("value"); + if (name != null && !name.isEmpty()) { + response.addHeader(name, value); + if ("Content-Type".equalsIgnoreCase(name)) { + response.setContentType(value); + } + } + } + } + return statusSet; + } + + private static void processSerializationParams(final Element params, + final Properties outputProperties) { + final NodeList children = params.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + final Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE + && RestXqNamespaces.OUTPUT_NS.equals(child.getNamespaceURI())) { + final Element param = (Element) child; + final String value = param.getAttribute("value"); + if (value != null && !value.isEmpty()) { + outputProperties.setProperty(param.getLocalName(), value); + } + } + } + } + + private static void handleForward(final NodeValue forwardNode, + final HttpServletResponse response) throws IOException { + final String path = forwardNode.getNode().getTextContent(); + if (path != null && !path.isEmpty()) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.sendRedirect(path); + } + } + + private static void serializeSequence(final DBBroker broker, + final Sequence sequence, + final Properties outputProperties, + final HttpServletResponse response) + throws IOException, XPathException, SAXException { + + // Handle binary results + if (sequence.hasOne()) { + final Item item = sequence.itemAt(0); + if (item instanceof BinaryValue) { + writeBinaryResult((BinaryValue) item, response); + return; + } + } + + // Serialize using XQuerySerializer + final OutputStream os = response.getOutputStream(); + try (final Writer writer = new OutputStreamWriter(os, StandardCharsets.UTF_8)) { + final XQuerySerializer serializer = new XQuerySerializer(broker, outputProperties, writer); + serializer.serialize(sequence); + writer.flush(); + } + } + + private static void writeBinaryResult(final BinaryValue binary, + final HttpServletResponse response) throws IOException { + try (final OutputStream os = response.getOutputStream()) { + binary.streamBinaryTo(os); + os.flush(); + } + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/RestXqAnnotationException.java b/exist-core/src/main/java/org/exist/http/restxq/RestXqAnnotationException.java new file mode 100644 index 00000000000..f18cdc2b333 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/RestXqAnnotationException.java @@ -0,0 +1,31 @@ +/* + * 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.http.restxq; + +/** + * Thrown when RESTXQ annotation validation fails during module scanning. + */ +public class RestXqAnnotationException extends Exception { + public RestXqAnnotationException(final String message) { + super(message); + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/RestXqForwardException.java b/exist-core/src/main/java/org/exist/http/restxq/RestXqForwardException.java new file mode 100644 index 00000000000..07032e9c843 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/RestXqForwardException.java @@ -0,0 +1,39 @@ +/* + * 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.http.restxq; + +/** + * Thrown when a RESTXQ function returns a {@code } element, + * indicating that the request should be internally dispatched to another route. + */ +public class RestXqForwardException extends java.io.IOException { + private final String forwardPath; + + public RestXqForwardException(final String forwardPath) { + super("Forward to: " + forwardPath); + this.forwardPath = forwardPath; + } + + public String getForwardPath() { + return forwardPath; + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/RestXqNamespaces.java b/exist-core/src/main/java/org/exist/http/restxq/RestXqNamespaces.java new file mode 100644 index 00000000000..9fe9f7de6d8 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/RestXqNamespaces.java @@ -0,0 +1,68 @@ +/* + * 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.http.restxq; + +/** + * Namespace constants for RESTXQ annotations and related namespaces. + */ +public final class RestXqNamespaces { + + /** The RESTXQ annotation namespace. */ + public static final String REST_NS = "http://exquery.org/ns/restxq"; + + /** The RESTXQ annotation namespace prefix. */ + public static final String REST_PREFIX = "rest"; + + /** The W3C serialization namespace for %output:* annotations. */ + public static final String OUTPUT_NS = "http://www.w3.org/2010/xslt-xquery-serialization"; + + /** The output namespace prefix. */ + public static final String OUTPUT_PREFIX = "output"; + + /** The HTTP client namespace for response elements. */ + public static final String HTTP_NS = "http://expath.org/ns/http-client"; + + /** The HTTP client namespace prefix. */ + public static final String HTTP_PREFIX = "http"; + + /** The input processing namespace for %input:* annotations. */ + public static final String INPUT_NS = "http://exquery.org/ns/restxq/input"; + + /** The input namespace prefix. */ + public static final String INPUT_PREFIX = "input"; + + /** The eXist-db auth annotation namespace. */ + public static final String AUTH_NS = "http://exist-db.org/ns/auth"; + + /** The auth namespace prefix. */ + public static final String AUTH_PREFIX = "auth"; + + /** The web module namespace (BaseX extension). */ + public static final String WEB_NS = "http://basex.org/modules/web"; + + /** The web module namespace prefix. */ + public static final String WEB_PREFIX = "web"; + + private RestXqNamespaces() { + // utility class + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/Route.java b/exist-core/src/main/java/org/exist/http/restxq/Route.java new file mode 100644 index 00000000000..3bddda10812 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/Route.java @@ -0,0 +1,315 @@ +/* + * 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.http.restxq; + +import org.exist.dom.QName; + +import javax.xml.transform.OutputKeys; +import java.util.*; + +/** + * Represents a single RESTXQ route: the combination of a path pattern, + * HTTP method(s), a function reference, and serialization options parsed + * from the function's annotations. + * + *

A Route is built by {@link AnnotationParser} from a compiled XQuery + * function's annotations, and consumed by {@link NativeRestXqServlet} for + * dispatch.

+ */ +public class Route implements Comparable { + + /** The XQuery source URI (database path) containing this function. */ + private final String moduleUri; + + /** The QName of the annotated XQuery function. */ + private final QName functionName; + + /** The arity of the function. */ + private final int arity; + + /** The compiled path matcher for %rest:path. */ + private final PathMatcher pathMatcher; + + /** The HTTP methods this route handles (GET, POST, etc.). */ + private final Set methods; + + /** Serialization properties from %output:* annotations. */ + private final Properties outputProperties; + + /** %rest:consumes media types. */ + private final List consumes; + + /** %rest:produces media types. */ + private final List produces; + + /** %rest:query-param bindings: param name → variable name. */ + private final Map queryParams; + + /** %rest:form-param bindings. */ + private final Map formParams; + + /** %rest:header-param bindings. */ + private final Map headerParams; + + /** %rest:cookie-param bindings. */ + private final Map cookieParams; + + /** The variable name for POST/PUT body binding, or null. */ + private final String bodyVariable; + + /** Input processing options from %input:json, %input:csv, %input:html annotations. */ + private final Properties inputOptions; + + Route(final String moduleUri, final QName functionName, final int arity, + final PathMatcher pathMatcher, final Set methods, + final Properties outputProperties, + final List consumes, final List produces, + final Map queryParams, + final Map formParams, + final Map headerParams, + final Map cookieParams, + final String bodyVariable, + final Properties inputOptions) { + this.moduleUri = moduleUri; + this.functionName = functionName; + this.arity = arity; + this.pathMatcher = pathMatcher; + this.methods = methods; + this.outputProperties = outputProperties; + this.consumes = consumes; + this.produces = produces; + this.queryParams = queryParams; + this.formParams = formParams; + this.headerParams = headerParams; + this.cookieParams = cookieParams; + this.bodyVariable = bodyVariable; + this.inputOptions = inputOptions; + } + + public String getModuleUri() { + return moduleUri; + } + + public QName getFunctionName() { + return functionName; + } + + public int getArity() { + return arity; + } + + public PathMatcher getPathMatcher() { + return pathMatcher; + } + + public Set getMethods() { + return methods; + } + + public Properties getOutputProperties() { + return outputProperties; + } + + public List getConsumes() { + return consumes; + } + + public List getProduces() { + return produces; + } + + public Map getQueryParams() { + return queryParams; + } + + public Map getFormParams() { + return formParams; + } + + public Map getHeaderParams() { + return headerParams; + } + + public Map getCookieParams() { + return cookieParams; + } + + public String getBodyVariable() { + return bodyVariable; + } + + public Properties getInputOptions() { + return inputOptions; + } + + /** + * Tests whether this route matches the given HTTP method and request path. + */ + public boolean matches(final String method, final String requestPath) { + return methods.contains(method.toUpperCase(Locale.ROOT)) + && pathMatcher.matches(requestPath); + } + + /** + * Tests whether this route's consumes constraint is satisfied by the + * given Content-Type (or if no constraint is declared). + */ + public boolean matchesConsumes(final String contentType) { + if (consumes.isEmpty()) { + return true; + } + if (contentType == null || contentType.isEmpty()) { + // No content type in request — only match if consumes includes wildcard + for (final String consume : consumes) { + if ("*/*".equals(consume.trim())) { + return true; + } + } + return false; + } + // Strip parameters (e.g., charset) for matching + final String baseType = contentType.contains(";") + ? contentType.substring(0, contentType.indexOf(';')).trim() + : contentType.trim(); + for (final String consume : consumes) { + if (mediaTypeMatches(baseType, consume)) { + return true; + } + } + return false; + } + + /** + * Tests whether this route's produces constraint is satisfied by the + * given Accept header (or if no constraint is declared). + */ + public boolean matchesProduces(final String acceptHeader) { + if (produces.isEmpty()) { + return true; + } + if (acceptHeader == null || acceptHeader.isEmpty()) { + return true; + } + for (final String produce : produces) { + // Strip qs parameter for matching + final String baseProduces = produce.contains(";") + ? produce.substring(0, produce.indexOf(';')).trim() + : produce.trim(); + for (final String accept : acceptHeader.split(",")) { + final String baseAccept = accept.contains(";") + ? accept.substring(0, accept.indexOf(';')).trim() + : accept.trim(); + if (mediaTypeMatches(baseAccept, baseProduces)) { + return true; + } + } + } + return false; + } + + private static boolean mediaTypeMatches(final String actual, final String pattern) { + if ("*/*".equals(pattern) || "*/*".equals(actual)) { + return true; + } + if (actual.equalsIgnoreCase(pattern)) { + return true; + } + // Check type/* wildcard + final int slashActual = actual.indexOf('/'); + final int slashPattern = pattern.indexOf('/'); + if (slashActual > 0 && slashPattern > 0) { + final String typeActual = actual.substring(0, slashActual); + final String typePattern = pattern.substring(0, slashPattern); + final String subtypePattern = pattern.substring(slashPattern + 1); + if (typeActual.equalsIgnoreCase(typePattern) && "*".equals(subtypePattern)) { + return true; + } + } + return false; + } + + /** + * Returns the Content-Type to set on the response, derived from + * %output:media-type or %output:method annotations. + */ + public String getResponseContentType() { + final String mediaType = outputProperties.getProperty("media-type"); + if (mediaType != null) { + return mediaType; + } + // Derive from method + final String method = outputProperties.getProperty(OutputKeys.METHOD, "xml"); + return switch (method) { + case "json" -> "application/json"; + case "html" -> "text/html"; + case "text" -> "text/plain"; + case "adaptive" -> "text/plain"; + default -> "application/xml"; + }; + } + + /** + * Sort by path specificity (most specific first). + */ + @Override + public int compareTo(final Route other) { + return this.pathMatcher.compareTo(other.pathMatcher); + } + + @Override + public String toString() { + return methods + " " + pathMatcher.getTemplate() + " → " + + functionName.getLocalPart() + "#" + arity + + " [" + moduleUri + "]"; + } + + /** + * A parameter binding from a %rest:*-param annotation. + */ + public static class ParamBinding { + private final String paramName; + private final String variableName; + private final List defaultValues; + + public ParamBinding(final String paramName, final String variableName, final List defaultValues) { + this.paramName = paramName; + this.variableName = variableName; + this.defaultValues = defaultValues; + } + + public String getParamName() { + return paramName; + } + + public String getVariableName() { + return variableName; + } + + public String getDefaultValue() { + return defaultValues.isEmpty() ? null : defaultValues.get(0); + } + + public List getDefaultValues() { + return defaultValues; + } + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/RouteRegistry.java b/exist-core/src/main/java/org/exist/http/restxq/RouteRegistry.java new file mode 100644 index 00000000000..d82ad721880 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/RouteRegistry.java @@ -0,0 +1,443 @@ +/* + * 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.http.restxq; + +import net.jcip.annotations.GuardedBy; +import net.jcip.annotations.ThreadSafe; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.collections.Collection; +import org.exist.dom.persistent.BinaryDocument; +import org.exist.dom.persistent.DocumentImpl; +import org.exist.security.PermissionDeniedException; +import org.exist.source.DBSource; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.storage.lock.Lock.LockMode; +import org.exist.xmldb.XmldbURI; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.XQueryContext; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * In-memory route table that discovers RESTXQ-annotated functions from + * XQuery modules stored in the database. Uses timestamp-based caching + * to re-parse only when modules change. + * + *

This replaces the trigger-based {@code ExistXqueryRegistry} and + * {@code RestXqServiceRegistryPersistence} from the old EXQuery-based + * implementation.

+ * + *

Concurrency model

+ *

All mutable state is protected by a {@link ReentrantReadWriteLock}. + * Write operations ({@link #fullScan}, {@link #invalidate}) acquire + * the write lock and rebuild immutable snapshots that are published via + * volatile references. Read operations ({@link #findRoute}, + * {@link #findErrorHandler}, {@link #allowedMethods}) read the volatile + * snapshots without locking for maximum throughput on the HTTP request + * path. Status accessors also read volatile snapshots.

+ */ +@ThreadSafe +public class RouteRegistry { + + private static final Logger LOG = LogManager.getLogger(RouteRegistry.class); + + private static final Set XQUERY_MIME_TYPES = Set.of( + "application/xquery" + ); + + private static final Set XQUERY_EXTENSIONS = Set.of( + ".xq", ".xqm", ".xql", ".xquery" + ); + + private final BrokerPool brokerPool; + private final XmldbURI scanRoot; + + /** Protects all mutable state below. */ + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + // --- Mutable state, guarded by lock.writeLock() --- + + /** Module URI to last-modified timestamp at time of last parse. */ + @GuardedBy("lock") + private final Map moduleTimestamps = new HashMap<>(); + + /** Per-module route list, for efficient partial updates during scan. */ + @GuardedBy("lock") + private final Map> routesByModule = new HashMap<>(); + + /** Per-module error route list. */ + @GuardedBy("lock") + private final Map> errorRoutesByModule = new HashMap<>(); + + /** Modules that failed compilation or annotation validation. */ + @GuardedBy("lock") + private final Map failedModules = new HashMap<>(); + + // --- Volatile snapshots, rebuilt under write lock, read without lock --- + + /** All currently registered routes, sorted by specificity. Immutable. */ + private volatile List routes = Collections.emptyList(); + + /** All currently registered error handlers. Immutable. */ + private volatile List errorRoutes = Collections.emptyList(); + + /** Immutable snapshot of failed modules for status reporting. */ + private volatile Map failedModulesSnapshot = Collections.emptyMap(); + + /** Count of modules with routes (snapshot for lock-free reads). */ + private volatile int moduleCount = 0; + + /** When we last completed a full scan (epoch millis). */ + private volatile long lastScanTime = 0; + + /** How long the scan took (ms). */ + private volatile long lastScanDurationMs = 0; + + /** Whether at least one scan has completed. */ + private volatile boolean initialized = false; + + public RouteRegistry(final BrokerPool brokerPool, final String scanRoot) { + this.brokerPool = brokerPool; + this.scanRoot = XmldbURI.create(scanRoot); + } + + // --- Read path: lock-free, reads volatile immutable snapshots --- + + /** + * Finds the best matching route for the given HTTP method and path. + * + *

Routes are pre-sorted by specificity; the first match wins. + * Content negotiation (consumes/produces) is also checked.

+ * + * @return the matching Route, or null if no route matches + */ + public Route findRoute(final String method, final String path, + final String contentType, final String acceptHeader) { + final List snapshot = routes; + for (final Route route : snapshot) { + if (route.matches(method, path) + && route.matchesConsumes(contentType) + && route.matchesProduces(acceptHeader)) { + return route; + } + } + return null; + } + + /** + * Returns all routes that match the given path (regardless of method), + * useful for generating Allow headers on 405 responses. + */ + public Set allowedMethods(final String path) { + return allowedMethods(path, null, null); + } + + /** + * Returns all HTTP methods that have a matching route for the given path + * and content negotiation constraints. Used for 405 Allow headers. + */ + public Set allowedMethods(final String path, final String contentType, + final String acceptHeader) { + final Set methods = new LinkedHashSet<>(); + final List snapshot = routes; + for (final Route route : snapshot) { + if (route.getPathMatcher().matches(path) + && route.matchesConsumes(contentType) + && route.matchesProduces(acceptHeader)) { + methods.addAll(route.getMethods()); + } + } + return methods; + } + + /** + * Finds the best matching error handler for the given error QName. + * Returns null if no handler matches. + */ + public ErrorRoute findErrorHandler(final org.exist.dom.QName errorQName) { + ErrorRoute bestHandler = null; + ErrorRoute.ErrorCode bestCode = null; + + final List snapshot = errorRoutes; + for (final ErrorRoute handler : snapshot) { + final ErrorRoute.ErrorCode match = handler.bestMatch(errorQName); + if (match != null) { + if (bestCode == null || match.getMatchType().priority < bestCode.getMatchType().priority) { + bestHandler = handler; + bestCode = match; + } + } + } + return bestHandler; + } + + // --- Write path: acquires write lock, rebuilds snapshots --- + + /** + * Invalidates the entire route cache, forcing a full rescan + * on the next call to {@link #ensureInitialized(DBBroker)}. + */ + public void invalidate() { + lock.writeLock().lock(); + try { + routesByModule.clear(); + errorRoutesByModule.clear(); + moduleTimestamps.clear(); + failedModules.clear(); + publishSnapshots(); + initialized = false; + LOG.info("RESTXQ route registry invalidated; will rescan on next request"); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Ensures the registry has been initialized (at least one scan completed). + * If not yet initialized, performs a full scan. + */ + public void ensureInitialized(final DBBroker broker) { + if (!initialized) { + fullScan(broker); + } + } + + /** + * Performs a full scan of all XQuery modules under the scan root. + * Modules whose timestamp hasn't changed since last scan are skipped. + */ + public void fullScan(final DBBroker broker) { + lock.writeLock().lock(); + try { + final long start = System.currentTimeMillis(); + LOG.info("Starting RESTXQ module scan of {}", scanRoot); + + // Track which modules exist during this scan + final Set scannedModules = new HashSet<>(); + scanCollection(broker, scanRoot, scannedModules); + + // Remove routes/failures for modules that no longer exist + routesByModule.keySet().retainAll(scannedModules); + errorRoutesByModule.keySet().retainAll(scannedModules); + moduleTimestamps.keySet().retainAll(scannedModules); + failedModules.keySet().retainAll(scannedModules); + + publishSnapshots(); + + lastScanTime = System.currentTimeMillis(); + lastScanDurationMs = lastScanTime - start; + initialized = true; + + LOG.info("RESTXQ scan complete: {} routes from {} modules in {}ms", + routes.size(), routesByModule.size(), lastScanDurationMs); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Rebuilds all volatile immutable snapshots from the guarded mutable state. + * Must be called under write lock. + */ + @GuardedBy("lock") + private void publishSnapshots() { + // Routes snapshot — sorted by specificity + final List allRoutes = new ArrayList<>(); + for (final List moduleRoutes : routesByModule.values()) { + allRoutes.addAll(moduleRoutes); + } + Collections.sort(allRoutes); + this.routes = Collections.unmodifiableList(allRoutes); + + // Error routes snapshot + final List allErrorRoutes = new ArrayList<>(); + for (final List moduleErrorRoutes : errorRoutesByModule.values()) { + allErrorRoutes.addAll(moduleErrorRoutes); + } + this.errorRoutes = Collections.unmodifiableList(allErrorRoutes); + + // Failed modules snapshot + this.failedModulesSnapshot = Map.copyOf(failedModules); + + // Module count snapshot + this.moduleCount = routesByModule.size(); + } + + // --- Scanning internals (called under write lock) --- + + @GuardedBy("lock") + private void scanCollection(final DBBroker broker, final XmldbURI collectionUri, + final Set scannedModules) { + try (final Collection collection = broker.openCollection(collectionUri, LockMode.READ_LOCK)) { + if (collection == null) { + return; + } + + // Scan documents in this collection + final Iterator docs = collection.iterator(broker); + while (docs.hasNext()) { + final DocumentImpl doc = docs.next(); + if (isXQueryDocument(doc)) { + scannedModules.add(doc.getURI().toString()); + scanDocument(broker, doc); + } + } + + // Recurse into child collections + final List childUris = new ArrayList<>(); + for (final Iterator it = collection.collectionIterator(broker); it.hasNext(); ) { + childUris.add(collectionUri.append(it.next())); + } + + // Release parent lock before recursing (we re-acquire per child) + collection.close(); + for (final XmldbURI childUri : childUris) { + scanCollection(broker, childUri, scannedModules); + } + + } catch (final PermissionDeniedException e) { + LOG.debug("Permission denied scanning collection {}: {}", collectionUri, e.getMessage()); + } catch (final Exception e) { + LOG.warn("Error scanning collection {}: {}", collectionUri, e.getMessage()); + } + } + + @GuardedBy("lock") + private void scanDocument(final DBBroker broker, final DocumentImpl doc) { + final String moduleUri = doc.getURI().toString(); + final long lastModified = doc.getLastModified(); + + // Check if we already have an up-to-date parse + final Long cached = moduleTimestamps.get(moduleUri); + if (cached != null && cached == lastModified) { + return; + } + + // Need to (re-)parse this module + try { + if (!(doc instanceof BinaryDocument binDoc)) { + return; + } + + final DBSource source = new DBSource(brokerPool, binDoc, true); + final XQuery xqueryService = brokerPool.getXQueryService(); + final XQueryContext context = new XQueryContext(brokerPool); + + // Set module load path so relative imports resolve correctly + context.setModuleLoadPath(XmldbURI.EMBEDDED_SERVER_URI_PREFIX + + doc.getURI().removeLastSegment().toString()); + + final CompiledXQuery compiled = xqueryService.compile(context, source); + final AnnotationParser.ParseResult result = + AnnotationParser.parseModuleFull(compiled, moduleUri); + + if (!result.routes.isEmpty()) { + routesByModule.put(moduleUri, result.routes); + LOG.debug("Found {} RESTXQ routes in {}", result.routes.size(), moduleUri); + } else { + routesByModule.remove(moduleUri); + } + + if (!result.errorRoutes.isEmpty()) { + errorRoutesByModule.put(moduleUri, result.errorRoutes); + LOG.debug("Found {} RESTXQ error handlers in {}", result.errorRoutes.size(), moduleUri); + } else { + errorRoutesByModule.remove(moduleUri); + } + + moduleTimestamps.put(moduleUri, lastModified); + failedModules.remove(moduleUri); + + } catch (final RestXqAnnotationException e) { + LOG.warn("RESTXQ annotation error in {}: {}", moduleUri, e.getMessage()); + failedModules.put(moduleUri, e.getMessage()); + routesByModule.remove(moduleUri); + errorRoutesByModule.remove(moduleUri); + moduleTimestamps.remove(moduleUri); + } catch (final XPathException e) { + LOG.warn("Failed to compile RESTXQ module {}: {}", moduleUri, e.getMessage()); + failedModules.put(moduleUri, e.getMessage()); + routesByModule.remove(moduleUri); + errorRoutesByModule.remove(moduleUri); + moduleTimestamps.remove(moduleUri); + } catch (final PermissionDeniedException | IOException e) { + LOG.warn("Error reading RESTXQ module {}: {}", moduleUri, e.getMessage()); + failedModules.put(moduleUri, e.getMessage()); + } catch (final Exception e) { + LOG.warn("Unexpected error reading RESTXQ module {}: {}", moduleUri, e.getMessage()); + failedModules.put(moduleUri, e.getClass().getName() + ": " + e.getMessage()); + } + } + + private static boolean isXQueryDocument(final DocumentImpl doc) { + if (doc instanceof BinaryDocument) { + final String mimeType = doc.getMimeType(); + if (mimeType != null && XQUERY_MIME_TYPES.contains(mimeType)) { + return true; + } + // Fallback: check file extension + final String name = doc.getFileURI().toString(); + for (final String ext : XQUERY_EXTENSIONS) { + if (name.endsWith(ext)) { + return true; + } + } + } + return false; + } + + // --- Status accessors: read volatile snapshots, no lock needed --- + + public int getRouteCount() { + return routes.size(); + } + + public int getModuleCount() { + return moduleCount; + } + + public long getLastScanTime() { + return lastScanTime; + } + + public long getLastScanDurationMs() { + return lastScanDurationMs; + } + + public Map getFailedModules() { + return failedModulesSnapshot; + } + + public List getAllRoutes() { + return routes; + } + + public boolean isInitialized() { + return initialized; + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/SecurityAnnotationHandler.java b/exist-core/src/main/java/org/exist/http/restxq/SecurityAnnotationHandler.java new file mode 100644 index 00000000000..9a146386e8f --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/SecurityAnnotationHandler.java @@ -0,0 +1,151 @@ +/* + * 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.http.restxq; + +import org.exist.dom.QName; +import org.exist.security.SecurityManager; +import org.exist.security.Subject; +import org.exist.xquery.Annotation; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.UserDefinedFunction; +import org.exist.xquery.XPathException; + +import java.util.*; + +/** + * Handles eXist-specific security annotations for access control on + * RESTXQ-dispatched functions. + * + *

Note: These annotations are an eXist-db extension, + * not part of the RESTXQ specification. They provide declarative access + * control by checking the already-authenticated servlet user against + * eXist's permission system. They do not introduce server-side session + * state — they inspect the {@link Subject} that was authenticated via + * standard HTTP mechanisms (Basic auth, etc.) by the servlet container.

+ * + *

Supported annotations

+ *
    + *
  • {@code %auth:allow-groups("dba", "editor")} — require membership + * in at least one listed group
  • + *
  • {@code %auth:allow-users("admin")} — require specific username
  • + *
  • {@code %auth:deny-groups("guest")} — deny access to listed groups
  • + *
  • {@code %auth:login-required} — require any authenticated user + * (not the guest account)
  • + *
+ * + *

All annotations use the namespace + * {@code http://exist-db.org/ns/auth} (prefix {@code auth}).

+ */ +public class SecurityAnnotationHandler { + + /** + * Accept both the canonical namespace and the test namespace for auth annotations. + * The test suite uses 'http://exist-db.org/xquery/restxq/auth'. + */ + private static final Set AUTH_NAMESPACES = Set.of( + RestXqNamespaces.AUTH_NS, + "http://exist-db.org/xquery/restxq/auth" + ); + + /** + * Checks whether the given subject is authorized to invoke the + * function at the given route. Returns null if authorized, or an + * error message string if denied. + * + * @param subject the authenticated user (from servlet-level auth) + * @param route the matched RESTXQ route + * @param compiled the compiled XQuery containing the function + * @return null if authorized, or a denial reason string + */ + public static String checkAccess(final Subject subject, final Route route, + final CompiledXQuery compiled) { + final UserDefinedFunction fn = + compiled.getContext().resolveFunction(route.getFunctionName(), route.getArity()); + if (fn == null) { + return null; + } + + final Annotation[] annotations = fn.getSignature().getAnnotations(); + if (annotations == null) { + return null; + } + + for (final Annotation annotation : annotations) { + final QName name = annotation.getName(); + if (!AUTH_NAMESPACES.contains(name.getNamespaceURI())) { + continue; + } + + final String local = name.getLocalPart(); + switch (local) { + case "login-required" -> { + if (SecurityManager.GUEST_USER.equals(subject.getName())) { + return "Authentication required"; + } + } + case "allow-groups" -> { + final Set allowed = literalValues(annotation); + if (!allowed.isEmpty() && !hasAnyGroup(subject, allowed)) { + return "User not in required group"; + } + } + case "allow-users" -> { + final Set allowed = literalValues(annotation); + if (!allowed.isEmpty() && !allowed.contains(subject.getName())) { + return "User not authorized"; + } + } + case "deny-groups" -> { + final Set denied = literalValues(annotation); + if (hasAnyGroup(subject, denied)) { + return "User in denied group"; + } + } + default -> { /* ignore unknown auth annotations */ } + } + } + + return null; // authorized + } + + private static boolean hasAnyGroup(final Subject subject, final Set groups) { + final String[] userGroups = subject.getGroups(); + for (final String ug : userGroups) { + if (groups.contains(ug)) { + return true; + } + } + return false; + } + + private static Set literalValues(final Annotation annotation) { + final Set values = new LinkedHashSet<>(); + for (final org.exist.xquery.LiteralValue lv : annotation.getValue()) { + try { + values.add(lv.getValue().getStringValue()); + } catch (final XPathException e) { + // skip + } + } + return values; + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/xquery/WebFunctions.java b/exist-core/src/main/java/org/exist/http/restxq/xquery/WebFunctions.java new file mode 100644 index 00000000000..c99a835f66c --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/xquery/WebFunctions.java @@ -0,0 +1,164 @@ +/* + * 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.http.restxq.xquery; + +import org.exist.dom.QName; +import org.exist.dom.memtree.MemTreeBuilder; +import org.exist.http.restxq.RestXqNamespaces; +import org.exist.xquery.*; +import org.exist.xquery.value.*; +import org.xml.sax.helpers.AttributesImpl; + +/** + * Implements web:redirect(), web:forward(), and web:error() for RESTXQ. + * + *
    + *
  • {@code web:redirect($url)} — returns a {@code } with HTTP 302
  • + *
  • {@code web:forward($url)} — returns a {@code } element
  • + *
  • {@code web:error($code, $message)} — throws an XPathException with HTTP status code
  • + *
+ */ +public class WebFunctions extends BasicFunction { + + private static final QName QN_REDIRECT = new QName("redirect", WebModule.NAMESPACE_URI, WebModule.PREFIX); + private static final QName QN_FORWARD = new QName("forward", WebModule.NAMESPACE_URI, WebModule.PREFIX); + private static final QName QN_ERROR = new QName("error", WebModule.NAMESPACE_URI, WebModule.PREFIX); + + public static final FunctionSignature FNS_REDIRECT = new FunctionSignature( + QN_REDIRECT, + "Returns a rest:response element that redirects the client to the given URL (HTTP 302).", + new SequenceType[]{ + new FunctionParameterSequenceType("url", Type.STRING, Cardinality.EXACTLY_ONE, + "The URL to redirect to") + }, + new FunctionReturnSequenceType(Type.NODE, Cardinality.EXACTLY_ONE, + "A rest:response element with HTTP 302 redirect") + ); + + public static final FunctionSignature FNS_FORWARD = new FunctionSignature( + QN_FORWARD, + "Returns a rest:forward element for server-side forwarding to the given path.", + new SequenceType[]{ + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, + "The path to forward to") + }, + new FunctionReturnSequenceType(Type.NODE, Cardinality.EXACTLY_ONE, + "A rest:forward element") + ); + + public static final FunctionSignature FNS_ERROR_2 = new FunctionSignature( + QN_ERROR, + "Aborts query evaluation and returns an HTTP error response with the given status code and message.", + new SequenceType[]{ + new FunctionParameterSequenceType("code", Type.INTEGER, Cardinality.EXACTLY_ONE, + "The HTTP status code"), + new FunctionParameterSequenceType("message", Type.STRING, Cardinality.EXACTLY_ONE, + "The error message body") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, + "Never returns — throws an exception") + ); + + public WebFunctions(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + final String funcName = getSignature().getName().getLocalPart(); + return switch (funcName) { + case "redirect" -> doRedirect(args[0].getStringValue()); + case "forward" -> doForward(args[0].getStringValue()); + case "error" -> doError( + ((IntegerValue) args[0].itemAt(0)).getInt(), + args[1].getStringValue()); + default -> throw new XPathException(this, "Unknown web function: " + funcName); + }; + } + + private Sequence doRedirect(final String url) { + context.pushDocumentContext(); + try { + final MemTreeBuilder builder = context.getDocumentBuilder(); + + // + builder.startElement( + new QName("response", RestXqNamespaces.REST_NS, RestXqNamespaces.REST_PREFIX), null); + + // + final AttributesImpl httpAttrs = new AttributesImpl(); + httpAttrs.addAttribute("", "status", "status", "CDATA", "302"); + builder.startElement( + new QName("response", RestXqNamespaces.HTTP_NS, RestXqNamespaces.HTTP_PREFIX), httpAttrs); + + // + final AttributesImpl headerAttrs = new AttributesImpl(); + headerAttrs.addAttribute("", "name", "name", "CDATA", "Location"); + headerAttrs.addAttribute("", "value", "value", "CDATA", url); + builder.startElement( + new QName("header", RestXqNamespaces.HTTP_NS, RestXqNamespaces.HTTP_PREFIX), headerAttrs); + builder.endElement(); + + builder.endElement(); // http:response + builder.endElement(); // rest:response + + return builder.getDocument().getNode(1); + } finally { + context.popDocumentContext(); + } + } + + private Sequence doForward(final String path) { + context.pushDocumentContext(); + try { + final MemTreeBuilder builder = context.getDocumentBuilder(); + builder.startElement( + new QName("forward", RestXqNamespaces.REST_NS, RestXqNamespaces.REST_PREFIX), null); + builder.characters(path); + builder.endElement(); + return builder.getDocument().getNode(1); + } finally { + context.popDocumentContext(); + } + } + + private Sequence doError(final int statusCode, final String message) throws XPathException { + // Throw a special exception that the servlet can catch and convert to an HTTP error response + throw new WebErrorException(this, statusCode, message); + } + + /** + * Special exception for web:error() that carries an HTTP status code. + */ + public static class WebErrorException extends XPathException { + private final int httpStatusCode; + + public WebErrorException(final Expression expr, final int statusCode, final String message) { + super(expr, ErrorCodes.ERROR, message); + this.httpStatusCode = statusCode; + } + + public int getHttpStatusCode() { + return httpStatusCode; + } + } +} diff --git a/exist-core/src/main/java/org/exist/http/restxq/xquery/WebModule.java b/exist-core/src/main/java/org/exist/http/restxq/xquery/WebModule.java new file mode 100644 index 00000000000..17bb0a6c182 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/restxq/xquery/WebModule.java @@ -0,0 +1,70 @@ +/* + * 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.http.restxq.xquery; + +import org.exist.xquery.AbstractInternalModule; +import org.exist.xquery.FunctionDef; + +import java.util.List; +import java.util.Map; + +/** + * XQuery module providing web:redirect(), web:forward(), and web:error() + * functions for RESTXQ applications. Compatible with the BaseX web module. + */ +public class WebModule extends AbstractInternalModule { + + public static final String NAMESPACE_URI = "http://basex.org/modules/web"; + public static final String PREFIX = "web"; + public static final String DESCRIPTION = "Web utility functions for RESTXQ (redirect, forward, error)"; + public static final String RELEASE_VERSION = "7.0"; + + private static final FunctionDef[] functions = { + new FunctionDef(WebFunctions.FNS_REDIRECT, WebFunctions.class), + new FunctionDef(WebFunctions.FNS_FORWARD, WebFunctions.class), + new FunctionDef(WebFunctions.FNS_ERROR_2, WebFunctions.class), + }; + + public WebModule(final Map> parameters) { + super(functions, parameters); + } + + @Override + public String getNamespaceURI() { + return NAMESPACE_URI; + } + + @Override + public String getDefaultPrefix() { + return PREFIX; + } + + @Override + public String getDescription() { + return DESCRIPTION; + } + + @Override + public String getReleaseVersion() { + return RELEASE_VERSION; + } +} diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpServletRequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpServletRequestWrapper.java index 5d3e437d4a0..a3624465422 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/HttpServletRequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/HttpServletRequestWrapper.java @@ -102,21 +102,23 @@ private void initialiseWrapper() { parseURLParameters(this.request.getQueryString()); //If POST request, Parse out parameters from the Content Body - if ("POST".equals(request.getMethod().toUpperCase())) { + if ("POST".equalsIgnoreCase(request.getMethod())) { //If there is some Content final int contentLength = request.getContentLength(); if (contentLength > 0 || contentLength == -1) { // If a form POST , and not a document POST - String contentType = request.getContentType().toLowerCase(); - final int semicolon = contentType.indexOf(';'); - if (semicolon > 0) { - contentType = contentType.substring(0, semicolon).trim(); - } - if ("application/x-www-form-urlencoded".equals(contentType) - && request.getHeader("ContentType") == null) { - //Parse out parameters from the Content Body - parseContentBodyParameters(); - + final String rawContentType = request.getContentType(); + if (rawContentType != null) { + String contentType = rawContentType.toLowerCase(); + final int semicolon = contentType.indexOf(';'); + if (semicolon > 0) { + contentType = contentType.substring(0, semicolon).trim(); + } + if ("application/x-www-form-urlencoded".equals(contentType) + && request.getHeader("ContentType") == null) { + //Parse out parameters from the Content Body + parseContentBodyParameters(); + } } } } @@ -280,10 +282,11 @@ public BufferedReader getReader() throws IOException { */ @Override public String toString() { + final String contentType = request.getContentType(); // If POST request AND there is some content AND its not a file upload - if ("POST".equals(request.getMethod().toUpperCase()) + if ("POST".equalsIgnoreCase(request.getMethod()) && (request.getContentLength() > 0 || request.getContentLength() == -1) - && !request.getContentType().toUpperCase().startsWith("MULTIPART/")) { + && (contentType == null || !contentType.toUpperCase().startsWith("MULTIPART/"))) { // Also return the content parameters, these are not part // of the standard HttpServletRequest.toString() output @@ -441,12 +444,6 @@ public boolean isRequestedSessionIdFromURL() { return request.isRequestedSessionIdFromURL(); } - @Override - @Deprecated - public boolean isRequestedSessionIdFromUrl() { - return request.isRequestedSessionIdFromUrl(); - } - @Override public boolean authenticate(final HttpServletResponse httpServletResponse) throws IOException, ServletException { return request.authenticate(httpServletResponse); @@ -572,12 +569,6 @@ public RequestDispatcher getRequestDispatcher(final String name) { return request.getRequestDispatcher(name); } - @Override - @Deprecated - public String getRealPath(final String path) { - return request.getSession().getServletContext().getRealPath(path); - } - @Override public int getRemotePort() { return request.getRemotePort(); @@ -633,6 +624,21 @@ public DispatcherType getDispatcherType() { return request.getDispatcherType(); } + @Override + public String getRequestId() { + return request.getRequestId(); + } + + @Override + public String getProtocolRequestId() { + return request.getProtocolRequestId(); + } + + @Override + public ServletConnection getServletConnection() { + return request.getServletConnection(); + } + @Override public void close() throws IOException { this.is.close(); diff --git a/exist-core/src/main/java/org/exist/http/urlrewrite/RewriteConfig.java b/exist-core/src/main/java/org/exist/http/urlrewrite/RewriteConfig.java index 6303fd67940..ace13810fd3 100644 --- a/exist-core/src/main/java/org/exist/http/urlrewrite/RewriteConfig.java +++ b/exist-core/src/main/java/org/exist/http/urlrewrite/RewriteConfig.java @@ -31,9 +31,8 @@ import org.exist.security.PermissionDeniedException; import org.exist.storage.DBBroker; import org.exist.storage.lock.Lock.LockMode; -import org.exist.thirdparty.net.sf.saxon.functions.regex.JDK15RegexTranslator; -import org.exist.thirdparty.net.sf.saxon.functions.regex.RegexSyntaxException; -import org.exist.thirdparty.net.sf.saxon.functions.regex.RegularExpression; +import net.sf.saxon.regex.JavaRegularExpression; +import net.sf.saxon.str.StringView; import org.exist.util.XMLReaderPool; import org.exist.xmldb.XmldbURI; import org.exist.xquery.Constants; @@ -272,16 +271,13 @@ private static final class Mapping { private Mapping(String regex, final URLRewrite action) throws ServletException { try { - final int options = RegularExpression.XML11 | RegularExpression.XPATH30; - int flagbits = 0; - - final List warnings = new ArrayList<>(); - regex = JDK15RegexTranslator.translate(regex, options, flagbits, warnings); + final JavaRegularExpression javaRegex = new JavaRegularExpression(StringView.of(regex), ""); + regex = javaRegex.getJavaRegularExpression(); this.pattern = Pattern.compile(regex, 0); this.action = action; this.matcher = pattern.matcher(""); - } catch (final RegexSyntaxException e) { + } catch (final net.sf.saxon.trans.XPathException e) { throw new ServletException("Syntax error in regular expression specified for path. " + e.getMessage(), e); } diff --git a/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java b/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java index e0cef4b13d6..4c686b5c6de 100644 --- a/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java +++ b/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java @@ -382,7 +382,6 @@ Subject getDefaultUser() { } private void applyViews(final ModelAndView modelView, final List views, final HttpServletResponse response, final RequestWrapper modifiedRequest, final HttpServletResponse currentResponse) throws IOException, ServletException { - //int status; HttpServletResponse wrappedResponse = currentResponse; for (int i = 0; i < views.size(); i++) { final URLRewrite view = views.get(i); @@ -456,6 +455,7 @@ private void response(final DBBroker broker, final HttpServletResponse response, private void flushError(final HttpServletResponse response, final HttpServletResponse wrappedResponse) throws IOException { if (!response.isCommitted()) { + response.setStatus(wrappedResponse.getStatus()); final byte[] data = ((CachingResponseWrapper) wrappedResponse).getData(); if (data != null) { response.setContentType(wrappedResponse.getContentType()); @@ -475,7 +475,7 @@ private ModelAndView getFromCache(final String url, final Subject user) throws E return null; } - try (final DBBroker broker = pool.get(Optional.ofNullable(user))) { + try (@SuppressWarnings("PMD.UnusedLocalVariable") final DBBroker broker = pool.get(Optional.ofNullable(user))) { if (model.getSourceInfo().source instanceof DBSource) { ((DBSource) model.getSourceInfo().source).validate(Permission.EXECUTE); @@ -506,25 +506,27 @@ void clearCaches() { * @param request the http request * @param response the http response */ - private void doRewrite(URLRewrite action, RequestWrapper request, final HttpServletResponse response) throws IOException, ServletException { - if (action.getTarget() != null && !(action instanceof Redirect)) { - final String uri = action.resolve(request); - final URLRewrite staticRewrite = rewriteConfig.lookup(uri, request.getServerName(), true, action); + private void doRewrite(final URLRewrite action, final RequestWrapper request, final HttpServletResponse response) throws IOException, ServletException { + URLRewrite effectiveAction = action; + RequestWrapper effectiveRequest = request; + if (effectiveAction.getTarget() != null && !(effectiveAction instanceof Redirect)) { + final String uri = effectiveAction.resolve(effectiveRequest); + final URLRewrite staticRewrite = rewriteConfig.lookup(uri, effectiveRequest.getServerName(), true, effectiveAction); if (staticRewrite != null) { - staticRewrite.copyFrom(action); - action = staticRewrite; - final RequestWrapper modifiedRequest = new RequestWrapper(request); - modifiedRequest.setPaths(uri, action.getPrefix()); + staticRewrite.copyFrom(effectiveAction); + effectiveAction = staticRewrite; + final RequestWrapper modifiedRequest = new RequestWrapper(effectiveRequest); + modifiedRequest.setPaths(uri, effectiveAction.getPrefix()); if (LOG.isTraceEnabled()) { - LOG.trace("Forwarding to : {} url: {}", action.toString(), action.getURI()); + LOG.trace("Forwarding to : {} url: {}", effectiveAction.toString(), effectiveAction.getURI()); } - request = modifiedRequest; + effectiveRequest = modifiedRequest; } } - action.prepareRequest(request); - action.doRewrite(request, response); + effectiveAction.prepareRequest(effectiveRequest); + effectiveAction.doRewrite(effectiveRequest, response); } protected ServletConfig getConfig() { @@ -543,6 +545,7 @@ private URLRewrite parseAction(final HttpServletRequest request, final Element a return rewrite; } + @SuppressWarnings("PMD.UnusedPrivateMethod") // called from switch expression in service() private void parseViews(final HttpServletRequest request, final Element view, final ModelAndView modelView) throws ServletException { Node node = view.getFirstChild(); while (node != null) { @@ -557,6 +560,7 @@ private void parseViews(final HttpServletRequest request, final Element view, fi } } + @SuppressWarnings("PMD.UnusedPrivateMethod") // called from switch expression in service() private void parseErrorHandlers(final HttpServletRequest request, final Element view, final ModelAndView modelView) throws ServletException { Node node = view.getFirstChild(); while (node != null) { @@ -685,28 +689,30 @@ private Sequence runQuery(final DBBroker broker, final RequestWrapper request, f } } - String adjustPathForSourceLookup(final String basePath, String path) { + String adjustPathForSourceLookup(final String basePath, final String path) { if (LOG.isTraceEnabled()) { LOG.trace("request path={}", path); } - if (basePath.startsWith(XmldbURI.EMBEDDED_SERVER_URI_PREFIX) && path.startsWith(basePath.replace(XmldbURI.EMBEDDED_SERVER_URI_PREFIX, ""))) { - path = path.replace(basePath.replace(XmldbURI.EMBEDDED_SERVER_URI_PREFIX, ""), ""); + String adjustedPath = path; + if (basePath.startsWith(XmldbURI.EMBEDDED_SERVER_URI_PREFIX) && adjustedPath.startsWith(basePath.replace(XmldbURI.EMBEDDED_SERVER_URI_PREFIX, ""))) { + adjustedPath = adjustedPath.replace(basePath.replace(XmldbURI.EMBEDDED_SERVER_URI_PREFIX, ""), ""); - } else if (path.startsWith("/db/")) { - path = path.substring(4); + } else if (adjustedPath.startsWith("/db/")) { + adjustedPath = adjustedPath.substring(4); } - if (path.startsWith("/")) { - path = path.substring(1); + if (adjustedPath.startsWith("/")) { + adjustedPath = adjustedPath.substring(1); } if (LOG.isTraceEnabled()) { - LOG.trace("adjusted request path={}", path); + LOG.trace("adjusted request path={}", adjustedPath); } - return path; + return adjustedPath; } + @SuppressWarnings("PMD.UnusedPrivateMethod") // called indirectly from getSourceInfo() private SourceInfo findSource(final HttpServletRequest request, final DBBroker broker, final String basePath) { if (LOG.isTraceEnabled()) { LOG.trace("basePath={}", basePath); @@ -977,9 +983,6 @@ private static class ModelAndView { private boolean useCache = false; private SourceInfo sourceInfo = null; - private ModelAndView() { - } - public void setSourceInfo(final SourceInfo sourceInfo) { this.sourceInfo = sourceInfo; } @@ -1179,12 +1182,10 @@ public String getPathTranslated() { return super.getSession().getServletContext().getRealPath(pathInfo); } - protected void setData(@Nullable byte[] data) { - if (data == null) { - data = new byte[0]; - } - contentLength = data.length; - sis = new CachingServletInputStream(data); + protected void setData(@Nullable final byte[] data) { + final byte[] effectiveData = data == null ? new byte[0] : data; + contentLength = effectiveData.length; + sis = new CachingServletInputStream(effectiveData); } public void addParameter(final String name, final String value) { @@ -1380,11 +1381,6 @@ public void setStatus(final int i) { super.setStatus(i); } - @Override - public void setStatus(final int i, final String msg) { - this.status = i; - super.setStatus(i, msg); - } @Override public void sendError(final int i, final String msg) throws IOException { @@ -1413,10 +1409,8 @@ public void flushBuffer() throws IOException { } public void flush() throws IOException { - if (cache) { - if (contentType != null) { - super.setContentType(contentType); - } + if (cache && contentType != null) { + super.setContentType(contentType); } if (sos != null) { final ServletOutputStream out = super.getOutputStream(); diff --git a/exist-core/src/main/java/org/exist/http/ws/EvalProtocol.java b/exist-core/src/main/java/org/exist/http/ws/EvalProtocol.java new file mode 100644 index 00000000000..7ca92bf5112 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/ws/EvalProtocol.java @@ -0,0 +1,336 @@ +/* + * 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.http.ws; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * JSON protocol messages for the /ws/eval WebSocket endpoint. + * + * All messages are JSON objects. Client messages have an "action" field; + * server messages have a "type" field. + */ +public final class EvalProtocol { + + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + public static final int DEFAULT_CHUNK_SIZE = 1000; + public static final long DEFAULT_MAX_EXECUTION_TIME = 0; // no limit + + // Client actions + public static final String ACTION_EVAL = "eval"; + public static final String ACTION_CANCEL = "cancel"; + public static final String ACTION_COMPILE = "compile"; + public static final String ACTION_ADMIN_CANCEL = "admin-cancel"; + + // Server message types + public static final String TYPE_PROGRESS = "progress"; + public static final String TYPE_RESULT = "result"; + public static final String TYPE_COMPILE = "compile"; + public static final String TYPE_ERROR = "error"; + public static final String TYPE_CANCELLED = "cancelled"; + + // Phases + public static final String PHASE_PARSING = "parsing"; + public static final String PHASE_COMPILING = "compiling"; + public static final String PHASE_EVALUATING = "evaluating"; + public static final String PHASE_SERIALIZING = "serializing"; + public static final String PHASE_COMPLETE = "complete"; + + private EvalProtocol() { + // utility class + } + + /** + * Parsed client request message. + */ + public static final class ClientMessage { + public final String action; + public final String id; + @Nullable public final String query; + @Nullable public final Properties serialization; + @Nullable public final Map variables; + @Nullable public final String context; + @Nullable public final String moduleLoadPath; + public final long maxExecutionTime; + public final boolean streaming; + public final int chunkSize; + + ClientMessage(final String action, final String id, @Nullable final String query, + @Nullable final Properties serialization, + @Nullable final Map variables, + @Nullable final String context, + @Nullable final String moduleLoadPath, + final long maxExecutionTime, + final boolean streaming, + final int chunkSize) { + this.action = action; + this.id = id; + this.query = query; + this.serialization = serialization; + this.variables = variables; + this.context = context; + this.moduleLoadPath = moduleLoadPath; + this.maxExecutionTime = maxExecutionTime; + this.streaming = streaming; + this.chunkSize = chunkSize; + } + } + + /** + * Timing breakdown for query execution phases. + */ + public static final class Timing { + public long parse; + public long compile; + public long evaluate; + public long serialize; + public long total; + } + + /** + * Parse a JSON client message. + */ + public static ClientMessage parseClientMessage(final String json) throws IOException { + String action = null; + String id = null; + String query = null; + Properties serialization = null; + Map variables = null; + String context = null; + String moduleLoadPath = null; + long maxExecutionTime = DEFAULT_MAX_EXECUTION_TIME; + boolean streaming = true; + int chunkSize = DEFAULT_CHUNK_SIZE; + + try (final JsonParser parser = JSON_FACTORY.createParser(json)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw new IOException("Expected JSON object"); + } + while (parser.nextToken() != JsonToken.END_OBJECT) { + final String field = parser.currentName(); + parser.nextToken(); + switch (field) { + case "action": + action = parser.getValueAsString(); + break; + case "id": + id = parser.getValueAsString(); + break; + case "query": + query = parser.getValueAsString(); + break; + case "context": + context = parser.getValueAsString(); + break; + case "module-load-path": + moduleLoadPath = parser.getValueAsString(); + break; + case "max-execution-time": + maxExecutionTime = parser.getValueAsLong(); + break; + case "streaming": + streaming = parser.getValueAsBoolean(); + break; + case "chunk-size": + chunkSize = parser.getValueAsInt(); + break; + case "serialization": + serialization = parseObject(parser); + break; + case "variables": + variables = parseStringMap(parser); + break; + default: + parser.skipChildren(); + break; + } + } + } + + if (action == null) { + throw new IOException("Missing required field: action"); + } + if (id == null) { + throw new IOException("Missing required field: id"); + } + + return new ClientMessage(action, id, query, serialization, variables, + context, moduleLoadPath, maxExecutionTime, streaming, chunkSize); + } + + private static Properties parseObject(final JsonParser parser) throws IOException { + final Properties props = new Properties(); + if (parser.currentToken() != JsonToken.START_OBJECT) { + return props; + } + while (parser.nextToken() != JsonToken.END_OBJECT) { + final String key = parser.currentName(); + parser.nextToken(); + props.setProperty(key, parser.getValueAsString()); + } + return props; + } + + private static Map parseStringMap(final JsonParser parser) throws IOException { + final Map map = new HashMap<>(); + if (parser.currentToken() != JsonToken.START_OBJECT) { + return map; + } + while (parser.nextToken() != JsonToken.END_OBJECT) { + final String key = parser.currentName(); + parser.nextToken(); + map.put(key, parser.getValueAsString()); + } + return map; + } + + // --- Server message builders --- + + public static String progressMessage(final String id, final String phase, + final long items, final long elapsed) throws IOException { + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", TYPE_PROGRESS); + gen.writeStringField("id", id); + gen.writeStringField("phase", phase); + gen.writeNumberField("items", items); + gen.writeNumberField("elapsed", elapsed); + gen.writeEndObject(); + } + return sw.toString(); + } + + public static String resultMessage(final String id, final int chunk, + final String data, final boolean more, + @Nullable final Timing timing, + final long items) throws IOException { + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", TYPE_RESULT); + gen.writeStringField("id", id); + gen.writeNumberField("chunk", chunk); + gen.writeStringField("data", data); + gen.writeBooleanField("more", more); + if (!more && timing != null) { + writeTiming(gen, timing); + } + if (!more) { + gen.writeNumberField("items", items); + gen.writeBooleanField("truncated", false); + } + gen.writeEndObject(); + } + return sw.toString(); + } + + public static String compileResultMessage(final String id, final boolean success, + @Nullable final String errorCode, + @Nullable final String errorMessage, + final int line, final int column) throws IOException { + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", TYPE_COMPILE); + gen.writeStringField("id", id); + gen.writeBooleanField("success", success); + if (!success) { + gen.writeArrayFieldStart("diagnostics"); + gen.writeStartObject(); + gen.writeNumberField("line", line); + gen.writeNumberField("column", column); + gen.writeStringField("severity", "error"); + if (errorCode != null) { + gen.writeStringField("code", errorCode); + } + if (errorMessage != null) { + gen.writeStringField("message", errorMessage); + } + gen.writeEndObject(); + gen.writeEndArray(); + } + gen.writeEndObject(); + } + return sw.toString(); + } + + public static String errorMessage(final String id, @Nullable final String code, + final String message, final int line, + final int column, + @Nullable final Timing timing) throws IOException { + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", TYPE_ERROR); + gen.writeStringField("id", id); + if (code != null) { + gen.writeStringField("code", code); + } + gen.writeStringField("message", message); + gen.writeNumberField("line", line); + gen.writeNumberField("column", column); + if (timing != null) { + writeTiming(gen, timing); + } + gen.writeEndObject(); + } + return sw.toString(); + } + + public static String cancelledMessage(final String id, final long items, + @Nullable final Timing timing) throws IOException { + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", TYPE_CANCELLED); + gen.writeStringField("id", id); + gen.writeNumberField("items", items); + if (timing != null) { + writeTiming(gen, timing); + } + gen.writeEndObject(); + } + return sw.toString(); + } + + private static void writeTiming(final JsonGenerator gen, final Timing timing) throws IOException { + gen.writeObjectFieldStart("timing"); + gen.writeNumberField("parse", timing.parse); + gen.writeNumberField("compile", timing.compile); + gen.writeNumberField("evaluate", timing.evaluate); + gen.writeNumberField("serialize", timing.serialize); + gen.writeNumberField("total", timing.total); + gen.writeEndObject(); + } +} diff --git a/exist-core/src/main/java/org/exist/http/ws/EvalSession.java b/exist-core/src/main/java/org/exist/http/ws/EvalSession.java new file mode 100644 index 00000000000..f59f9dc5fc6 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/ws/EvalSession.java @@ -0,0 +1,86 @@ +/* + * 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.http.ws; + +import org.exist.security.Subject; +import org.exist.xquery.XQueryWatchDog; + +import javax.annotation.Nullable; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-connection state for the /ws/eval WebSocket endpoint. + * + * Tracks the authenticated user and all running queries for cancellation. + */ +public final class EvalSession { + + private final Subject subject; + private final Map runningQueries = new ConcurrentHashMap<>(); + + public EvalSession(final Subject subject) { + this.subject = subject; + } + + public Subject getSubject() { + return subject; + } + + /** + * Register a running query's watchdog for cancellation. + */ + public void registerQuery(final String queryId, final XQueryWatchDog watchDog) { + runningQueries.put(queryId, watchDog); + } + + /** + * Unregister a completed or cancelled query. + */ + public void unregisterQuery(final String queryId) { + runningQueries.remove(queryId); + } + + /** + * Cancel a running query by its ID. + * + * @return true if the query was found and cancelled + */ + public boolean cancelQuery(final String queryId) { + final XQueryWatchDog watchDog = runningQueries.get(queryId); + if (watchDog != null) { + watchDog.kill(0); + return true; + } + return false; + } + + /** + * Cancel all running queries (called on session close). + */ + public void cancelAll() { + for (final XQueryWatchDog watchDog : runningQueries.values()) { + watchDog.kill(0); + } + runningQueries.clear(); + } +} diff --git a/exist-core/src/main/java/org/exist/http/ws/EvalWebSocketEndpoint.java b/exist-core/src/main/java/org/exist/http/ws/EvalWebSocketEndpoint.java new file mode 100644 index 00000000000..8daae884b9c --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/ws/EvalWebSocketEndpoint.java @@ -0,0 +1,346 @@ +/* + * 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.http.ws; + +import org.apache.commons.codec.binary.Base64; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.security.AuthenticationException; +import org.exist.security.SecurityManager; +import org.exist.security.Subject; +import org.exist.storage.BrokerPool; +import org.exist.storage.ProcessMonitor; + +import jakarta.websocket.*; +import jakarta.websocket.server.HandshakeRequest; +import jakarta.websocket.server.ServerEndpoint; +import jakarta.websocket.server.ServerEndpointConfig; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * WebSocket endpoint for real-time, streaming XQuery evaluation. + * + * Supports: + *
    + *
  • Query evaluation with streaming results
  • + *
  • Query cancellation
  • + *
  • Compile-only checks (no execution)
  • + *
  • Progress reporting
  • + *
  • Timing breakdown (parse/compile/evaluate/serialize)
  • + *
+ * + * Authentication is performed during the WebSocket handshake using + * Basic authentication from the HTTP upgrade request headers. + */ +@ServerEndpoint( + value = "/ws/eval", + configurator = EvalWebSocketEndpoint.AuthConfigurator.class +) +public class EvalWebSocketEndpoint { + + private static final Logger LOG = LogManager.getLogger(EvalWebSocketEndpoint.class); + private static final String USER_PROPERTY = "exist.eval.subject"; + + private static final Map sessions = new ConcurrentHashMap<>(); + private static ExecutorService queryExecutorService = createExecutorService(); + + private static ExecutorService createExecutorService() { + return Executors.newCachedThreadPool(r -> { + final Thread t = new Thread(r, "ws-eval-worker"); + t.setDaemon(true); + return t; + }); + } + + /** + * Shutdown the WebSocket eval subsystem. + */ + public static synchronized void shutdown() { + if (queryExecutorService != null) { + queryExecutorService.shutdown(); + queryExecutorService = null; + } + } + + /** + * Re-initialize the executor service if needed. + */ + private static synchronized ExecutorService getExecutorService() { + if (queryExecutorService == null) { + queryExecutorService = createExecutorService(); + } + return queryExecutorService; + } + + /** + * Configurator that extracts Basic auth credentials during the WebSocket + * handshake and authenticates the user against eXist's SecurityManager. + */ + public static class AuthConfigurator extends ServerEndpointConfig.Configurator { + @Override + public void modifyHandshake(final ServerEndpointConfig sec, + final HandshakeRequest request, + final HandshakeResponse response) { + final Map> headers = request.getHeaders(); + final List authHeaders = headers.get("authorization"); + + Subject subject = null; + if (authHeaders != null && !authHeaders.isEmpty()) { + final String credentials = authHeaders.get(0); + if (credentials.toLowerCase().startsWith("basic ")) { + try { + final byte[] decoded = Base64.decodeBase64( + credentials.substring("basic ".length())); + final String s = new String(decoded, UTF_8); + final int p = s.indexOf(':'); + final String username = p < 0 ? s : s.substring(0, p); + final String password = p < 0 ? null : s.substring(p + 1); + + final BrokerPool pool = BrokerPool.getInstance(); + final SecurityManager secman = pool.getSecurityManager(); + subject = secman.authenticate(username, password); + } catch (final AuthenticationException e) { + LOG.warn("WebSocket eval authentication failed: {}", e.getMessage()); + } catch (final EXistException e) { + LOG.error("Failed to get BrokerPool for WebSocket auth: {}", e.getMessage()); + } + } + } + + // No guest fallback — authentication is required for WebSocket eval. + // Unauthenticated connections will be rejected in onOpen(). + if (subject != null) { + sec.getUserProperties().put(USER_PROPERTY, subject); + } else { + LOG.debug("WebSocket eval connection without valid credentials — will be rejected in onOpen"); + } + } + } + + private static final int MAX_TEXT_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB + + @OnOpen + public void onOpen(final Session session, final EndpointConfig config) { + session.setMaxIdleTimeout(0); // no idle timeout for eval sessions + session.setMaxTextMessageBufferSize(MAX_TEXT_MESSAGE_SIZE); + + final Subject subject = (Subject) config.getUserProperties().get(USER_PROPERTY); + if (subject == null) { + try { + session.close(new CloseReason( + CloseReason.CloseCodes.VIOLATED_POLICY, "Authentication failed")); + } catch (final IOException e) { + LOG.debug("Error closing unauthenticated session: {}", e.getMessage()); + } + return; + } + + final EvalSession evalSession = new EvalSession(subject); + sessions.put(session, evalSession); + LOG.debug("Eval WebSocket opened for user: {}", subject.getName()); + } + + @OnMessage + public void onMessage(final String message, final Session session) { + final EvalSession evalSession = sessions.get(session); + if (evalSession == null) { + LOG.warn("Received message on unregistered session"); + return; + } + + final EvalProtocol.ClientMessage msg; + try { + msg = EvalProtocol.parseClientMessage(message); + } catch (final IOException e) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage("unknown", null, + "Invalid message: " + e.getMessage(), 0, 0, null)); + } catch (final IOException ex) { + LOG.debug("Failed to send parse error: {}", ex.getMessage()); + } + return; + } + + switch (msg.action) { + case EvalProtocol.ACTION_EVAL: + handleEval(session, evalSession, msg); + break; + case EvalProtocol.ACTION_CANCEL: + handleCancel(evalSession, msg); + break; + case EvalProtocol.ACTION_COMPILE: + handleCompile(session, evalSession, msg); + break; + case EvalProtocol.ACTION_ADMIN_CANCEL: + handleAdminCancel(session, evalSession, msg); + break; + default: + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(msg.id, null, + "Unknown action: " + msg.action, 0, 0, null)); + } catch (final IOException e) { + LOG.debug("Failed to send unknown action error: {}", e.getMessage()); + } + break; + } + } + + @OnClose + public void onClose(final Session session, final CloseReason reason) { + final EvalSession evalSession = sessions.remove(session); + if (evalSession != null) { + evalSession.cancelAll(); + LOG.debug("Eval WebSocket closed: {}", reason.getReasonPhrase()); + } + } + + @OnError + public void onError(final Session session, final Throwable error) { + if (error.getMessage() != null && error.getMessage().contains("Text message size")) { + LOG.warn("WebSocket message exceeds {}MB buffer limit: {}", MAX_TEXT_MESSAGE_SIZE / (1024 * 1024), error.getMessage()); + } else { + LOG.warn("WebSocket eval error: {}", error.getMessage(), error); + } + final EvalSession evalSession = sessions.remove(session); + if (evalSession != null) { + evalSession.cancelAll(); + } + } + + private void handleEval(final Session session, final EvalSession evalSession, + final EvalProtocol.ClientMessage msg) { + if (msg.query == null || msg.query.isEmpty()) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(msg.id, null, + "Missing required field: query", 0, 0, null)); + } catch (final IOException e) { + LOG.debug("Failed to send missing query error: {}", e.getMessage()); + } + return; + } + + // Execute on a worker thread to avoid blocking the WebSocket message thread + getExecutorService().submit(() -> { + try { + final BrokerPool pool = BrokerPool.getInstance(); + final QueryExecutor executor = new QueryExecutor(pool); + executor.execute(session, evalSession, msg); + } catch (final EXistException e) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(msg.id, null, + "Database unavailable: " + e.getMessage(), 0, 0, null)); + } catch (final IOException ex) { + LOG.debug("Failed to send database error: {}", ex.getMessage()); + } + } + }); + } + + private void handleCancel(final EvalSession evalSession, + final EvalProtocol.ClientMessage msg) { + final boolean cancelled = evalSession.cancelQuery(msg.id); + if (!cancelled) { + LOG.debug("Cancel requested for unknown query: {}", msg.id); + } + } + + /** + * Admin cancel: kill any running query by its context identity hash code. + * Requires DBA role. Uses the ProcessMonitor (same as system:kill-running-xquery). + */ + private void handleAdminCancel(final Session session, final EvalSession evalSession, + final EvalProtocol.ClientMessage msg) { + if (!evalSession.getSubject().hasDbaRole()) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(msg.id, null, + "Permission denied: admin-cancel requires DBA role", 0, 0, null)); + } catch (final IOException e) { + LOG.debug("Failed to send permission error: {}", e.getMessage()); + } + return; + } + + try { + final BrokerPool pool = BrokerPool.getInstance(); + final ProcessMonitor monitor = pool.getProcessMonitor(); + final int targetId = Integer.parseInt(msg.id); + + boolean found = false; + for (final org.exist.xquery.XQueryWatchDog wd : monitor.getRunningXQueries()) { + if (System.identityHashCode(wd.getContext()) == targetId) { + wd.kill(0); + found = true; + break; + } + } + + if (!found) { + LOG.debug("Admin cancel: query {} not found", msg.id); + } + } catch (final Exception e) { + LOG.warn("Admin cancel failed: {}", e.getMessage()); + } + } + + private void handleCompile(final Session session, final EvalSession evalSession, + final EvalProtocol.ClientMessage msg) { + if (msg.query == null || msg.query.isEmpty()) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(msg.id, null, + "Missing required field: query", 0, 0, null)); + } catch (final IOException e) { + LOG.debug("Failed to send missing query error: {}", e.getMessage()); + } + return; + } + + getExecutorService().submit(() -> { + try { + final BrokerPool pool = BrokerPool.getInstance(); + final QueryExecutor executor = new QueryExecutor(pool); + executor.compile(session, evalSession, msg); + } catch (final EXistException e) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(msg.id, null, + "Database unavailable: " + e.getMessage(), 0, 0, null)); + } catch (final IOException ex) { + LOG.debug("Failed to send database error: {}", ex.getMessage()); + } + } + }); + } +} diff --git a/exist-core/src/main/java/org/exist/http/ws/QueryExecutor.java b/exist-core/src/main/java/org/exist/http/ws/QueryExecutor.java new file mode 100644 index 00000000000..4ec8b64f787 --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/ws/QueryExecutor.java @@ -0,0 +1,389 @@ +/* + * 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.http.ws; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.security.Subject; +import org.exist.source.StringSource; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.util.serializer.XQuerySerializer; +import org.exist.xquery.*; +import org.exist.xquery.value.AnyURIValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceIterator; +import org.exist.xquery.value.ValueSequence; +import org.exist.xmldb.XmldbURI; +import org.xml.sax.SAXException; + +import jakarta.websocket.Session; +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.StringWriter; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +/** + * Executes XQuery expressions for the /ws/eval endpoint with support for + * streaming results, progress reporting, cancellation, and timing. + */ +public final class QueryExecutor { + + private static final Logger LOG = LogManager.getLogger(QueryExecutor.class); + + private static final long PROGRESS_INTERVAL_MS = 500; + + private final BrokerPool pool; + + public QueryExecutor(final BrokerPool pool) { + this.pool = pool; + } + + /** + * Execute a query and stream results back over the WebSocket session. + */ + public void execute(final Session wsSession, final EvalSession evalSession, + final EvalProtocol.ClientMessage msg) { + final EvalProtocol.Timing timing = new EvalProtocol.Timing(); + final long startTime = System.currentTimeMillis(); + final String user = evalSession.getSubject().getName(); + + try (final DBBroker broker = pool.get(Optional.of(evalSession.getSubject()))) { + // Parse phase + sendProgress(wsSession, msg.id, EvalProtocol.PHASE_PARSING, 0, 0); + QueryMonitorBroadcaster.broadcastEvent("started", msg.id, user, msg.query, + EvalProtocol.PHASE_PARSING, 0, 0); + final long parseStart = System.currentTimeMillis(); + + final XQueryContext context = new XQueryContext(pool); + configureContext(context, msg); + + final XQuery xquery = pool.getXQueryService(); + final CompiledXQuery compiled; + + // Compile phase + try { + compiled = xquery.compile(context, new StringSource(msg.query)); + } catch (final XPathException e) { + timing.parse = System.currentTimeMillis() - parseStart; + timing.total = System.currentTimeMillis() - startTime; + sendError(wsSession, msg.id, e, timing); + QueryMonitorBroadcaster.broadcastEvent("error", msg.id, user, msg.query, + null, 0, timing.total); + return; + } catch (final IOException e) { + timing.total = System.currentTimeMillis() - startTime; + sendError(wsSession, msg.id, null, e.getMessage(), 0, 0, timing); + QueryMonitorBroadcaster.broadcastEvent("error", msg.id, user, msg.query, + null, 0, timing.total); + return; + } + + timing.parse = System.currentTimeMillis() - parseStart; + final long compileEnd = System.currentTimeMillis(); + timing.compile = compileEnd - parseStart - timing.parse; + + sendProgress(wsSession, msg.id, EvalProtocol.PHASE_COMPILING, 0, + System.currentTimeMillis() - startTime); + + // Set timeout via watchdog + final XQueryWatchDog watchDog = context.getWatchDog(); + if (msg.maxExecutionTime > 0) { + watchDog.setTimeout(msg.maxExecutionTime); + } + evalSession.registerQuery(msg.id, watchDog); + + try { + // Evaluate phase + sendProgress(wsSession, msg.id, EvalProtocol.PHASE_EVALUATING, 0, + System.currentTimeMillis() - startTime); + QueryMonitorBroadcaster.broadcastEvent("progress", msg.id, user, msg.query, + EvalProtocol.PHASE_EVALUATING, 0, System.currentTimeMillis() - startTime); + final long evalStart = System.currentTimeMillis(); + + final Sequence result; + try { + result = xquery.execute(broker, compiled, null, new Properties(), true); + } catch (final TerminatedException e) { + timing.evaluate = System.currentTimeMillis() - evalStart; + timing.total = System.currentTimeMillis() - startTime; + if (watchDog.isTerminating()) { + sendCancelled(wsSession, msg.id, 0, timing); + QueryMonitorBroadcaster.broadcastEvent("cancelled", msg.id, user, msg.query, + null, 0, timing.total); + } else { + sendError(wsSession, msg.id, null, e.getMessage(), + e.getLine(), e.getColumn(), timing); + QueryMonitorBroadcaster.broadcastEvent("error", msg.id, user, msg.query, + null, 0, timing.total); + } + return; + } catch (final XPathException e) { + timing.evaluate = System.currentTimeMillis() - evalStart; + timing.total = System.currentTimeMillis() - startTime; + if (watchDog.isTerminating()) { + sendCancelled(wsSession, msg.id, 0, timing); + QueryMonitorBroadcaster.broadcastEvent("cancelled", msg.id, user, msg.query, + null, 0, timing.total); + } else { + sendError(wsSession, msg.id, e, timing); + QueryMonitorBroadcaster.broadcastEvent("error", msg.id, user, msg.query, + null, 0, timing.total); + } + return; + } + + timing.evaluate = System.currentTimeMillis() - evalStart; + + // Serialize phase + sendProgress(wsSession, msg.id, EvalProtocol.PHASE_SERIALIZING, 0, + System.currentTimeMillis() - startTime); + final long serStart = System.currentTimeMillis(); + + final long itemCount = result.getItemCount(); + final Properties outputProperties = msg.serialization != null + ? msg.serialization : new Properties(); + + try { + if (msg.streaming && itemCount > msg.chunkSize) { + streamResults(wsSession, msg.id, broker, result, outputProperties, + msg.chunkSize, timing, startTime, watchDog); + } else { + final String serialized = serializeAll(broker, result, outputProperties); + timing.serialize = System.currentTimeMillis() - serStart; + timing.total = System.currentTimeMillis() - startTime; + + sendResult(wsSession, msg.id, 1, serialized, false, timing, itemCount); + } + QueryMonitorBroadcaster.broadcastEvent("completed", msg.id, user, msg.query, + null, itemCount, System.currentTimeMillis() - startTime); + } catch (final SAXException | XPathException e) { + timing.serialize = System.currentTimeMillis() - serStart; + timing.total = System.currentTimeMillis() - startTime; + sendError(wsSession, msg.id, null, e.getMessage(), 0, 0, timing); + QueryMonitorBroadcaster.broadcastEvent("error", msg.id, user, msg.query, + null, 0, timing.total); + } + } finally { + evalSession.unregisterQuery(msg.id); + context.runCleanupTasks(); + } + + } catch (final EXistException | PermissionDeniedException e) { + timing.total = System.currentTimeMillis() - startTime; + sendError(wsSession, msg.id, null, e.getMessage(), 0, 0, timing); + QueryMonitorBroadcaster.broadcastEvent("error", msg.id, user, msg.query, + null, 0, timing.total); + } + } + + /** + * Compile-check a query without executing it. + */ + public void compile(final Session wsSession, final EvalSession evalSession, + final EvalProtocol.ClientMessage msg) { + try (final DBBroker broker = pool.get(Optional.of(evalSession.getSubject()))) { + final XQueryContext context = new XQueryContext(pool); + configureContext(context, msg); + + final XQuery xquery = pool.getXQueryService(); + try { + xquery.compile(context, new StringSource(msg.query)); + sendCompileResult(wsSession, msg.id, true, null, null, 0, 0); + } catch (final XPathException e) { + sendCompileResult(wsSession, msg.id, false, + e.getErrorCode() != null ? e.getErrorCode().toString() : null, + e.getDetailMessage(), e.getLine(), e.getColumn()); + } catch (final IOException e) { + sendCompileResult(wsSession, msg.id, false, null, e.getMessage(), 0, 0); + } finally { + context.runCleanupTasks(); + } + } catch (final EXistException | PermissionDeniedException e) { + sendError(wsSession, msg.id, null, e.getMessage(), 0, 0, null); + } + } + + private void configureContext(final XQueryContext context, + final EvalProtocol.ClientMessage msg) { + if (msg.moduleLoadPath != null) { + context.setModuleLoadPath(msg.moduleLoadPath); + } + if (msg.context != null) { + try { + final XmldbURI baseUri = XmldbURI.create(msg.context); + context.setStaticallyKnownDocuments(new XmldbURI[]{baseUri}); + context.setBaseURI(new AnyURIValue(msg.context)); + } catch (final XPathException e) { + LOG.warn("Invalid context URI: {}", msg.context, e); + } + } + if (msg.variables != null) { + for (final Map.Entry entry : msg.variables.entrySet()) { + try { + context.declareVariable(entry.getKey(), entry.getValue()); + } catch (final XPathException e) { + LOG.warn("Failed to declare variable ${}: {}", entry.getKey(), e.getMessage()); + } + } + } + } + + private void streamResults(final Session wsSession, final String queryId, + final DBBroker broker, final Sequence result, + final Properties outputProperties, final int chunkSize, + final EvalProtocol.Timing timing, final long startTime, + final XQueryWatchDog watchDog) { + final long serStart = System.currentTimeMillis(); + final long totalItems = result.getItemCount(); + int chunkNum = 0; + long itemsSent = 0; + + try { + final SequenceIterator iter = result.iterate(); + while (iter.hasNext()) { + if (watchDog.isTerminating()) { + timing.serialize = System.currentTimeMillis() - serStart; + timing.total = System.currentTimeMillis() - startTime; + sendCancelled(wsSession, queryId, itemsSent, timing); + return; + } + + // collect a chunk + final ValueSequence chunk = new ValueSequence(); + for (int i = 0; i < chunkSize && iter.hasNext(); i++) { + chunk.add(iter.nextItem()); + } + + chunkNum++; + itemsSent += chunk.getItemCount(); + final boolean more = iter.hasNext(); + + final String data = serializeAll(broker, chunk, outputProperties); + + if (!more) { + timing.serialize = System.currentTimeMillis() - serStart; + timing.total = System.currentTimeMillis() - startTime; + } + + sendResult(wsSession, queryId, chunkNum, data, more, + more ? null : timing, totalItems); + + // send progress during streaming + if (more && (System.currentTimeMillis() - startTime) % PROGRESS_INTERVAL_MS < 50) { + sendProgress(wsSession, queryId, EvalProtocol.PHASE_SERIALIZING, + itemsSent, System.currentTimeMillis() - startTime); + } + } + } catch (final XPathException | SAXException e) { + timing.serialize = System.currentTimeMillis() - serStart; + timing.total = System.currentTimeMillis() - startTime; + sendError(wsSession, queryId, null, e.getMessage(), 0, 0, timing); + } + } + + private String serializeAll(final DBBroker broker, final Sequence sequence, + final Properties outputProperties) + throws SAXException, XPathException { + final StringWriter writer = new StringWriter(); + final XQuerySerializer serializer = new XQuerySerializer(broker, outputProperties, writer); + serializer.serialize(sequence); + return writer.toString(); + } + + // --- WebSocket message senders --- + + private void sendProgress(final Session session, final String id, + final String phase, final long items, final long elapsed) { + try { + session.getBasicRemote().sendText( + EvalProtocol.progressMessage(id, phase, items, elapsed)); + } catch (final IOException e) { + LOG.debug("Failed to send progress: {}", e.getMessage()); + } + // Also broadcast to monitor channel (user/query not available here, + // but the snapshot fills in full details) + QueryMonitorBroadcaster.broadcastEvent("progress", id, "", null, phase, items, elapsed); + } + + private void sendResult(final Session session, final String id, final int chunk, + final String data, final boolean more, + @Nullable final EvalProtocol.Timing timing, final long items) { + try { + session.getBasicRemote().sendText( + EvalProtocol.resultMessage(id, chunk, data, more, timing, items)); + } catch (final IOException e) { + LOG.debug("Failed to send result: {}", e.getMessage()); + } + } + + private void sendError(final Session session, final String id, + @Nullable final XPathException xpe, + @Nullable final EvalProtocol.Timing timing) { + final String code = xpe != null && xpe.getErrorCode() != null + ? xpe.getErrorCode().toString() : null; + final String message = xpe != null ? xpe.getDetailMessage() : "Unknown error"; + final int line = xpe != null ? xpe.getLine() : 0; + final int column = xpe != null ? xpe.getColumn() : 0; + sendError(session, id, code, message, line, column, timing); + } + + private void sendError(final Session session, final String id, + @Nullable final String code, final String message, + final int line, final int column, + @Nullable final EvalProtocol.Timing timing) { + try { + session.getBasicRemote().sendText( + EvalProtocol.errorMessage(id, code, message, line, column, timing)); + } catch (final IOException e) { + LOG.debug("Failed to send error: {}", e.getMessage()); + } + } + + private void sendCancelled(final Session session, final String id, + final long items, + @Nullable final EvalProtocol.Timing timing) { + try { + session.getBasicRemote().sendText( + EvalProtocol.cancelledMessage(id, items, timing)); + } catch (final IOException e) { + LOG.debug("Failed to send cancelled: {}", e.getMessage()); + } + } + + private void sendCompileResult(final Session session, final String id, + final boolean success, + @Nullable final String errorCode, + @Nullable final String errorMessage, + final int line, final int column) { + try { + session.getBasicRemote().sendText( + EvalProtocol.compileResultMessage(id, success, errorCode, errorMessage, line, column)); + } catch (final IOException e) { + LOG.debug("Failed to send compile result: {}", e.getMessage()); + } + } +} diff --git a/exist-core/src/main/java/org/exist/http/ws/QueryMonitorBroadcaster.java b/exist-core/src/main/java/org/exist/http/ws/QueryMonitorBroadcaster.java new file mode 100644 index 00000000000..92b5b4a922c --- /dev/null +++ b/exist-core/src/main/java/org/exist/http/ws/QueryMonitorBroadcaster.java @@ -0,0 +1,147 @@ +/* + * 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.http.ws; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.storage.BrokerPool; +import org.exist.storage.ProcessMonitor; +import org.exist.xquery.XQueryWatchDog; +import org.exist.xquery.functions.websocket.WebSocketEndpoint; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.StringWriter; + +/** + * Bridges the /ws/eval query lifecycle to the /ws pub/sub channel system, + * broadcasting query events to admin monitoring clients subscribed to the + * {@code _monitor} channel. + * + *

Also provides periodic snapshots of ALL running queries (including + * REST/XQueryServlet queries that don't go through /ws/eval).

+ */ +public final class QueryMonitorBroadcaster { + + private static final Logger LOG = LogManager.getLogger(QueryMonitorBroadcaster.class); + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + private static final String CHANNEL = "_monitor"; + private static final int MAX_SOURCE_LENGTH = 100; + + private QueryMonitorBroadcaster() { + // utility class + } + + /** + * Broadcast a query lifecycle event to the _monitor channel. + * No-op if nobody is subscribed. + */ + public static void broadcastEvent(final String event, final String queryId, + final String user, @Nullable final String query, + @Nullable final String phase, final long items, + final long elapsed) { + if (WebSocketEndpoint.getChannelCount(CHANNEL) == 0) { + return; + } + try { + final String json = buildEventMessage(event, queryId, user, query, phase, items, elapsed); + WebSocketEndpoint.sendAll(CHANNEL, json); + } catch (final IOException e) { + LOG.debug("Failed to broadcast monitor event: {}", e.getMessage()); + } + } + + /** + * Broadcast a snapshot of all running queries from the ProcessMonitor. + * Called periodically (e.g., every 1 second) to catch queries from all + * execution paths (REST, XQueryServlet, URL rewrite, etc.). + */ + public static void broadcastSnapshot(final BrokerPool pool) { + if (WebSocketEndpoint.getChannelCount(CHANNEL) == 0) { + return; + } + try { + final ProcessMonitor monitor = pool.getProcessMonitor(); + final XQueryWatchDog[] watchDogs = monitor.getRunningXQueries(); + + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", "monitor"); + gen.writeStringField("event", "snapshot"); + gen.writeArrayFieldStart("queries"); + + for (final XQueryWatchDog wd : watchDogs) { + final var ctx = wd.getContext(); + gen.writeStartObject(); + gen.writeStringField("queryId", String.valueOf(System.identityHashCode(ctx))); + gen.writeStringField("user", ctx.getEffectiveUser().getName()); + + final String sourceKey = ctx.getSource() != null ? ctx.getSource().pathOrShortIdentifier() : ""; + gen.writeStringField("source", sourceKey); + + gen.writeStringField("phase", "evaluating"); + gen.writeNumberField("elapsed", + System.currentTimeMillis() - wd.getStartTime()); + gen.writeBooleanField("terminating", wd.isTerminating()); + gen.writeEndObject(); + } + + gen.writeEndArray(); + gen.writeEndObject(); + } + + WebSocketEndpoint.sendAll(CHANNEL, sw.toString()); + } catch (final Exception e) { + LOG.debug("Failed to broadcast monitor snapshot: {}", e.getMessage()); + } + } + + private static String buildEventMessage(final String event, final String queryId, + final String user, @Nullable final String query, + @Nullable final String phase, final long items, + final long elapsed) throws IOException { + final StringWriter sw = new StringWriter(); + try (final JsonGenerator gen = JSON_FACTORY.createGenerator(sw)) { + gen.writeStartObject(); + gen.writeStringField("type", "monitor"); + gen.writeStringField("event", event); + gen.writeStringField("queryId", queryId); + gen.writeStringField("user", user); + if (query != null) { + gen.writeStringField("source", + query.length() > MAX_SOURCE_LENGTH + ? query.substring(0, MAX_SOURCE_LENGTH) + "..." + : query); + } + if (phase != null) { + gen.writeStringField("phase", phase); + } + gen.writeNumberField("items", items); + gen.writeNumberField("elapsed", elapsed); + gen.writeEndObject(); + } + return sw.toString(); + } +} diff --git a/exist-core/src/main/java/org/exist/indexing/IndexController.java b/exist-core/src/main/java/org/exist/indexing/IndexController.java index 3392ac47005..e85e99c82d4 100644 --- a/exist-core/src/main/java/org/exist/indexing/IndexController.java +++ b/exist-core/src/main/java/org/exist/indexing/IndexController.java @@ -40,6 +40,7 @@ import org.w3c.dom.NodeList; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -514,6 +515,9 @@ public List getQueryRewriters(final XQueryContext context) { rewriters.add(rewriter); } } + // Sort by priority so the optimizer dispatches to value-aware indexes + // before string-aware ones; map iteration order is otherwise nondeterministic. + rewriters.sort(Comparator.comparingInt(QueryRewriter::getPriority)); return rewriters; } } diff --git a/exist-core/src/main/java/org/exist/jetty/JettyStart.java b/exist-core/src/main/java/org/exist/jetty/JettyStart.java index 3225fbab227..f025077d03e 100644 --- a/exist-core/src/main/java/org/exist/jetty/JettyStart.java +++ b/exist-core/src/main/java/org/exist/jetty/JettyStart.java @@ -26,14 +26,12 @@ import org.apache.logging.log4j.Logger; import org.eclipse.jetty.server.*; import org.eclipse.jetty.server.handler.ContextHandler; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.util.Jetty; -import org.eclipse.jetty.util.MultiException; import org.eclipse.jetty.util.component.LifeCycle; -import org.eclipse.jetty.util.resource.PathResource; import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceFactory; import org.eclipse.jetty.xml.XmlConfiguration; import org.exist.SystemProperties; import org.exist.http.servlets.ExistExtensionServlet; @@ -68,7 +66,7 @@ /** * This class provides a main method to start Jetty with eXist. It registers shutdown * handlers to cleanly shut down the database and the webserver. - * + * * @author wolf */ public class JettyStart extends Observable implements LifeCycle.Listener { @@ -149,8 +147,6 @@ public synchronized void run(final boolean standalone) { return jettyPath; }); - System.setProperty("org.eclipse.jetty.util.log.class", "org.eclipse.jetty.util.log.Slf4jLog"); - final Path jettyConfig; if (standalone) { jettyConfig = Paths.get(jettyProperty).normalize().resolve("etc").resolve(Main.STANDALONE_ENABLED_JETTY_CONFIGS); @@ -159,7 +155,7 @@ public synchronized void run(final boolean standalone) { } run(new String[] { jettyConfig.toAbsolutePath().toString() }, null); } - + public synchronized void run(final String[] args, final Observer observer) { if (args.length == 0) { logger.error("No configuration file specified!"); @@ -261,7 +257,7 @@ public synchronized void run(final String[] args, final Observer observer) { XmlConfiguration last = null; for(final Path confFile : configFiles) { logger.info("[loading jetty configuration : {}]", confFile.toString()); - final Resource resource = new PathResource(confFile); + final Resource resource = ResourceFactory.root().newResource(confFile); final XmlConfiguration configuration = new XmlConfiguration(resource); if (last != null) { configuration.getIdMap().putAll(last.getIdMap()); @@ -271,9 +267,12 @@ public synchronized void run(final String[] args, final Observer observer) { last = configuration; } + // configure WebSocket on any ServletContextHandler + configureWebSocket(configuredObjects); + // start Jetty final Optional maybeServer = startJetty(configuredObjects); - if(!maybeServer.isPresent()) { + if(maybeServer.isEmpty()) { logger.error("Unable to find a server to start in jetty configurations"); throw new IllegalStateException(); } @@ -303,7 +302,7 @@ public synchronized void run(final String[] args, final Observer observer) { allPorts.append(networkConnector.getLocalPort()); } } - + //************************************************************* final List serverUris = getSeverURIs(server); if(!serverUris.isEmpty()) { @@ -317,9 +316,9 @@ public synchronized void run(final String[] args, final Observer observer) { } logger.info("Configured contexts:"); - final LinkedHashSet handlers = getAllHandlers(server.getHandler()); + final List handlers = getAllHandlers(server.getHandler()); for (final Handler handler: handlers) { - + if (handler instanceof ContextHandler contextHandler) { logger.info("{} ({})", contextHandler.getContextPath(), contextHandler.getDisplayName()); } @@ -348,29 +347,7 @@ public synchronized void run(final String[] args, final Observer observer) { setChanged(); notifyObservers(SIGNAL_STARTED); - - } catch (final MultiException e) { - - // Mute the BindExceptions - - boolean hasBindException = false; - for (final Throwable t : e.getThrowables()) { - if (t instanceof java.net.BindException) { - hasBindException = true; - logger.error("----------------------------------------------------------"); - logger.error("ERROR: Could not bind to port because {}", t.getMessage()); - logger.error(t.toString()); - logger.error("----------------------------------------------------------"); - } - } - // If it is another error, print stacktrace - if (!hasBindException) { - e.printStackTrace(); - } - setChanged(); - notifyObservers(SIGNAL_ERROR); - } catch (final SocketException e) { logger.error("----------------------------------------------------------"); logger.error("ERROR: Could not bind to port because {}", e.getMessage()); @@ -378,44 +355,65 @@ public synchronized void run(final String[] args, final Observer observer) { logger.error("----------------------------------------------------------"); setChanged(); notifyObservers(SIGNAL_ERROR); - + } catch (final Exception e) { - e.printStackTrace(); + logger.fatal("An unexpected error occurred, web server can not be started: {}", e.getMessage(), e); setChanged(); notifyObservers(SIGNAL_ERROR); } } - private LinkedHashSet getAllHandlers(final Handler handler) { - if(handler instanceof HandlerWrapper handlerWrapper) { - final LinkedHashSet handlers = new LinkedHashSet<>(); - handlers.add(handlerWrapper); - if(handlerWrapper.getHandler() != null) { - handlers.addAll(getAllHandlers(handlerWrapper.getHandler())); + private void configureWebSocket(final List configuredObjects) { + for (final Object obj : configuredObjects) { + if (obj instanceof Server server) { + final List handlers = getAllHandlers(server.getHandler()); + for (final Handler handler : handlers) { + if (handler instanceof ServletContextHandler sch) { + try { + org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer + .configure(sch, (servletContext, serverContainer) -> { + serverContainer.addEndpoint( + org.exist.xquery.functions.websocket.WebSocketEndpoint.class); + logger.info("[WebSocket endpoint registered: /ws]"); + serverContainer.addEndpoint( + org.exist.http.ws.EvalWebSocketEndpoint.class); + logger.info("[WebSocket endpoint registered: /ws/eval]"); + }); + org.exist.xquery.functions.websocket.WebSocketEndpoint.initialize(); + return; // only need to configure once + } catch (final Exception e) { + logger.warn("Failed to configure WebSocket endpoint: {}", e.getMessage(), e); + } + } + } } - return handlers; + } + } - } else if(handler instanceof HandlerContainer handlerContainer) { - final LinkedHashSet handlers = new LinkedHashSet<>(); - handlers.add(handler); - for(final Handler childHandler : handlerContainer.getChildHandlers()) { + private List getAllHandlers(final Handler handler) { + final List handlers = new ArrayList<>(); + handlers.add(handler); + + if (handler instanceof Handler.Wrapper wrapper) { + if (wrapper.getHandler() != null) { + handlers.addAll(getAllHandlers(wrapper.getHandler())); + } + } else if (handler instanceof Handler.Container container) { + for (final Handler childHandler : container.getHandlers()) { handlers.addAll(getAllHandlers(childHandler)); } - return handlers; - - } else { - //assuming just Handler - final LinkedHashSet handlers = new LinkedHashSet<>(); - handlers.add(handler); - return handlers; } + + return handlers; } /** * See {@link Server#getURI()} */ private List getSeverURIs(final Server server) { - final ContextHandler context = server.getChildHandlerByClass(ContextHandler.class); + final ContextHandler context = server.getHandler() instanceof Handler.Container container + ? container.getDescendant(ContextHandler.class) + : null; return Arrays.stream(server.getConnectors()) .filter(connector -> connector instanceof NetworkConnector) .map(connector -> (NetworkConnector)connector) @@ -438,9 +436,13 @@ private URI getURI(final NetworkConnector networkConnector, final ContextHandler } String host = null; - if (context != null && context.getVirtualHosts() != null && context.getVirtualHosts().length > 0) { - host = context.getVirtualHosts()[0]; - } else { + if (context != null) { + final List virtualHosts = context.getVirtualHosts(); + if (virtualHosts != null && !virtualHosts.isEmpty()) { + host = virtualHosts.getFirst(); + } + } + if (host == null) { host = networkConnector.getHost(); } @@ -492,11 +494,9 @@ private Optional startJetty(final List configuredObjects) throws server = Optional.of(_server); } - if (configuredObject instanceof LifeCycle lc) { - if (!lc.isRunning()) { - logger.info("[Starting jetty component : {}]", lc.getClass().getName()); - lc.start(); - } + if (configuredObject instanceof LifeCycle lc && !lc.isRunning()) { + logger.info("[Starting jetty component : {}]", lc.getClass().getName()); + lc.start(); } } @@ -562,14 +562,14 @@ public synchronized void shutdown() { logger.warn("Unable to remove BrokerPoolsAndJetty.ShutdownHook hook: {}", e.getMessage()); } }); - + BrokerPool.stopAll(false); - + while (status != STATUS_STOPPED) { try { wait(); } catch (final InterruptedException e) { - // ignore + Thread.currentThread().interrupt(); } } } @@ -603,7 +603,7 @@ public void run() { // make sure to stop the timer thread! timer.cancel(); } catch (final Exception e) { - e.printStackTrace(); + logger.error("An error occurred in the shutdown scheduler: {}", e.getMessage(), e); } } }, 1000); // timer.schedule @@ -644,6 +644,7 @@ public synchronized boolean isStarted() { try { wait(); } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); } } return false; @@ -669,6 +670,7 @@ public synchronized void lifeCycleStarted(final LifeCycle lifeCycle) { @Override public void lifeCycleFailure(final LifeCycle lifeCycle, final Throwable throwable) { + // no-op } @Override @@ -681,6 +683,8 @@ public synchronized void lifeCycleStopping(final LifeCycle lifeCycle) { @Override public synchronized void lifeCycleStopped(final LifeCycle lifeCycle) { logger.info("Jetty server stopped"); + org.exist.xquery.functions.websocket.WebSocketEndpoint.shutdown(); + org.exist.http.ws.EvalWebSocketEndpoint.shutdown(); status = STATUS_STOPPED; notifyAll(); } diff --git a/exist-core/src/main/java/org/exist/jetty/WebAppContext.java b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java index d07a431fa50..660bd0cb545 100644 --- a/exist-core/src/main/java/org/exist/jetty/WebAppContext.java +++ b/exist-core/src/main/java/org/exist/jetty/WebAppContext.java @@ -27,17 +27,17 @@ * @author Dmitriy Shabanov * */ -public class WebAppContext extends org.eclipse.jetty.webapp.WebAppContext { - +public class WebAppContext extends org.eclipse.jetty.ee10.webapp.WebAppContext { + @Override public String toString() { return "eXist-db Open Source Native XML Database"; } - + @Override protected void doStop() throws Exception { super.doStop(); - + BrokerPool.stopAll(true); } diff --git a/exist-core/src/main/java/org/exist/repo/ExistPkgInfo.java b/exist-core/src/main/java/org/exist/repo/ExistPkgInfo.java index 84806e3a4e6..cd9e00dc45c 100644 --- a/exist-core/src/main/java/org/exist/repo/ExistPkgInfo.java +++ b/exist-core/src/main/java/org/exist/repo/ExistPkgInfo.java @@ -69,6 +69,10 @@ public Set getJavaModules() { return myJava.keySet(); } + public Set getXQueryModules() { + return myXquery.keySet(); + } + public void addJar(String jar) { myJars.add(jar); } diff --git a/exist-core/src/main/java/org/exist/repo/ExistRepository.java b/exist-core/src/main/java/org/exist/repo/ExistRepository.java index 6c6ec6fee9b..354a2278634 100644 --- a/exist-core/src/main/java/org/exist/repo/ExistRepository.java +++ b/exist-core/src/main/java/org/exist/repo/ExistRepository.java @@ -334,6 +334,48 @@ public List getJavaModules() { return modules; } + public List getXQueryModules() { + final List modules = new ArrayList<>(); + for (final Packages pp : myParent.listPackages()) { + final Package pkg = pp.latest(); + // 1. XQuery modules declared in exist.xml + final ExistPkgInfo info = (ExistPkgInfo) pkg.getInfo("exist"); + if (info != null) { + modules.addAll(info.getXQueryModules()); + } + // 2. XQuery modules declared in expath-pkg.xml (standard EXPath components) + final FileSystemResolver resolver = (FileSystemResolver) pkg.getResolver(); + final Path pkgDescriptor = resolver.resolveResourceAsFile("expath-pkg.xml"); + if (pkgDescriptor != null && Files.exists(pkgDescriptor)) { + try { + final javax.xml.parsers.DocumentBuilderFactory dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + final Document doc = dbf.newDocumentBuilder().parse(pkgDescriptor.toFile()); + final org.w3c.dom.NodeList xqueryElements = doc.getElementsByTagNameNS( + "http://expath.org/ns/pkg", "xquery"); + for (int i = 0; i < xqueryElements.getLength(); i++) { + final org.w3c.dom.Element xquery = (org.w3c.dom.Element) xqueryElements.item(i); + final org.w3c.dom.NodeList nsElements = xquery.getElementsByTagNameNS( + "http://expath.org/ns/pkg", "namespace"); + for (int j = 0; j < nsElements.getLength(); j++) { + final String ns = nsElements.item(j).getTextContent().trim(); + if (!ns.isEmpty()) { + try { + modules.add(new URI(ns)); + } catch (final URISyntaxException e) { + LOG.debug("Invalid namespace URI in expath-pkg.xml: {}", ns); + } + } + } + } + } catch (final Exception e) { + LOG.debug("Error parsing expath-pkg.xml for package {}: {}", pkg.getName(), e.getMessage()); + } + } + } + return modules; + } + public static Path getRepositoryDir(final Configuration config) throws IOException { final Path dataDir = Optional.ofNullable((Path) config.getProperty(BrokerPool.PROPERTY_DATA_DIR)) .orElse(Paths.get(NativeBroker.DEFAULT_DATA_DIR)); diff --git a/exist-core/src/main/java/org/exist/source/AbstractSource.java b/exist-core/src/main/java/org/exist/source/AbstractSource.java index 24bbdf9ebbb..aa90293a5aa 100644 --- a/exist-core/src/main/java/org/exist/source/AbstractSource.java +++ b/exist-core/src/main/java/org/exist/source/AbstractSource.java @@ -89,6 +89,9 @@ public QName isModule() throws IOException { * @param is the input stream * @return The guessed encoding. */ + // TODO(rd-parser): DeclScanner is a lightweight ANTLR 2 pre-scanner that extracts + // version/encoding declarations without full parsing. The rd parser may need an + // equivalent lightweight method (e.g., XQueryParser.scanVersionDecl). protected static String guessXQueryEncoding(final InputStream is) { final XQueryLexer lexer = new XQueryLexer(null, new InputStreamReader(is)); final DeclScanner scanner = new DeclScanner(lexer); diff --git a/exist-core/src/main/java/org/exist/storage/serializers/EXistOutputKeys.java b/exist-core/src/main/java/org/exist/storage/serializers/EXistOutputKeys.java index ca85a06f5fe..6342b32e7b7 100644 --- a/exist-core/src/main/java/org/exist/storage/serializers/EXistOutputKeys.java +++ b/exist-core/src/main/java/org/exist/storage/serializers/EXistOutputKeys.java @@ -28,6 +28,18 @@ public class EXistOutputKeys { */ public static final String ITEM_SEPARATOR = "item-separator"; + // --- QT4 Serialization 4.0 parameters --- + public static final String CANONICAL = "canonical"; + public static final String ESCAPE_SOLIDUS = "escape-solidus"; + public static final String JSON_LINES = "json-lines"; + + // --- CSV serialization parameters --- + public static final String CSV_FIELD_DELIMITER = "csv.field-delimiter"; + public static final String CSV_ROW_DELIMITER = "csv.row-delimiter"; + public static final String CSV_QUOTE_CHARACTER = "csv.quote-character"; + public static final String CSV_HEADER = "csv.header"; + public static final String CSV_QUOTES = "csv.quotes"; + public static final String OMIT_ORIGINAL_XML_DECLARATION = "omit-original-xml-declaration"; public static final String OUTPUT_DOCTYPE = "output-doctype"; @@ -94,6 +106,15 @@ public class EXistOutputKeys { */ public static final String ALLOW_DUPLICATE_NAMES = "allow-duplicate-names"; + /** + * eXist-specific opt-in for the legacy XML-to-JSON conversion (where a single + * XML element/document was converted to a JSON object graph and an empty + * element became {@code null}). Default is {@code "no"}, which makes single + * nodes follow W3C JSON Output Method semantics: serialize as XML and wrap + * the result in a JSON string. + */ + public static final String LEGACY_JSON_CONVERSION = "legacy-json-conversion"; + public static final String HTML_VERSION = "html-version"; /** diff --git a/exist-core/src/main/java/org/exist/storage/serializers/Serializer.java b/exist-core/src/main/java/org/exist/storage/serializers/Serializer.java index e2f2166443b..27b5aaec44e 100644 --- a/exist-core/src/main/java/org/exist/storage/serializers/Serializer.java +++ b/exist-core/src/main/java/org/exist/storage/serializers/Serializer.java @@ -825,7 +825,8 @@ public void setStylesheet(final Document doc, final @Nullable String stylesheet) // restore handlers receiver = oldReceiver; - factory.get().setURIResolver(null); + // Saxon 12 rejects null URIResolver; reset to default identity resolver + factory.get().setURIResolver((href, base) -> null); } LOG.debug("compiling stylesheet took {}", System.currentTimeMillis() - start); if (templates != null) { diff --git a/exist-core/src/main/java/org/exist/storage/serializers/XIncludeFilter.java b/exist-core/src/main/java/org/exist/storage/serializers/XIncludeFilter.java index 36ec6696c29..3de7672ad8a 100644 --- a/exist-core/src/main/java/org/exist/storage/serializers/XIncludeFilter.java +++ b/exist-core/src/main/java/org/exist/storage/serializers/XIncludeFilter.java @@ -88,6 +88,8 @@ public class XIncludeFilter implements Receiver { private static final QName HREF_ATTRIB = new QName("href", XMLConstants.NULL_NS_URI); private static final QName XPOINTER_ATTRIB = new QName("xpointer", XMLConstants.NULL_NS_URI); + private static final QName PARSE_ATTRIB = new QName("parse", XMLConstants.NULL_NS_URI); + private static final QName ENCODING_ATTRIB = new QName("encoding", XMLConstants.NULL_NS_URI); private static final String XI_INCLUDE = "include"; private static final String XI_FALLBACK = "fallback"; @@ -112,6 +114,9 @@ private ResourceError(final String message) { private @Nullable String moduleLoadPath = null; private @Nullable Map namespaces = null; private boolean inFallback = false; + private int inIncludeDepth = 0; // depth of non-XInclude elements inside xi:include (for suppressing non-fallback children) + private boolean suppressIncludeChildren = false; // true while processing xi:include's own children (not expanded content) + private int fallbackCount = 0; // count of xi:fallback children in current xi:include private @Nullable ResourceError error = null; public XIncludeFilter(final Serializer serializer, @Nullable final Receiver receiver) { @@ -132,6 +137,9 @@ public void reset() { this.moduleLoadPath = null; this.namespaces = null; this.inFallback = false; + this.inIncludeDepth = 0; + this.suppressIncludeChildren = false; + this.fallbackCount = 0; this.error = null; } @@ -155,6 +163,9 @@ public void setModuleLoadPath(final String path) { @Override public void characters(final CharSequence seq) throws SAXException { + if (suppressIncludeChildren && !inFallback) { + return; // suppress non-fallback content inside xi:include + } if (!inFallback || error != null) { receiver.characters(seq); } @@ -162,6 +173,9 @@ public void characters(final CharSequence seq) throws SAXException { @Override public void comment(final char[] ch, final int start, final int length) throws SAXException { + if (suppressIncludeChildren && !inFallback) { + return; // suppress non-fallback content inside xi:include + } if (!inFallback || error != null) { receiver.comment(ch, start, length); } @@ -179,13 +193,19 @@ public void endElement(final QName qname) throws SAXException { inFallback = false; // clear error error = null; - } else if (XI_INCLUDE.equals(qname.getLocalPart()) && error != null) { - // found an error, but there was no fallback element. - // throw the exception now - final SAXException e = error.cause.map(cause -> new SAXException(error.message, cause)).orElse(new SAXException(error.message)); - error = null; - throw e; + } else if (XI_INCLUDE.equals(qname.getLocalPart())) { + inIncludeDepth--; + suppressIncludeChildren = (inIncludeDepth > 0); // restore suppression if nested + if (error != null) { + // found an error, but there was no fallback element. + // throw the exception now + final SAXException e = error.cause.map(cause -> new SAXException(error.message, cause)).orElse(new SAXException(error.message)); + error = null; + throw e; + } } + } else if (suppressIncludeChildren && !inFallback) { + // Inside xi:include but not in fallback — suppress non-fallback children per spec } else if (!inFallback || error != null) { receiver.endElement(qname); } @@ -237,8 +257,28 @@ public void startElement(final QName qname, final AttrList attribs) throws SAXEx if (LOG.isDebugEnabled()) { LOG.debug("processing include ..."); } + inIncludeDepth++; + + // Validate parse attribute (per spec 4.1: must be "xml" or "text") + final String parseMode = attribs.getValue(PARSE_ATTRIB); + if (parseMode != null && !"xml".equals(parseMode) && !"text".equals(parseMode)) { + throw new SAXException("Invalid value for parse attribute: '" + parseMode + + "'. Must be 'xml' or 'text'."); + } + + // processXInclude will serialize included content through this filter; + // suppress only the xi:include's own children (not the expanded content) + final boolean prevSuppress = suppressIncludeChildren; + suppressIncludeChildren = false; // allow expanded content through - final Optional maybeResourceError = processXInclude(attribs.getValue(HREF_ATTRIB), attribs.getValue(XPOINTER_ATTRIB)); + final String encoding = attribs.getValue(ENCODING_ATTRIB); + final Optional maybeResourceError = processXInclude( + attribs.getValue(HREF_ATTRIB), attribs.getValue(XPOINTER_ATTRIB), + parseMode, encoding); + + // After processXInclude returns, any remaining SAX events until + // are the xi:include's own children — suppress them (except fallback) + suppressIncludeChildren = true; if (maybeResourceError.isPresent()) { final ResourceError resourceError = maybeResourceError.get(); @@ -250,6 +290,8 @@ public void startElement(final QName qname, final AttrList attribs) throws SAXEx } else if (qname.getLocalPart().equals(XI_FALLBACK)) { inFallback = true; } + } else if (suppressIncludeChildren && !inFallback) { + // Inside xi:include but not in fallback — suppress non-fallback children per spec } else if (!inFallback || error != null) { //LOG.debug("start: " + qName); receiver.startElement(qname, attribs); @@ -270,12 +312,24 @@ public void highlightText(final CharSequence seq) { /** * @param href The resource to be xincluded * @param xpointer The xpointer + * @param parseMode The parse mode ("xml" or "text"), null defaults to "xml" + * @param encoding The encoding for text inclusion, null defaults to UTF-8 * @return Optionally a ResourceError if it was not possible to retrieve the resource * to be xincluded * @throws SAXException If a SAX processing error occurs */ - protected Optional processXInclude(final String href, String xpointer) throws SAXException { - if (href == null) { + protected Optional processXInclude(final String href, String xpointer, + @Nullable final String parseMode, + @Nullable final String encoding) throws SAXException { + if (href == null && xpointer == null) { + throw new SAXException("No href or xpointer attribute found in XInclude include element"); + } + + // Intra-document reference: xpointer without href (or href="") + if (href == null || href.isEmpty()) { + if (xpointer != null) { + return processIntraDocumentXPointer(xpointer); + } throw new SAXException("No href attribute found in XInclude include element"); } // save some settings @@ -400,6 +454,20 @@ protected Optional processXInclude(final String href, String xpoi return Optional.of(new ResourceError("document " + docUri + " not found")); } + // Handle parse="text" — include resource as text, not XML + if ("text".equals(parseMode)) { + final String textContent = readResourceAsText(doc, memtreeDoc, docUri, href, encoding); + if (textContent != null) { + characters(textContent); + } else { + return Optional.of(new ResourceError("Unable to read text content from " + (docUri != null ? docUri : href))); + } + // restore settings and return + document = prevDoc; + serializer.createContainerElements = createContainerElements; + return Optional.empty(); + } + if (xpointer == null && !xqueryDoc) { // no xpointer found - just serialize the doc if (memtreeDoc == null) { @@ -412,11 +480,28 @@ protected Optional processXInclude(final String href, String xpoi Source source = null; final XQueryPool pool = serializer.broker.getBrokerPool().getXQueryPool(); CompiledXQuery compiled = null; + boolean wasElementScheme = false; try { if (xpointer == null) { source = new DBSource(serializer.broker.getBrokerPool(), (BinaryDocument) doc, true); } else { + wasElementScheme = xpointer.trim().startsWith("element("); + xpointer = convertXPointerToXPath(xpointer); xpointer = checkNamespaces(xpointer); + // element() scheme produces XPath — needs doc() context + // and must be compiled as regular XQuery, not xpointer mode + if (wasElementScheme) { + final XmldbURI contextDocUri = doc != null ? doc.getURI() : docUri; + if (contextDocUri != null) { + if (xpointer.startsWith("/")) { + // Child sequence: /1/2 -> doc('...')/*[1]/*[2] + xpointer = "doc('" + contextDocUri + "')" + xpointer; + } else if (xpointer.startsWith("id(")) { + // ID-based: id('x') -> doc('...')/id('x') + xpointer = "doc('" + contextDocUri + "')/" + xpointer; + } + } + } source = new StringSource(xpointer); } final XQuery xquery = serializer.broker.getBrokerPool().getXQueryService(); @@ -461,7 +546,10 @@ protected Optional processXInclude(final String href, String xpoi if (compiled == null) { try { - compiled = xquery.compile(context, source, xpointer != null); + // element() scheme expressions are converted to regular XQuery + // (doc('...')/*[1]) and must not use xpointer compilation mode + final boolean useXPointerMode = xpointer != null && !wasElementScheme; + compiled = xquery.compile(context, source, useXPointerMode); } catch (final IOException e) { throw new SAXException("I/O error while reading query for xinclude: " + e.getMessage(), e); } @@ -500,8 +588,12 @@ protected Optional processXInclude(final String href, String xpoi } } catch (final XPathException | PermissionDeniedException e) { + // XPointer evaluation failures are resource errors per XInclude spec 4.2, + // not fatal errors. Return as ResourceError to allow fallback processing. LOG.warn("xpointer error", e); - throw new SAXException("Error while processing XInclude expression: " + e.getMessage(), e); + document = prevDoc; + serializer.createContainerElements = createContainerElements; + return Optional.of(new ResourceError("Error while processing XInclude expression: " + e.getMessage(), e)); } finally { if (compiled != null) { pool.returnCompiledXQuery(source, compiled); @@ -515,6 +607,251 @@ protected Optional processXInclude(final String href, String xpoi return Optional.empty(); } + /** + * Handle intra-document XPointer references (xpointer without href). + * Per XInclude spec, when href is absent or empty, the xpointer is evaluated + * against the current document. + */ + private Optional processIntraDocumentXPointer(String xpointer) throws SAXException { + if (document == null) { + return Optional.of(new ResourceError("No current document for intra-document XPointer reference")); + } + + // Convert element() scheme to XPath if needed + xpointer = convertXPointerToXPath(xpointer); + + // For absolute XPath expressions (from element() scheme), wrap with doc() + // to ensure the document context is properly set + final String docUri = document.getURI().toString(); + if (xpointer.startsWith("/")) { + xpointer = "doc('" + docUri + "')" + xpointer; + } else if (xpointer.startsWith("id(")) { + // id() needs the document context — wrap: doc('...')/id('...') + xpointer = "doc('" + docUri + "')/" + xpointer; + } + + final XQueryPool pool = serializer.broker.getBrokerPool().getXQueryPool(); + CompiledXQuery compiled = null; + Source source = null; + try { + xpointer = checkNamespaces(xpointer); + source = new StringSource(xpointer); + final XQuery xquery = serializer.broker.getBrokerPool().getXQueryService(); + XQueryContext context; + compiled = pool.borrowCompiledXQuery(serializer.broker, source); + if (compiled == null) { + context = new XQueryContext(serializer.broker.getBrokerPool()); + } else { + context = compiled.getContext(); + context.prepareForReuse(); + } + if (namespaces != null) { + context.declareNamespaces(namespaces); + } + context.declareNamespace("xinclude", Namespaces.XINCLUDE_NS); + // Set the current document as the statically known document + context.setStaticallyKnownDocuments(new XmldbURI[]{document.getURI()}); + + if (compiled == null) { + compiled = xquery.compile(context, source, true); + } else { + compiled.getContext().updateContext(context); + context.getWatchDog().reset(); + } + + try { + final Sequence seq = xquery.execute(serializer.broker, compiled, null); + if (Type.subTypeOf(seq.getItemType(), Type.NODE)) { + NodeValue node; + for (final SequenceIterator i = seq.iterate(); i.hasNext(); ) { + node = (NodeValue) i.nextItem(); + serializer.serializeToReceiver(node, false); + } + } else { + for (int i = 0; i < seq.getItemCount(); i++) { + characters(seq.itemAt(i).getStringValue()); + } + } + } finally { + context.runCleanupTasks(); + } + return Optional.empty(); + } catch (final XPathException | PermissionDeniedException e) { + LOG.warn("intra-document xpointer error", e); + throw new SAXException("Error while processing intra-document XPointer: " + e.getMessage(), e); + } catch (final IOException e) { + throw new SAXException("I/O error while reading intra-document XPointer query: " + e.getMessage(), e); + } finally { + if (compiled != null) { + pool.returnCompiledXQuery(source, compiled); + } + } + } + + /** + * Convert XPointer element() scheme to XPath expressions. + * The xpointer() scheme is handled natively by the ANTLR parser's xpointer() rule, + * so we leave it as-is and only convert element() scheme pointers. + * + * Handles: + * element(/1) -> /node()[1] + * element(/1/2/3) -> /node()[1]/node()[2]/node()[3] + * element(myid) -> id('myid') + * element(myid/2/3) -> id('myid')/node()[2]/node()[3] + * xpointer(expr) -> xpointer(expr) (left for ANTLR parser) + * xmlns(...)element() -> strips xmlns(), converts element() + */ + private static String convertXPointerToXPath(String xpointer) { + xpointer = xpointer.trim(); + + // xpointer() scheme — leave as-is; the ANTLR parser's xpointer() rule handles it + if (xpointer.startsWith("xpointer(")) { + return xpointer; + } + + // Handle element() scheme + if (xpointer.startsWith("element(") && xpointer.endsWith(")")) { + final String content = xpointer.substring(8, xpointer.length() - 1).trim(); + return convertElementSchemeToXPath(content); + } + + // Handle multiple schemes: xmlns(...)element(...) + // Strip xmlns() schemes first (handled by checkNamespaces), then look for element() + if (xpointer.contains("element(")) { + int idx = 0; + while (idx < xpointer.length()) { + if (xpointer.startsWith("xmlns(", idx)) { + final int close = xpointer.indexOf(')', idx); + if (close > 0) { + idx = close + 1; + continue; + } + } + break; + } + if (idx > 0 && idx < xpointer.length()) { + return convertXPointerToXPath(xpointer.substring(idx)); + } + } + + return xpointer; + } + + /** + * Convert element() scheme content to XPath. + * Per XPointer element() scheme spec, child sequences use 1-based + * element positions (not node positions), so we use *[N] not node()[N]. + */ + private static String convertElementSchemeToXPath(final String content) { + if (content.startsWith("/")) { + // Child sequence: /1/2/3 -> /*[1]/*[2]/*[3] + final String[] parts = content.substring(1).split("/"); + final StringBuilder xpath = new StringBuilder(); + for (final String part : parts) { + xpath.append("/*[").append(part.trim()).append("]"); + } + return xpath.toString(); + } else if (content.contains("/")) { + // ID + child sequence: myid/2/3 -> id('myid')/*[2]/*[3] + final String[] parts = content.split("/"); + final StringBuilder xpath = new StringBuilder("id('").append(parts[0].trim()).append("')"); + for (int i = 1; i < parts.length; i++) { + xpath.append("/*[").append(parts[i].trim()).append("]"); + } + return xpath.toString(); + } else { + // Just an ID: myid -> id('myid') + return "id('" + content.trim() + "')"; + } + } + + /** + * Read a resource as text for parse="text" inclusion. + * + *

Per the XInclude spec, when parse="text", the resource is read as plain text + * and included as character data. XML special characters in the included text are + * preserved as-is (they will be escaped during serialization).

+ * + *

Architectural note: BaseX delegates XInclude entirely to Java's built-in + * SAXParserFactory.setXIncludeAware(true), which handles parse="text" at document + * import time. eXist's approach (serialization-time XIncludeFilter) is more powerful + * (works on stored documents) but requires implementing each XInclude feature + * explicitly. A complementary parse-time XInclude option (like BaseX) could be + * added as a future enhancement.

+ */ + private @Nullable String readResourceAsText(@Nullable final DocumentImpl doc, + @Nullable final org.exist.dom.memtree.DocumentImpl memtreeDoc, + @Nullable final XmldbURI docUri, + final String href, + @Nullable final String encoding) { + final java.nio.charset.Charset charset; + try { + charset = encoding != null ? java.nio.charset.Charset.forName(encoding) : UTF_8; + } catch (final java.nio.charset.UnsupportedCharsetException e) { + LOG.warn("Unsupported encoding '{}' for text inclusion, falling back to UTF-8", encoding); + return readResourceAsText(doc, memtreeDoc, docUri, href, null); + } + + // Case 1: Binary document in database — read raw bytes + if (doc != null && doc.getResourceType() == DocumentImpl.BINARY_FILE) { + try (final InputStream is = serializer.broker.getBinaryResource((BinaryDocument) doc)) { + return new String(is.readAllBytes(), charset); + } catch (final IOException e) { + LOG.warn("Error reading binary resource as text: {}", docUri, e); + return null; + } + } + + // Case 2: XML document in database — serialize to string (text representation) + if (doc != null) { + // For XML documents with parse="text", we serialize the document to its + // XML text representation and include that as character data. + // Per XInclude spec, the XML declaration is NOT part of the text inclusion. + try { + final Serializer tempSerializer = serializer.broker.borrowSerializer(); + try { + tempSerializer.setProperty(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); + tempSerializer.setProperty(javax.xml.transform.OutputKeys.INDENT, "no"); + return tempSerializer.serialize(doc); + } finally { + serializer.broker.returnSerializer(tempSerializer); + } + } catch (final Exception e) { + LOG.warn("Error serializing XML document as text: {}", docUri, e); + return null; + } + } + + // Case 3: In-memory document + if (memtreeDoc != null) { + try { + final Serializer tempSerializer = serializer.broker.borrowSerializer(); + try { + tempSerializer.setProperty(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes"); + tempSerializer.setProperty(javax.xml.transform.OutputKeys.INDENT, "no"); + return tempSerializer.serialize(memtreeDoc); + } finally { + serializer.broker.returnSerializer(tempSerializer); + } + } catch (final Exception e) { + LOG.warn("Error serializing in-memory document as text: {}", href, e); + return null; + } + } + + // Case 4: External URI — read from URL + try { + final URI externalUri = new URI(href); + final URLConnection con = externalUri.toURL().openConnection(); + try (final InputStream is = con.getInputStream()) { + return new String(is.readAllBytes(), charset); + } + } catch (final Exception e) { + LOG.warn("Error reading external resource as text: {}", href, e); + return null; + } + } + private Either parseExternal(final URI externalUri) throws ParserConfigurationException, SAXException { try { final URLConnection con = externalUri.toURL().openConnection(); diff --git a/exist-core/src/main/java/org/exist/util/CharSlice.java b/exist-core/src/main/java/org/exist/util/CharSlice.java index ff2ac84d8da..c6b578b7387 100644 --- a/exist-core/src/main/java/org/exist/util/CharSlice.java +++ b/exist-core/src/main/java/org/exist/util/CharSlice.java @@ -196,6 +196,19 @@ public void copyTo(final char[] destination, final int destOffset) { public void write(final Writer writer) throws java.io.IOException { writer.write(array, offset, len); } + + /** + * Write a sub-range of this slice to a writer using a single bulk + * {@link Writer#write(char[], int, int)} call. + * + * @param writer the writer + * @param start the start index within this slice (inclusive) + * @param length the number of characters to write + * @throws java.io.IOException if an error occurs whilst writing + */ + public void write(final Writer writer, final int start, final int length) throws java.io.IOException { + writer.write(array, offset + start, length); + } } // diff --git a/exist-core/src/main/java/org/exist/util/Collations.java b/exist-core/src/main/java/org/exist/util/Collations.java index 2d03138a291..64183619e30 100644 --- a/exist-core/src/main/java/org/exist/util/Collations.java +++ b/exist-core/src/main/java/org/exist/util/Collations.java @@ -75,6 +75,11 @@ public class Collations { */ public final static String HTML_ASCII_CASE_INSENSITIVE_COLLATION_URI = "http://www.w3.org/2005/xpath-functions/collation/html-ascii-case-insensitive"; + /** + * The Unicode Case-Insensitive Collation as defined by XPath F&O 4.0. + */ + public final static String UNICODE_CASE_INSENSITIVE_COLLATION_URI = "http://www.w3.org/2005/xpath-functions/collation/unicode-case-insensitive"; + /** * The XQTS ASCII Case-blind Collation as defined by the XQTS 3.1. */ @@ -90,6 +95,11 @@ public class Collations { */ private final static AtomicReference htmlAsciiCaseInsensitiveCollator = new AtomicReference<>(); + /** + * Lazy-initialized singleton Unicode Case Insensitive Collator + */ + private final static AtomicReference unicodeCaseInsensitiveCollator = new AtomicReference<>(); + /** * Lazy-initialized singleton XQTS Case Blind Collator */ @@ -276,6 +286,12 @@ public class Collations { } catch (final Exception e) { throw new XPathException(expression, "Unable to instantiate HTML ASCII Case Insensitive Collator: " + e.getMessage(), e); } + } else if(UNICODE_CASE_INSENSITIVE_COLLATION_URI.equals(uri)) { + try { + collator = getUnicodeCaseInsensitiveCollator(); + } catch (final Exception e) { + throw new XPathException(expression, "Unable to instantiate Unicode Case Insensitive Collator: " + e.getMessage(), e); + } } else if(XQTS_ASCII_CASE_BLIND_COLLATION_URI.equals(uri)) { try { collator = getXqtsAsciiCaseBlindCollator(); @@ -344,14 +360,43 @@ public static boolean equals(@Nullable final Collator collator, final String s1, * * @throws UnsupportedOperationException if ICU4J does not support collation */ - public static int compare(@Nullable final Collator collator, final String s1,final String s2) { + public static int compare(@Nullable final Collator collator, final String s1, final String s2) { if (collator == null) { - return s1 == null ? (s2 == null ? 0 : -1) : s1.compareTo(s2); + if (s1 == null) { + return s2 == null ? 0 : -1; + } + return compareByCodepoint(s1, s2); } else { return collator.compare(s1, s2); } } + /** + * Compares two strings by Unicode codepoints rather than UTF-16 code units. + * {@link String#compareTo(String)} compares {@code char} (UTF-16) values, which gives + * incorrect ordering for supplementary characters (U+10000 and above) that are encoded + * as surrogate pairs. + * + * @param a the first string to compare. + * @param b the second string to compare. + * @return a negative integer, zero, or a positive integer if {@code a} is less than, + * equal to, or greater than {@code b} by codepoint order. + */ + private static int compareByCodepoint(final String a, final String b) { + int i1 = 0, i2 = 0; + while (i1 < a.length() && i2 < b.length()) { + final int cp1 = a.codePointAt(i1); + final int cp2 = b.codePointAt(i2); + if (cp1 != cp2) { + return cp1 - cp2; + } + i1 += Character.charCount(cp1); + i2 += Character.charCount(cp2); + } + // Shorter string is less; equal length means equal + return (a.length() - i1) - (b.length() - i2); + } + /** * Determines if one string starts with another with regards to a Collation. * @@ -371,10 +416,16 @@ public static boolean startsWith(@Nullable final Collator collator, final String return true; } else if (s1.isEmpty()) { return false; - } else { + } else if (collator instanceof RuleBasedCollator rbc) { final SearchIterator searchIterator = - new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator); + new StringSearch(s2, new StringCharacterIterator(s1), rbc); return searchIterator.first() == 0; + } else { + // Fallback for non-RuleBasedCollator (e.g., HtmlAsciiCaseInsensitiveCollator) + if (s1.length() >= s2.length()) { + return collator.compare(s1.substring(0, s2.length()), s2) == 0; + } + return false; } } } @@ -398,9 +449,9 @@ public static boolean endsWith(@Nullable final Collator collator, final String s return true; } else if (s1.isEmpty()) { return false; - } else { + } else if (collator instanceof RuleBasedCollator rbc) { final SearchIterator searchIterator = - new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator); + new StringSearch(s2, new StringCharacterIterator(s1), rbc); int lastPos = SearchIterator.DONE; int lastLen = 0; for (int pos = searchIterator.first(); pos != SearchIterator.DONE; @@ -410,6 +461,12 @@ public static boolean endsWith(@Nullable final Collator collator, final String s } return lastPos > SearchIterator.DONE && lastPos + lastLen == s1.length(); + } else { + // Fallback for non-RuleBasedCollator + if (s1.length() >= s2.length()) { + return collator.compare(s1.substring(s1.length() - s2.length()), s2) == 0; + } + return false; } } } @@ -433,10 +490,18 @@ public static boolean contains(@Nullable final Collator collator, final String s return true; } else if (s1.isEmpty()) { return false; - } else { + } else if (collator instanceof RuleBasedCollator rbc) { final SearchIterator searchIterator = - new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator); + new StringSearch(s2, new StringCharacterIterator(s1), rbc); return searchIterator.first() >= 0; + } else { + // Fallback for non-RuleBasedCollator + for (int i = 0; i <= s1.length() - s2.length(); i++) { + if (collator.compare(s1.substring(i, i + s2.length()), s2) == 0) { + return true; + } + } + return false; } } } @@ -459,10 +524,18 @@ public static int indexOf(@Nullable final Collator collator, final String s1, fi return 0; } else if (s1.isEmpty()) { return -1; - } else { + } else if (collator instanceof RuleBasedCollator rbc) { final SearchIterator searchIterator = - new StringSearch(s2, new StringCharacterIterator(s1), (RuleBasedCollator) collator); + new StringSearch(s2, new StringCharacterIterator(s1), rbc); return searchIterator.first(); + } else { + // Fallback for non-RuleBasedCollator + for (int i = 0; i <= s1.length() - s2.length(); i++) { + if (collator.compare(s1.substring(i, i + s2.length()), s2) == 0) { + return i; + } + } + return -1; } } } @@ -809,21 +882,119 @@ private static Collator getSamiskCollator() throws Exception { return collator; } - private static Collator getHtmlAsciiCaseInsensitiveCollator() throws Exception { + private static Collator getHtmlAsciiCaseInsensitiveCollator() { Collator collator = htmlAsciiCaseInsensitiveCollator.get(); if (collator == null) { - collator = new RuleBasedCollator("&a=A &b=B &c=C &d=D &e=E &f=F &g=G &h=H " - + "&i=I &j=J &k=K &l=L &m=M &n=N &o=O &p=P &q=Q &r=R &s=S &t=T " - + "&u=U &v=V &w=W &x=X &y=Y &z=Z"); - collator.setStrength(Collator.PRIMARY); + // XQ4 html-ascii-case-insensitive: ASCII letters A-Z fold to a-z, + // all other characters compare by Unicode codepoint order. + // Cannot use RuleBasedCollator with PRIMARY strength because that + // makes ALL case/accent differences irrelevant, not just ASCII. htmlAsciiCaseInsensitiveCollator.compareAndSet(null, - collator.freeze()); + new HtmlAsciiCaseInsensitiveCollator()); collator = htmlAsciiCaseInsensitiveCollator.get(); } return collator; } + private static Collator getUnicodeCaseInsensitiveCollator() { + Collator collator = unicodeCaseInsensitiveCollator.get(); + if (collator == null) { + // Unicode case-insensitive: UCA with SECONDARY strength + // ignores case differences but respects accents and other distinctions + final Collator uca = Collator.getInstance(); + uca.setStrength(Collator.SECONDARY); + unicodeCaseInsensitiveCollator.compareAndSet(null, uca); + collator = unicodeCaseInsensitiveCollator.get(); + } + + return collator; + } + + /** + * Custom Collator for HTML ASCII case-insensitive comparison. + * Folds only ASCII letters A-Z to a-z, then compares by Unicode codepoint. + * Non-ASCII characters are compared by their codepoint value without folding. + */ + private static final class HtmlAsciiCaseInsensitiveCollator extends Collator { + + @Override + public int compare(final String source, final String target) { + int i1 = 0, i2 = 0; + while (i1 < source.length() && i2 < target.length()) { + int cp1 = source.codePointAt(i1); + int cp2 = target.codePointAt(i2); + // Fold ASCII uppercase to lowercase only + if (cp1 >= 'A' && cp1 <= 'Z') { + cp1 += 32; + } + if (cp2 >= 'A' && cp2 <= 'Z') { + cp2 += 32; + } + if (cp1 != cp2) { + return cp1 - cp2; + } + i1 += Character.charCount(cp1); + i2 += Character.charCount(cp2); + } + return (source.length() - i1) - (target.length() - i2); + } + + @Override + public CollationKey getCollationKey(final String source) { + throw new UnsupportedOperationException("CollationKey not supported for HTML ASCII case-insensitive collation"); + } + + @Override + public RawCollationKey getRawCollationKey(final String source, final RawCollationKey key) { + throw new UnsupportedOperationException("RawCollationKey not supported for HTML ASCII case-insensitive collation"); + } + + @Override + public int setVariableTop(final String varTop) { + return 0; + } + + @Override + public int getVariableTop() { + return 0; + } + + @Override + public void setVariableTop(final int varTop) { + } + + @Override + public VersionInfo getVersion() { + return VersionInfo.getInstance(1); + } + + @Override + public VersionInfo getUCAVersion() { + return VersionInfo.getInstance(1); + } + + @Override + public int hashCode() { + return HtmlAsciiCaseInsensitiveCollator.class.hashCode(); + } + + @Override + public Collator freeze() { + return this; + } + + @Override + public boolean isFrozen() { + return true; + } + + @Override + public Collator cloneAsThawed() { + return new HtmlAsciiCaseInsensitiveCollator(); + } + } + private static Collator getXqtsAsciiCaseBlindCollator() throws Exception { Collator collator = xqtsAsciiCaseBlindCollator.get(); if (collator == null) { diff --git a/exist-core/src/main/java/org/exist/util/XMLBackwardsCompatHandler.java b/exist-core/src/main/java/org/exist/util/XMLBackwardsCompatHandler.java new file mode 100644 index 00000000000..47e364d09cb --- /dev/null +++ b/exist-core/src/main/java/org/exist/util/XMLBackwardsCompatHandler.java @@ -0,0 +1,106 @@ +/* + * 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.util; + +import org.xml.sax.Attributes; +import org.xml.sax.ContentHandler; +import org.xml.sax.Locator; +import org.xml.sax.SAXException; + +/** + * A SAX ContentHandler wrapper that suppresses duplicate startDocument/endDocument calls. + * Saxon 12's LinkedTreeBuilder does not tolerate receiving startDocument more than once, + * which can happen when eXist's Serializer sends document events that overlap with + * explicitly-called startDocument/endDocument in the XSLT compilation pipeline. + */ +public class XMLBackwardsCompatHandler implements ContentHandler { + + private final ContentHandler delegate; + private boolean documentStarted = false; + + public XMLBackwardsCompatHandler(final ContentHandler delegate) { + this.delegate = delegate; + } + + @Override + public void startDocument() throws SAXException { + if (!documentStarted) { + documentStarted = true; + delegate.startDocument(); + } + } + + @Override + public void endDocument() throws SAXException { + // Suppress — the caller will call endDocument on the delegate directly + } + + @Override + public void setDocumentLocator(final Locator locator) { + delegate.setDocumentLocator(locator); + } + + @Override + public void startPrefixMapping(final String prefix, final String uri) throws SAXException { + // Saxon 12 rejects any namespace declaration involving the XML namespace URI + // (http://www.w3.org/XML/1998/namespace) — the xml prefix is always implicitly bound + if ("xml".equals(prefix) || javax.xml.XMLConstants.XML_NS_URI.equals(uri)) { + return; + } + delegate.startPrefixMapping(prefix, uri); + } + + @Override + public void endPrefixMapping(final String prefix) throws SAXException { + delegate.endPrefixMapping(prefix); + } + + @Override + public void startElement(final String uri, final String localName, final String qName, final Attributes atts) throws SAXException { + delegate.startElement(uri, localName, qName, atts); + } + + @Override + public void endElement(final String uri, final String localName, final String qName) throws SAXException { + delegate.endElement(uri, localName, qName); + } + + @Override + public void characters(final char[] ch, final int start, final int length) throws SAXException { + delegate.characters(ch, start, length); + } + + @Override + public void ignorableWhitespace(final char[] ch, final int start, final int length) throws SAXException { + delegate.ignorableWhitespace(ch, start, length); + } + + @Override + public void processingInstruction(final String target, final String data) throws SAXException { + delegate.processingInstruction(target, data); + } + + @Override + public void skippedEntity(final String name) throws SAXException { + delegate.skippedEntity(name); + } +} diff --git a/exist-core/src/main/java/org/exist/util/serializer/AbstractSerializer.java b/exist-core/src/main/java/org/exist/util/serializer/AbstractSerializer.java index 758ccee130a..a1b7c9890b3 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/AbstractSerializer.java +++ b/exist-core/src/main/java/org/exist/util/serializer/AbstractSerializer.java @@ -81,13 +81,27 @@ protected SerializerWriter getDefaultWriter() { public void setOutput(Writer writer, Properties properties) { outputProperties = Objects.requireNonNullElseGet(properties, () -> new Properties(defaultProperties)); final String method = outputProperties.getProperty(OutputKeys.METHOD, "xml"); - final String htmlVersionProp = outputProperties.getProperty(EXistOutputKeys.HTML_VERSION, "1.0"); - + // For html/xhtml methods, determine HTML version: + // 1. Use html-version if explicitly set + // 2. Otherwise use version (W3C spec: version controls HTML version for html method) + // 3. Default to 5.0 double htmlVersion; - try { - htmlVersion = Double.parseDouble(htmlVersionProp); - } catch (NumberFormatException e) { - htmlVersion = 1.0; + final String explicitHtmlVersion = outputProperties.getProperty(EXistOutputKeys.HTML_VERSION); + if (explicitHtmlVersion != null) { + try { + htmlVersion = Double.parseDouble(explicitHtmlVersion); + } catch (NumberFormatException e) { + htmlVersion = 5.0; + } + } else if (("html".equalsIgnoreCase(method) || "xhtml".equalsIgnoreCase(method)) + && outputProperties.getProperty(OutputKeys.VERSION) != null) { + try { + htmlVersion = Double.parseDouble(outputProperties.getProperty(OutputKeys.VERSION)); + } catch (NumberFormatException e) { + htmlVersion = 5.0; + } + } else { + htmlVersion = 5.0; } final SerializerWriter baseSerializerWriter = getBaseSerializerWriter(method, htmlVersion); diff --git a/exist-core/src/main/java/org/exist/util/serializer/AdaptiveWriter.java b/exist-core/src/main/java/org/exist/util/serializer/AdaptiveWriter.java index 22ab6dfca23..59fc8af3dfb 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/AdaptiveWriter.java +++ b/exist-core/src/main/java/org/exist/util/serializer/AdaptiveWriter.java @@ -152,6 +152,17 @@ public void write(final Sequence sequence, final String itemSep, final boolean e case Type.FUNCTION: writeFunctionItem((FunctionReference) item); break; + // XQuery 4.0 JNode types — serialize as their underlying JSON structure + case Type.JSON_NODE: + case Type.JSON_OBJECT: + case Type.JSON_ARRAY: + case Type.JSON_STRING: + case Type.JSON_NUMBER: + case Type.JSON_BOOLEAN: + case Type.JSON_NULL: + case Type.JSON_MEMBER: + writeJNode((org.exist.xquery.value.jnode.JNode) item); + break; default: writeAtomic(item.atomize()); break; @@ -190,10 +201,15 @@ private void writeAtomic(AtomicValue value) throws IOException, SAXException, XP } private void writeDouble(final DoubleValue item) throws SAXException { - final DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(Locale.US); - symbols.setExponentSeparator("e"); - final DecimalFormat df = new DecimalFormat("0.0##########################E0", symbols); - writeText(df.format(item.getDouble())); + final double d = item.getDouble(); + if (Double.isInfinite(d) || Double.isNaN(d)) { + writeText(item.getStringValue()); + } else { + final DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(Locale.US); + symbols.setExponentSeparator("e"); + final DecimalFormat df = new DecimalFormat("0.0##########################E0", symbols); + writeText(df.format(d)); + } } private void writeArray(final ArrayType array) throws XPathException, SAXException, TransformerException { @@ -215,9 +231,7 @@ private void writeArray(final ArrayType array) throws XPathException, SAXExcepti private void writeMap(final AbstractMapType map) throws SAXException, XPathException, TransformerException { try { - writer.write("map"); - addSpaceIfIndent(); - writer.write('{'); + writer.write("map{"); addIndent(); indent(); for (final Iterator> i = map.iterator(); i.hasNext(); ) { @@ -297,4 +311,23 @@ private void writeXML(final Item item) throws SAXException { broker.returnSerializer(serializer); } } + + /** + * Serialize a JNode in adaptive mode. + * Maps/arrays are serialized as their adaptive representation, + * leaf values as their string representation. + */ + private void writeJNode(final org.exist.xquery.value.jnode.JNode jnode) throws SAXException, XPathException, TransformerException { + final Sequence value = jnode.getValue(); + if (value instanceof AbstractMapType) { + writeMap((AbstractMapType) value); + } else if (value instanceof ArrayType) { + writeArray((ArrayType) value); + } else if (value == Sequence.EMPTY_SEQUENCE || value.isEmpty()) { + writeText("null"); + } else { + // Delegate to the normal write loop for the underlying value + write(value, ", ", false); + } + } } diff --git a/exist-core/src/main/java/org/exist/util/serializer/CSVSerializer.java b/exist-core/src/main/java/org/exist/util/serializer/CSVSerializer.java new file mode 100644 index 00000000000..98c599fc582 --- /dev/null +++ b/exist-core/src/main/java/org/exist/util/serializer/CSVSerializer.java @@ -0,0 +1,295 @@ +/* + * 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.util.serializer; + +import io.lacuna.bifurcan.IEntry; +import org.exist.storage.serializers.EXistOutputKeys; +import org.exist.xquery.XPathException; +import org.exist.xquery.functions.array.ArrayType; +import org.exist.xquery.functions.map.AbstractMapType; +import org.exist.xquery.value.*; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.io.Writer; +import java.util.*; + +/** + * Serializes XDM sequences as RFC 4180 CSV output. + * + * Accepts three input formats: + *
    + *
  • Array of arrays: each inner array is a row
  • + *
  • Sequence of maps: keys become header, values become rows
  • + *
  • XML table: <csv><record><field>...</field></record></csv>
  • + *
+ */ +public class CSVSerializer { + + private final String fieldDelimiter; + private final String rowDelimiter; + private final char quoteChar; + private final boolean alwaysQuote; + private final boolean includeHeader; + + public CSVSerializer(final Properties outputProperties) { + this.fieldDelimiter = outputProperties.getProperty(EXistOutputKeys.CSV_FIELD_DELIMITER, ","); + this.rowDelimiter = outputProperties.getProperty(EXistOutputKeys.CSV_ROW_DELIMITER, "\n"); + final String qc = outputProperties.getProperty(EXistOutputKeys.CSV_QUOTE_CHARACTER, "\""); + this.quoteChar = qc.isEmpty() ? '"' : qc.charAt(0); + this.alwaysQuote = !"no".equals(outputProperties.getProperty(EXistOutputKeys.CSV_QUOTES, "yes")); + this.includeHeader = "yes".equals(outputProperties.getProperty(EXistOutputKeys.CSV_HEADER, "no")); + } + + public void serialize(final Sequence sequence, final Writer writer) throws SAXException { + try { + if (sequence.isEmpty()) { + return; + } + + final Item first = sequence.itemAt(0); + + if (first.getType() == Type.ARRAY_ITEM) { + if (sequence.hasOne()) { + // Single array: treat as array-of-arrays + serializeArrayOfArrays((ArrayType) first, writer); + } else { + // Sequence of arrays: each array is a row + serializeSequenceOfArrays(sequence, writer); + } + } else if (first.getType() == Type.MAP_ITEM) { + serializeSequenceOfMaps(sequence, writer); + } else if (Type.subTypeOf(first.getType(), Type.NODE)) { + serializeXmlTable(sequence, writer); + } else { + // Single atomic or sequence of atomics — one row + serializeAtomicSequence(sequence, writer); + } + } catch (final IOException | XPathException e) { + throw new SAXException(e.getMessage(), e); + } + } + + private void serializeArrayOfArrays(final ArrayType outerArray, final Writer writer) throws IOException, XPathException { + for (int i = 0; i < outerArray.getSize(); i++) { + final Sequence member = outerArray.get(i); + if (member.getItemCount() == 1 && member.itemAt(0).getType() == Type.ARRAY_ITEM) { + writeRow((ArrayType) member.itemAt(0), writer); + } else { + writeSequenceRow(member, writer); + } + writer.write(rowDelimiter); + } + } + + private void serializeSequenceOfArrays(final Sequence sequence, final Writer writer) throws IOException, XPathException { + for (final SequenceIterator i = sequence.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (item.getType() == Type.ARRAY_ITEM) { + writeRow((ArrayType) item, writer); + } else { + writer.write(quoteField(item.getStringValue())); + } + writer.write(rowDelimiter); + } + } + + private void serializeSequenceOfMaps(final Sequence sequence, final Writer writer) throws IOException, XPathException { + // Collect all keys from first map for header + final AbstractMapType firstMap = (AbstractMapType) sequence.itemAt(0); + final List keys = new ArrayList<>(); + for (final IEntry entry : firstMap) { + keys.add(entry.key().getStringValue()); + } + Collections.sort(keys); + + // Write header + if (includeHeader) { + writeFields(keys, writer); + writer.write(rowDelimiter); + } + + // Write rows + for (final SequenceIterator i = sequence.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (item.getType() == Type.MAP_ITEM) { + final AbstractMapType map = (AbstractMapType) item; + boolean first = true; + for (final String key : keys) { + if (!first) { + writer.write(fieldDelimiter); + } + final Sequence value = map.get(new StringValue(key)); + writer.write(quoteField(value.isEmpty() ? "" : value.getStringValue())); + first = false; + } + } + writer.write(rowDelimiter); + } + } + + private void serializeXmlTable(final Sequence sequence, final Writer writer) throws IOException, XPathException { + // Walk XML table: value + // or
value
+ for (final SequenceIterator i = sequence.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (Type.subTypeOf(item.getType(), Type.ELEMENT)) { + final org.w3c.dom.Element elem = (org.w3c.dom.Element) ((NodeValue) item).getNode(); + serializeXmlElement(elem, writer); + } + } + } + + private void serializeXmlElement(final org.w3c.dom.Element element, final Writer writer) throws IOException { + final org.w3c.dom.NodeList children = element.getChildNodes(); + boolean hasChildElements = false; + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) { + hasChildElements = true; + break; + } + } + + if (!hasChildElements) { + // Leaf element — output as a field value + writer.write(quoteField(element.getTextContent())); + return; + } + + // Check if children are "record" elements (containing field elements) + // or direct field elements + boolean firstRecord = true; + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) { + final org.w3c.dom.Element child = (org.w3c.dom.Element) children.item(i); + final org.w3c.dom.NodeList grandchildren = child.getChildNodes(); + boolean hasGrandchildElements = false; + for (int j = 0; j < grandchildren.getLength(); j++) { + if (grandchildren.item(j).getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) { + hasGrandchildElements = true; + break; + } + } + + if (hasGrandchildElements) { + // This is a record element — its children are fields + if (!firstRecord) { + // row delimiter already written + } + boolean firstField = true; + for (int j = 0; j < grandchildren.getLength(); j++) { + if (grandchildren.item(j).getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) { + if (!firstField) { + writer.write(fieldDelimiter); + } + writer.write(quoteField(grandchildren.item(j).getTextContent())); + firstField = false; + } + } + writer.write(rowDelimiter); + firstRecord = false; + } else { + // Direct field element — accumulate as part of a single row + if (!firstRecord) { + writer.write(fieldDelimiter); + } + writer.write(quoteField(child.getTextContent())); + firstRecord = false; + } + } + } + } + + private void serializeAtomicSequence(final Sequence sequence, final Writer writer) throws IOException, XPathException { + boolean first = true; + for (final SequenceIterator i = sequence.iterate(); i.hasNext(); ) { + if (!first) { + writer.write(fieldDelimiter); + } + writer.write(quoteField(i.nextItem().getStringValue())); + first = false; + } + writer.write(rowDelimiter); + } + + private void writeRow(final ArrayType array, final Writer writer) throws IOException, XPathException { + for (int i = 0; i < array.getSize(); i++) { + if (i > 0) { + writer.write(fieldDelimiter); + } + final Sequence member = array.get(i); + writer.write(quoteField(member.isEmpty() ? "" : member.getStringValue())); + } + } + + private void writeSequenceRow(final Sequence sequence, final Writer writer) throws IOException, XPathException { + boolean first = true; + for (final SequenceIterator i = sequence.iterate(); i.hasNext(); ) { + if (!first) { + writer.write(fieldDelimiter); + } + writer.write(quoteField(i.nextItem().getStringValue())); + first = false; + } + } + + private void writeFields(final List fields, final Writer writer) throws IOException { + boolean first = true; + for (final String field : fields) { + if (!first) { + writer.write(fieldDelimiter); + } + writer.write(quoteField(field)); + first = false; + } + } + + /** + * Quote a field value per RFC 4180. + * If alwaysQuote is true, all fields are quoted. + * If false, only fields containing the delimiter, quote char, or newline are quoted. + * Quote characters within the value are escaped by doubling. + */ + private String quoteField(final String value) { + final boolean needsQuoting = alwaysQuote + || value.contains(fieldDelimiter) + || value.indexOf(quoteChar) >= 0 + || value.contains("\n") + || value.contains("\r"); + + if (!needsQuoting) { + return value; + } + + final StringBuilder sb = new StringBuilder(value.length() + 2); + sb.append(quoteChar); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + if (c == quoteChar) { + sb.append(quoteChar); // escape by doubling + } + sb.append(c); + } + sb.append(quoteChar); + return sb.toString(); + } +} diff --git a/exist-core/src/main/java/org/exist/util/serializer/HTML5Writer.java b/exist-core/src/main/java/org/exist/util/serializer/HTML5Writer.java index 1dffc3029b7..fa0a368bb9a 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/HTML5Writer.java +++ b/exist-core/src/main/java/org/exist/util/serializer/HTML5Writer.java @@ -118,6 +118,13 @@ public class HTML5Writer extends XHTML5Writer { BOOLEAN_ATTRIBUTE_NAMES.add("willValidate"); } + private static final ObjectSet BOOLEAN_ATTRIBUTE_NAMES_LOWER = new ObjectOpenHashSet<>(BOOLEAN_ATTRIBUTE_NAMES.size()); + static { + for (final String n : BOOLEAN_ATTRIBUTE_NAMES) { + BOOLEAN_ATTRIBUTE_NAMES_LOWER.add(n.toLowerCase(java.util.Locale.ROOT)); + } + } + private static final ObjectSet EMPTY_TAGS = new ObjectOpenHashSet<>(31); static { EMPTY_TAGS.add("area"); @@ -156,8 +163,15 @@ public void endElement(QName qname) throws TransformerException { if (!isEmptyTag(qname.getLocalPart())) { super.endElement(qname); } else { + // HTML5 omits the close tag for void elements; we still need to + // honor the meta-in-head dedup that XHTMLWriter sets up at startElement + // time. Capture the buffered-meta flag before closeStartTag flips state. + final boolean wasBufferedMeta = isBufferedMeta(qname.getLocalPart()); closeStartTag(true); endIndent(qname.getNamespaceURI(), qname.getLocalPart()); + if (wasBufferedMeta) { + endMetaBuffer(); + } } } @@ -166,24 +180,33 @@ public void endElement(String namespaceURI, String localName, String qname) thro if (!isEmptyTag(localName)) { super.endElement(namespaceURI, localName, qname); } else { + final boolean wasBufferedMeta = isBufferedMeta(localName); closeStartTag(true); endIndent(namespaceURI, localName); + if (wasBufferedMeta) { + endMetaBuffer(); + } } } @Override public void attribute(String qname, CharSequence value) throws TransformerException { + // Strip prefix for the meta-dedup redundancy check + final int colon = qname.indexOf(':'); + final String localName = colon < 0 ? qname : qname.substring(colon + 1); + noteMetaAttribute(localName, value); + final CharSequence effectiveValue = maybeEscapeUriHtml5(localName, value); try { if(!tagIsOpen) { - characters(value); + characters(effectiveValue); return; } final Writer writer = getWriter(); writer.write(' '); writer.write(qname); - if (!(BOOLEAN_ATTRIBUTE_NAMES.contains(qname) && qname.contentEquals(value))) { + if (!isBooleanAttributeMatch(qname, effectiveValue)) { writer.write("=\""); - writeChars(value, true); + writeChars(effectiveValue, true); writer.write('"'); } } catch(final IOException ioe) { @@ -193,9 +216,12 @@ public void attribute(String qname, CharSequence value) throws TransformerExcept @Override public void attribute(QName qname, CharSequence value) throws TransformerException { + noteMetaAttribute(qname.getLocalPart(), value); + final String localPart = qname.getLocalPart(); + final CharSequence effectiveValue = maybeEscapeUriHtml5(localPart, value); try { if(!tagIsOpen) { - characters(value); + characters(effectiveValue); return; // throw new TransformerException("Found an attribute outside an // element"); @@ -206,11 +232,10 @@ public void attribute(QName qname, CharSequence value) throws TransformerExcepti writer.write(qname.getPrefix()); writer.write(':'); } - final String localPart = qname.getLocalPart(); writer.write(localPart); - if (!(BOOLEAN_ATTRIBUTE_NAMES.contains(localPart) && localPart.contentEquals(value))) { + if (!isBooleanAttributeMatch(localPart, effectiveValue)) { writer.write("=\""); - writeChars(value, true); + writeChars(effectiveValue, true); writer.write('"'); } } catch(final IOException ioe) { @@ -218,26 +243,76 @@ public void attribute(QName qname, CharSequence value) throws TransformerExcepti } } + /** + * URI-attribute escaping for the HTML5 writer. Mirrors + * {@link XHTMLWriter#shouldEscapeUriAttribute(String, String)} but unwraps + * the prefixed form of {@link #currentTag} so the (element, attribute) + * lookup uses local names only. + */ + private CharSequence maybeEscapeUriHtml5(final String attrLocal, final CharSequence value) { + if (currentTag == null) { + return value; + } + final String elementLocal = currentTag.contains(":") + ? currentTag.substring(currentTag.indexOf(':') + 1) + : currentTag; + if (!shouldEscapeUriAttribute(elementLocal, attrLocal)) { + return value; + } + return escapeUriAttribute(value); + } + + /** + * HTML5 boolean attribute minimization: emit just the bare name when the + * value is empty or matches the attribute name case-insensitively + * (per W3C XSLT/XQuery Serialization 3.1, section 7.2.2). + */ + private static boolean isBooleanAttributeMatch(final String name, final CharSequence value) { + if (!BOOLEAN_ATTRIBUTE_NAMES_LOWER.contains(name.toLowerCase(java.util.Locale.ROOT))) { + return false; + } + if (value == null || value.length() == 0) { + return true; + } + return name.equalsIgnoreCase(value.toString()); + } + @Override public void namespace(String prefix, String nsURI) throws TransformerException { - // no namespaces allowed in HTML5 + // HTML5 elements never carry an explicit xmlns since the parser puts + // them in the HTML namespace implicitly. Foreign content (anything + // outside the XHTML namespace, e.g. SVG, MathML, custom XML) keeps + // its namespace declarations so the receiver can re-parse it as XML. + if (nsURI == null || nsURI.isEmpty()) { + return; + } + if (org.exist.Namespaces.XHTML_NS.equals(nsURI)) { + return; + } + super.namespace(prefix, nsURI); } @Override protected void closeStartTag(boolean isEmpty) throws TransformerException { try { if (tagIsOpen) { + final Writer w = getWriter(); if (isEmpty) { if (isEmptyTag(currentTag)) { - getWriter().write(">"); + w.write('>'); + } else if (isForeignContent()) { + // Foreign content (SVG, MathML, custom XML namespace) + // embedded in HTML5 is serialized with XML self-close + // syntax so the receiver can re-parse it as XML. + w.write("/>"); } else { - getWriter().write('>'); - getWriter().write("'); + // Coalesce ">", "" into 2 writer calls instead of 4 + w.write(">'); } } else { - getWriter().write('>'); + w.write('>'); } tagIsOpen = false; } @@ -246,6 +321,39 @@ protected void closeStartTag(boolean isEmpty) throws TransformerException { } } + /** + * The current element is "foreign content" when its namespace is neither + * the XHTML namespace nor the empty (no-namespace) HTML namespace; that + * is the trigger for XML-style self-closing per HTML5's foreign-content + * serialization rule. + */ + private boolean isForeignContent() { + final String ns = currentElementNamespaceURI(); + return ns != null && !ns.isEmpty() && !org.exist.Namespaces.XHTML_NS.equals(ns); + } + + @Override + public void processingInstruction(final String target, final String data) throws TransformerException { + // QT4 PR2372: HTML5 has no PI syntax, so the serializer renders + // processing instructions as comments of the form ``, + // matching the HTML5 parser's coercion of `` content. + try { + if (tagIsOpen) { + closeStartTag(false); + } + final Writer writer = getWriter(); + writer.write(""); + } catch (final IOException e) { + throw new TransformerException(e.getMessage(), e); + } + } + @Override protected boolean needsEscape(char ch) { if (RAW_TEXT_ELEMENTS.contains(currentTag)) { @@ -253,4 +361,31 @@ protected boolean needsEscape(char ch) { } return super.needsEscape(ch); } + + @Override + protected boolean needsEscape(final char ch, final boolean inAttribute) { + // In raw text elements (script, style), suppress escaping for TEXT content only. + // Attribute values must always be escaped, even on raw text elements. + if (!inAttribute && RAW_TEXT_ELEMENTS.contains(currentTag)) { + return false; + } + // For attributes, always return true (bypass the 1-arg override + // which returns false for all script/style content) + if (inAttribute) { + return true; + } + return super.needsEscape(ch, inAttribute); + } + + @Override + protected boolean needsEscaping(final boolean inAttribute) { + // Mirror the per-char rule above: TEXT content inside script/style is + // raw text and never needs escaping. Lets writeChars() bulk-stream + // the entire block in one Writer.write() call. + if (!inAttribute && RAW_TEXT_ELEMENTS.contains(currentTag)) { + return false; + } + return true; + } + } diff --git a/exist-core/src/main/java/org/exist/util/serializer/IndentingXMLWriter.java b/exist-core/src/main/java/org/exist/util/serializer/IndentingXMLWriter.java index c336d8b2943..99df54c3e19 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/IndentingXMLWriter.java +++ b/exist-core/src/main/java/org/exist/util/serializer/IndentingXMLWriter.java @@ -25,7 +25,9 @@ import java.io.Writer; import java.util.ArrayDeque; import java.util.Deque; +import java.util.HashSet; import java.util.Properties; +import java.util.Set; import javax.xml.transform.OutputKeys; import javax.xml.transform.TransformerException; @@ -48,6 +50,8 @@ public class IndentingXMLWriter extends XMLWriter { private boolean sameline = false; private boolean whitespacePreserve = false; private final Deque whitespacePreserveStack = new ArrayDeque<>(); + private Set suppressIndentation = null; + private int suppressIndentDepth = 0; public IndentingXMLWriter() { super(); @@ -75,6 +79,9 @@ public void startElement(final String namespaceURI, final String localName, fina indent(); } super.startElement(namespaceURI, localName, qname); + if (isSuppressIndentation(localName)) { + suppressIndentDepth++; + } addIndent(); afterTag = true; sameline = true; @@ -86,6 +93,9 @@ public void startElement(final QName qname) throws TransformerException { indent(); } super.startElement(qname); + if (isSuppressIndentation(qname.getLocalPart())) { + suppressIndentDepth++; + } addIndent(); afterTag = true; sameline = true; @@ -95,6 +105,9 @@ public void startElement(final QName qname) throws TransformerException { public void endElement(final String namespaceURI, final String localName, final String qname) throws TransformerException { endIndent(namespaceURI, localName); super.endElement(namespaceURI, localName, qname); + if (isSuppressIndentation(localName) && suppressIndentDepth > 0) { + suppressIndentDepth--; + } popWhitespacePreserve(); // apply ancestor's xml:space value _after_ end element sameline = isInlineTag(namespaceURI, localName); afterTag = true; @@ -104,6 +117,9 @@ public void endElement(final String namespaceURI, final String localName, final public void endElement(final QName qname) throws TransformerException { endIndent(qname.getNamespaceURI(), qname.getLocalPart()); super.endElement(qname); + if (isSuppressIndentation(qname.getLocalPart()) && suppressIndentDepth > 0) { + suppressIndentDepth--; + } popWhitespacePreserve(); // apply ancestor's xml:space value _after_ end element sameline = isInlineTag(qname.getNamespaceURI(), qname.getLocalPart()); afterTag = true; @@ -164,7 +180,29 @@ public void setOutputProperties(final Properties properties) { } catch (final NumberFormatException e) { LOG.warn("Invalid indentation value: '{}'", option); } - indent = "yes".equals(outputProperties.getProperty(OutputKeys.INDENT, "no")); + final String indentValue = outputProperties.getProperty(OutputKeys.INDENT, "no").trim(); + indent = "yes".equals(indentValue) || "true".equals(indentValue) || "1".equals(indentValue); + final String suppressProp = outputProperties.getProperty("suppress-indentation"); + if (suppressProp != null && !suppressProp.isEmpty()) { + suppressIndentation = new HashSet<>(); + for (final String name : suppressProp.split("\\s+")) { + if (!name.isEmpty()) { + // Handle URI-qualified names: Q{ns}local or {ns}local → extract local part + if (name.startsWith("Q{") || name.startsWith("{")) { + final int closeBrace = name.indexOf('}'); + if (closeBrace > 0 && closeBrace < name.length() - 1) { + suppressIndentation.add(name.substring(closeBrace + 1)); + } else { + suppressIndentation.add(name); + } + } else { + suppressIndentation.add(name); + } + } + } + } else { + suppressIndentation = null; + } } @Override @@ -220,8 +258,12 @@ protected void addSpaceIfIndent() throws IOException { writer.write(' '); } + private boolean isSuppressIndentation(final String localName) { + return suppressIndentation != null && suppressIndentation.contains(localName); + } + protected void indent() throws TransformerException { - if (!indent || whitespacePreserve) { + if (!indent || whitespacePreserve || suppressIndentDepth > 0) { return; } final int spaces = indentAmount * level; diff --git a/exist-core/src/main/java/org/exist/util/serializer/TEXTWriter.java b/exist-core/src/main/java/org/exist/util/serializer/TEXTWriter.java index 85c5c4cf5a6..5ea206a25da 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/TEXTWriter.java +++ b/exist-core/src/main/java/org/exist/util/serializer/TEXTWriter.java @@ -206,16 +206,10 @@ protected void writeDoctype(final String rootElement) throws TransformerExceptio @Override protected void writeChars(final CharSequence s, final boolean inAttribute) throws IOException { - final int len = s.length(); - writeCharSeq(s, 0, len); + writeCharSeq(s, 0, s.length()); } - - private void writeCharSeq(final CharSequence ch, final int start, final int end) throws IOException { - for (int i = start; i < end; i++) { - writer.write(ch.charAt(i)); - } - } - + + @Override protected void writeCharacterReference(final char charval) throws IOException { int o = 0; diff --git a/exist-core/src/main/java/org/exist/util/serializer/XHTML5Writer.java b/exist-core/src/main/java/org/exist/util/serializer/XHTML5Writer.java index e89e7119d19..bc4990eb5eb 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/XHTML5Writer.java +++ b/exist-core/src/main/java/org/exist/util/serializer/XHTML5Writer.java @@ -22,7 +22,6 @@ package org.exist.util.serializer; import java.io.Writer; -import javax.xml.transform.TransformerException; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; import it.unimi.dsi.fastutil.objects.ObjectSet; @@ -121,14 +120,4 @@ public XHTML5Writer(ObjectSet emptyTags, ObjectSet inlineTags) { public XHTML5Writer(Writer writer, ObjectSet emptyTags, ObjectSet inlineTags) { super(writer, emptyTags, inlineTags); } - - @Override - protected void writeDoctype(String rootElement) throws TransformerException { - if (doctypeWritten) { - return; - } - - documentType("html", null, null); - doctypeWritten = true; - } } diff --git a/exist-core/src/main/java/org/exist/util/serializer/XHTMLWriter.java b/exist-core/src/main/java/org/exist/util/serializer/XHTMLWriter.java index b0006f7f51c..216ef6f59b4 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/XHTMLWriter.java +++ b/exist-core/src/main/java/org/exist/util/serializer/XHTMLWriter.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.io.Writer; +import javax.xml.transform.OutputKeys; import javax.xml.transform.TransformerException; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; @@ -36,12 +37,176 @@ */ public class XHTMLWriter extends IndentingXMLWriter { + /** + * HTML boolean attributes per HTML 4.01 and HTML5 spec. + * When method="html" and the attribute value equals the attribute name + * (case-insensitive), the attribute is minimized to just the name. + */ + protected static final ObjectSet BOOLEAN_ATTRIBUTES = new ObjectOpenHashSet<>(31); + static { + BOOLEAN_ATTRIBUTES.add("checked"); + BOOLEAN_ATTRIBUTES.add("compact"); + BOOLEAN_ATTRIBUTES.add("declare"); + BOOLEAN_ATTRIBUTES.add("defer"); + BOOLEAN_ATTRIBUTES.add("disabled"); + BOOLEAN_ATTRIBUTES.add("ismap"); + BOOLEAN_ATTRIBUTES.add("multiple"); + BOOLEAN_ATTRIBUTES.add("nohref"); + BOOLEAN_ATTRIBUTES.add("noresize"); + BOOLEAN_ATTRIBUTES.add("noshade"); + BOOLEAN_ATTRIBUTES.add("nowrap"); + BOOLEAN_ATTRIBUTES.add("readonly"); + BOOLEAN_ATTRIBUTES.add("selected"); + } + + /** + * URI-valued attributes that must be %-escaped when escape-uri-attributes=yes + * (default for HTML/XHTML output methods, per W3C XSLT and XQuery + * Serialization 3.1 § 7.2.5). Keys are element local name + "/" + attribute + * local name, both lowercase. The synthetic key "*/href" matches any + * element bearing an href attribute (covers both a/@href and area/@href etc. + * in a single check while still letting non-URI attributes through). + */ + private static final ObjectSet URI_VALUED_ATTRIBUTES = new ObjectOpenHashSet<>(48); + static { + URI_VALUED_ATTRIBUTES.add("a/href"); + URI_VALUED_ATTRIBUTES.add("a/name"); + URI_VALUED_ATTRIBUTES.add("applet/codebase"); + URI_VALUED_ATTRIBUTES.add("area/href"); + URI_VALUED_ATTRIBUTES.add("base/href"); + URI_VALUED_ATTRIBUTES.add("blockquote/cite"); + URI_VALUED_ATTRIBUTES.add("body/background"); + URI_VALUED_ATTRIBUTES.add("button/formaction"); + URI_VALUED_ATTRIBUTES.add("del/cite"); + URI_VALUED_ATTRIBUTES.add("form/action"); + URI_VALUED_ATTRIBUTES.add("frame/longdesc"); + URI_VALUED_ATTRIBUTES.add("frame/src"); + URI_VALUED_ATTRIBUTES.add("head/profile"); + URI_VALUED_ATTRIBUTES.add("html/manifest"); + URI_VALUED_ATTRIBUTES.add("iframe/longdesc"); + URI_VALUED_ATTRIBUTES.add("iframe/src"); + URI_VALUED_ATTRIBUTES.add("img/longdesc"); + URI_VALUED_ATTRIBUTES.add("img/src"); + URI_VALUED_ATTRIBUTES.add("img/usemap"); + URI_VALUED_ATTRIBUTES.add("input/formaction"); + URI_VALUED_ATTRIBUTES.add("input/src"); + URI_VALUED_ATTRIBUTES.add("input/usemap"); + URI_VALUED_ATTRIBUTES.add("ins/cite"); + URI_VALUED_ATTRIBUTES.add("link/href"); + URI_VALUED_ATTRIBUTES.add("object/archive"); + URI_VALUED_ATTRIBUTES.add("object/classid"); + URI_VALUED_ATTRIBUTES.add("object/codebase"); + URI_VALUED_ATTRIBUTES.add("object/data"); + URI_VALUED_ATTRIBUTES.add("object/usemap"); + URI_VALUED_ATTRIBUTES.add("q/cite"); + URI_VALUED_ATTRIBUTES.add("script/src"); + URI_VALUED_ATTRIBUTES.add("source/src"); + URI_VALUED_ATTRIBUTES.add("track/src"); + URI_VALUED_ATTRIBUTES.add("video/poster"); + URI_VALUED_ATTRIBUTES.add("video/src"); + URI_VALUED_ATTRIBUTES.add("audio/src"); + } + + /** + * Returns true when the {@code escape-uri-attributes} serialization + * parameter is enabled (default {@code yes} for HTML/XHTML methods) + * and the (element, attribute) pair names a URI-valued attribute that + * must have non-ASCII characters %-encoded as UTF-8. + */ + protected boolean shouldEscapeUriAttribute(final String elementLocal, final String attrLocal) { + if (elementLocal == null || attrLocal == null) { + return false; + } + if (outputProperties != null + && "no".equals(outputProperties.getProperty("escape-uri-attributes", "yes"))) { + return false; + } + return URI_VALUED_ATTRIBUTES.contains( + elementLocal.toLowerCase(java.util.Locale.ROOT) + + '/' + attrLocal.toLowerCase(java.util.Locale.ROOT)); + } + + /** + * Apply XSLT/XQuery Serialization 3.1 § 7.2.5 URI %-escaping: each + * character outside the URI character set (defined in RFC 3986 plus + * a handful of additions) is encoded to UTF-8 bytes and each byte + * emitted as {@code %XX}. Characters already in the URI character set + * (including {@code %} itself, to keep already-escaped sequences intact) + * are written verbatim. + */ + protected static CharSequence escapeUriAttribute(final CharSequence value) { + if (value == null || value.length() == 0) { + return value; + } + final String src = value.toString(); + StringBuilder sb = null; + for (int i = 0; i < src.length(); i++) { + final char c = src.charAt(i); + if (isUriChar(c)) { + if (sb != null) { + sb.append(c); + } + continue; + } + if (sb == null) { + sb = new StringBuilder(src.length() + 16); + sb.append(src, 0, i); + } + // Collect the codepoint (handling surrogate pairs) then UTF-8 encode. + int cp = c; + if (Character.isHighSurrogate(c) && i + 1 < src.length() + && Character.isLowSurrogate(src.charAt(i + 1))) { + cp = Character.toCodePoint(c, src.charAt(i + 1)); + i++; + } + appendUtf8Percent(sb, cp); + } + return sb == null ? value : sb; + } + + private static boolean isUriChar(final char c) { + // The escape-uri-attributes contract per W3C Serialization 3.1 § 7.2.5 + // is to encode disallowed-in-URI characters. In practice (matching + // Saxon and the QT4 conformance tests) only non-ASCII codepoints are + // %-escaped: ASCII-range characters — including ' ' which an authoring + // tool may leave literal in href values — pass through unchanged so + // existing valid escapes are not double-encoded. + return c < 0x80; + } + + private static void appendUtf8Percent(final StringBuilder sb, final int codepoint) { + if (codepoint < 0x80) { + appendHexByte(sb, codepoint); + } else if (codepoint < 0x800) { + appendHexByte(sb, 0xC0 | (codepoint >> 6)); + appendHexByte(sb, 0x80 | (codepoint & 0x3F)); + } else if (codepoint < 0x10000) { + appendHexByte(sb, 0xE0 | (codepoint >> 12)); + appendHexByte(sb, 0x80 | ((codepoint >> 6) & 0x3F)); + appendHexByte(sb, 0x80 | (codepoint & 0x3F)); + } else { + appendHexByte(sb, 0xF0 | (codepoint >> 18)); + appendHexByte(sb, 0x80 | ((codepoint >> 12) & 0x3F)); + appendHexByte(sb, 0x80 | ((codepoint >> 6) & 0x3F)); + appendHexByte(sb, 0x80 | (codepoint & 0x3F)); + } + } + + private static final char[] HEX = "0123456789ABCDEF".toCharArray(); + + private static void appendHexByte(final StringBuilder sb, final int b) { + sb.append('%'); + sb.append(HEX[(b >> 4) & 0xF]); + sb.append(HEX[b & 0xF]); + } + protected static final ObjectSet EMPTY_TAGS = new ObjectOpenHashSet<>(31); static { EMPTY_TAGS.add("area"); EMPTY_TAGS.add("base"); EMPTY_TAGS.add("br"); EMPTY_TAGS.add("col"); + EMPTY_TAGS.add("embed"); EMPTY_TAGS.add("hr"); EMPTY_TAGS.add("img"); EMPTY_TAGS.add("input"); @@ -88,6 +253,18 @@ public class XHTMLWriter extends IndentingXMLWriter { } protected String currentTag; + protected boolean inHead = false; + protected boolean contentTypeMetaWritten = false; + + // Meta-tag dedup state: when a `` element is encountered inside + // `` AFTER the auto-generated content-type meta has been emitted, + // its bytes are diverted to {@link #metaScratch}. If, while buffering, + // we observe a {@code charset} or {@code http-equiv="Content-Type"} + // attribute, the buffered meta is dropped (the auto-meta replaces it); + // otherwise the buffer is flushed verbatim at endElement time. + private Writer metaSuspendedWriter = null; + private java.io.StringWriter metaScratch = null; + private boolean metaIsContentTypeOrCharset = false; protected final ObjectSet emptyTags; protected final ObjectSet inlineTags; @@ -120,78 +297,191 @@ public XHTMLWriter(final Writer writer, ObjectSet emptyTags, ObjectSet 0 && namespaceURI != null && namespaceURI.equals(Namespaces.XHTML_NS)) { - haveCollapsedXhtmlPrefix = true; - return qname.substring(pos+1); - + if (pos > 0 && namespaceURI != null) { + if (namespaceURI.equals(Namespaces.XHTML_NS)) { + haveCollapsedXhtmlPrefix = true; + return qname.substring(pos + 1); + } + // XHTML5: normalize SVG and MathML prefixes + if (isHtml5Version() && (namespaceURI.equals(SVG_NS) || namespaceURI.equals(MATHML_NS))) { + collapsedForeignNs = namespaceURI; + return qname.substring(pos + 1); + } } - return qname; } @Override public void namespace(final String prefix, final String nsURI) throws TransformerException { - if(haveCollapsedXhtmlPrefix && prefix != null && !prefix.isEmpty() && nsURI.equals(Namespaces.XHTML_NS)) { - return; //dont output the xmlns:prefix for the collapsed nodes prefix + if (haveCollapsedXhtmlPrefix && prefix != null && !prefix.isEmpty() && nsURI.equals(Namespaces.XHTML_NS)) { + return; // don't output the xmlns:prefix for the collapsed node's prefix + } + // When a foreign namespace prefix was collapsed, replace the prefixed + // declaration with a default namespace declaration + if (collapsedForeignNs != null && prefix != null && !prefix.isEmpty() + && nsURI.equals(collapsedForeignNs)) { + super.namespace("", nsURI); // emit xmlns="..." instead of xmlns:prefix="..." + return; } - super.namespace(prefix, nsURI); } @@ -200,17 +490,33 @@ public void namespace(final String prefix, final String nsURI) throws Transforme protected void closeStartTag(final boolean isEmpty) throws TransformerException { try { if (tagIsOpen) { + // Flush canonical buffers (sorted namespaces + attributes) if active + if (isCanonical()) { + flushCanonicalBuffersXhtml(); + } + final Writer w = getWriter(); if (isEmpty) { - if (isEmptyTag(currentTag)) { - getWriter().write(" />"); + if (isCanonical()) { + // Canonical: always expand empty elements — coalesce 4 writes into 2 + w.write(">'); + } else if (isEmptyTag(currentTag)) { + // For method="html", use HTML-style void tags (
) + // For method="xhtml", use XHTML-style (
) + if (isHtmlMethod()) { + w.write('>'); + } else { + w.write(" />"); + } } else { - getWriter().write('>'); - getWriter().write("'); + // Coalesce ">", "" into 2 writer calls instead of 4 + w.write(">'); } } else { - getWriter().write('>'); + w.write('>'); } tagIsOpen = false; } @@ -218,10 +524,294 @@ protected void closeStartTag(final boolean isEmpty) throws TransformerException throw new TransformerException(ioe.getMessage(), ioe); } } + + /** + * Returns true if the output method is "html" (not "xhtml"). + * HTML uses void element syntax (
) while XHTML uses self-closing (
). + */ + protected boolean isHtmlMethod() { + if (outputProperties != null) { + final String method = outputProperties.getProperty(javax.xml.transform.OutputKeys.METHOD); + return "html".equalsIgnoreCase(method); + } + return false; + } + + /** + * Returns true if the HTML version is 5.0 or higher. + * Checks html-version first, then falls back to version (per W3C spec for html method). + */ + protected boolean isHtml5Version() { + if (outputProperties == null) { + return true; // default to HTML5 + } + final String htmlVersion = outputProperties.getProperty(org.exist.storage.serializers.EXistOutputKeys.HTML_VERSION); + if (htmlVersion != null) { + try { + return Double.parseDouble(htmlVersion) >= 5.0; + } catch (final NumberFormatException e) { + // fall through + } + } + final String version = outputProperties.getProperty(OutputKeys.VERSION); + if (version != null) { + try { + return Double.parseDouble(version) >= 5.0; + } catch (final NumberFormatException e) { + // ignore + } + } + return true; // default to HTML5 + } + + /** + * DOCTYPE emission for XHTML/HTML output methods, per + * W3C XSLT and XQuery Serialization 3.1 sections 7.1 and 7.2. + * + *
    + *
  • doctype-system set: emit DOCTYPE with PUBLIC/SYSTEM ids
  • + *
  • doctype-system absent, html method, doctype-public set: emit DOCTYPE PUBLIC
  • + *
  • doctype-system absent, html-version ≥ 5: emit {@code }
  • + *
  • otherwise: no DOCTYPE
  • + *
+ * + * Only emitted when the root element is {@code html} (case-insensitive); for + * fragments rooted on any other element the DOCTYPE is suppressed. + */ + @Override + protected void writeDoctype(final String rootElement) throws TransformerException { + if (doctypeWritten) { + return; + } + if (isCanonical()) { + doctypeWritten = true; + return; + } + final String localName = rootElement.contains(":") + ? rootElement.substring(rootElement.indexOf(':') + 1) + : rootElement; + if (!"html".equalsIgnoreCase(localName)) { + doctypeWritten = true; + return; + } + + final String publicId = outputProperties != null + ? outputProperties.getProperty(OutputKeys.DOCTYPE_PUBLIC) : null; + final String systemId = outputProperties != null + ? outputProperties.getProperty(OutputKeys.DOCTYPE_SYSTEM) : null; + final boolean htmlMethod = isHtmlMethod(); + final boolean html5 = isHtml5Version(); + + if (systemId != null) { + documentType("html", publicId, systemId); + } else if (htmlMethod && publicId != null) { + documentType("html", publicId, null); + } else if (html5) { + documentType("html", null, null); + } + doctypeWritten = true; + } + @Override + public void attribute(final QName qname, final CharSequence value) throws TransformerException { + noteMetaAttribute(qname.getLocalPart(), value); + final CharSequence effectiveValue = maybeEscapeUri(qname.getLocalPart(), value); + // For method="html", minimize boolean attributes when value matches name + if (isHtmlMethod() && isBooleanAttribute(qname.getLocalPart(), effectiveValue)) { + try { + if (!tagIsOpen) { + characters(value); + return; + } + final Writer w = getWriter(); + w.write(' '); + w.write(qname.getLocalPart()); + // Don't write ="value" — minimized form + } catch (final IOException ioe) { + throw new TransformerException(ioe.getMessage(), ioe); + } + return; + } + super.attribute(qname, effectiveValue); + } + + @Override + public void attribute(final String qname, final CharSequence value) throws TransformerException { + // Strip prefix for the redundancy check (we want the local name). + final int colon = qname.indexOf(':'); + final String localName = colon < 0 ? qname : qname.substring(colon + 1); + noteMetaAttribute(localName, value); + final CharSequence effectiveValue = maybeEscapeUri(localName, value); + if (isHtmlMethod() && isBooleanAttribute(qname, effectiveValue)) { + try { + if (!tagIsOpen) { + characters(value); + return; + } + final Writer w = getWriter(); + w.write(' '); + w.write(qname); + } catch (final IOException ioe) { + throw new TransformerException(ioe.getMessage(), ioe); + } + return; + } + super.attribute(qname, effectiveValue); + } + + /** + * Apply escape-uri-attributes when the current element/attribute names + * a URI-valued attribute; otherwise return the value unchanged. The + * caller decides whether to use the escaped form when checking boolean + * attribute minimization (it cannot match a URI value, so this is safe). + */ + private CharSequence maybeEscapeUri(final String attrLocal, final CharSequence value) { + if (!isHtmlMethod() || currentTag == null) { + // currentTag is also kept for XHTML; we still apply escaping there + // so URI-valued attributes round-trip in XHTML 1.0 / 5 output too. + if (currentTag == null) { + return value; + } + } + final String elementLocal = currentTag.contains(":") + ? currentTag.substring(currentTag.indexOf(':') + 1) + : currentTag; + if (!shouldEscapeUriAttribute(elementLocal, attrLocal)) { + return value; + } + return escapeUriAttribute(value); + } + + private boolean isBooleanAttribute(final String attrName, final CharSequence value) { + return BOOLEAN_ATTRIBUTES.contains(attrName.toLowerCase(java.util.Locale.ROOT)) + && attrName.equalsIgnoreCase(value.toString()); + } + + private static final ObjectSet RAW_TEXT_ELEMENTS_HTML = new ObjectOpenHashSet<>(4); + static { + RAW_TEXT_ELEMENTS_HTML.add("script"); + RAW_TEXT_ELEMENTS_HTML.add("style"); + } + + @Override + protected boolean needsEscape(final char ch, final boolean inAttribute) { + // For HTML method, script and style content should not be escaped + if (!inAttribute && isHtmlMethod() + && currentTag != null && RAW_TEXT_ELEMENTS_HTML.contains(currentTag.toLowerCase(java.util.Locale.ROOT))) { + return false; + } + return super.needsEscape(ch, inAttribute); + } + + @Override + protected boolean needsEscaping(final boolean inAttribute) { + if (!inAttribute && isHtmlMethod() + && currentTag != null && RAW_TEXT_ELEMENTS_HTML.contains(currentTag.toLowerCase(java.util.Locale.ROOT))) { + return false; + } + return super.needsEscaping(inAttribute); + } + + /** + * Per W3C XSLT and XQuery Serialization 3.1 § 7.2.7, the html method + * ignores cdata-section-elements for HTML elements (CDATA sections are + * not valid HTML syntax) but DOES apply them to foreign content + * (e.g. SVG, MathML, or any element in a non-HTML namespace embedded + * in the document). For foreign content the rule is unconditional — + * the xdm-serialization gate that the XML writer otherwise applies + * does not gate HTML's foreign-content CDATA emission. + */ + @Override + protected boolean shouldUseCdataSections() { + if (isHtmlMethod()) { + final String ns = currentElementNamespaceURI(); + if (ns == null || ns.isEmpty() || Namespaces.XHTML_NS.equals(ns)) { + return false; + } + return true; + } + return super.shouldUseCdataSections(); + } + + /** + * Processing-instruction serialization for HTML method (pre-HTML5). + * Per W3C XSLT and XQuery Serialization 3.1 § 7.1.5, the HTML output + * method emits PIs as {@code } (no closing {@code ?>}); + * XHTML uses the regular XML form which the parent already provides. + * The HTML5 (PR2372) variant lives in {@link HTML5Writer}. + */ + @Override + public void processingInstruction(final String target, final String data) throws TransformerException { + if (!isHtmlMethod()) { + super.processingInstruction(target, data); + return; + } + try { + if (tagIsOpen) { + closeStartTag(false); + } + final Writer w = getWriter(); + w.write("'); + } catch (final IOException ioe) { + throw new TransformerException(ioe.getMessage(), ioe); + } + } + + @Override + protected boolean escapeAmpersandBeforeBrace() { + // HTML spec: & before { in attribute values should not be escaped + return false; + } + @Override protected boolean isInlineTag(final String namespaceURI, final String localName) { return (namespaceURI == null || namespaceURI.isEmpty() || Namespaces.XHTML_NS.equals(namespaceURI)) && inlineTags.contains(localName); } + + /** + * Write a meta content-type tag as the first child of head when + * include-content-type is enabled (the default per W3C Serialization 3.1). + */ + protected void writeContentTypeMeta() throws TransformerException { + if (contentTypeMetaWritten || outputProperties == null) { + return; + } + final String includeContentType = outputProperties.getProperty("include-content-type", "yes"); + if (!"yes".equals(includeContentType)) { + return; + } + contentTypeMetaWritten = true; + try { + final String encoding = outputProperties.getProperty(OutputKeys.ENCODING, "UTF-8"); + closeStartTag(false); + final Writer writer = getWriter(); + + // HTML5 method uses + // XHTML and HTML4 use + // XHTML mode requires self-closing tags (/>) for valid XML output — + // the URL rewrite pipeline re-parses this as XML in the view step. + final boolean selfClose = !isHtmlMethod(); + if (isHtmlMethod() && isHtml5Version()) { + writer.write("" : "\">"); + } else { + final String mediaType = outputProperties.getProperty(OutputKeys.MEDIA_TYPE, "text/html"); + writer.write("" : "\">"); + } + } catch (IOException e) { + throw new TransformerException(e.getMessage(), e); + } + } } diff --git a/exist-core/src/main/java/org/exist/util/serializer/XMLWriter.java b/exist-core/src/main/java/org/exist/util/serializer/XMLWriter.java index 763aaf52ef6..1ecba62f281 100644 --- a/exist-core/src/main/java/org/exist/util/serializer/XMLWriter.java +++ b/exist-core/src/main/java/org/exist/util/serializer/XMLWriter.java @@ -78,6 +78,11 @@ public class XMLWriter implements SerializerWriter { private String defaultNamespace = ""; + // Namespace stack (BaseX-style): flat list of (prefix, uri) pairs for all in-scope bindings. + // nstack records the list size at each startElement so endElement can roll back declarations. + private final List nspaces = new ArrayList<>(); + private final Deque nstack = new ArrayDeque<>(); + /** * When serializing an XDM this should be true, * otherwise false. @@ -86,8 +91,33 @@ public class XMLWriter implements SerializerWriter { * compared to retrieving resources from the database. */ private boolean xdmSerialization = false; + private boolean xml11 = false; + private boolean canonical = false; + @Nullable private java.text.Normalizer.Form normalizationForm = null; + + // Canonical XML: buffer namespaces and attributes for sorting + private final List canonicalNamespaces = new ArrayList<>(); // [prefix, uri] + private final List canonicalAttributes = new ArrayList<>(); // [nsUri, localName, qname, value] private final Deque elementName = new ArrayDeque<>(); + + /** + * Returns true if cdata-section-elements should be applied. + * Subclasses (e.g., XHTMLWriter for HTML method) can override + * to suppress CDATA sections. + */ + protected boolean shouldUseCdataSections() { + return xdmSerialization; + } + + /** + * Returns the namespace URI of the current (innermost) element, + * or null if no element is on the stack. + */ + protected String currentElementNamespaceURI() { + final QName top = elementName.peek(); + return top != null ? top.getNamespaceURI() : null; + } private LazyVal> cdataSectionElements = new LazyVal<>(this::parseCdataSectionElementNames); private boolean cdataSetionElement = false; @@ -96,8 +126,9 @@ public class XMLWriter implements SerializerWriter { Arrays.fill(textSpecialChars, false); textSpecialChars['<'] = true; textSpecialChars['>'] = true; - // textSpecialChars['\r'] = true; + textSpecialChars['\r'] = true; textSpecialChars['&'] = true; + textSpecialChars[0x7F] = true; // DEL must be escaped as  attrSpecialChars = new boolean[128]; Arrays.fill(attrSpecialChars, false); @@ -108,6 +139,7 @@ public class XMLWriter implements SerializerWriter { attrSpecialChars['\t'] = true; attrSpecialChars['&'] = true; attrSpecialChars['"'] = true; + attrSpecialChars[0x7F] = true; // DEL must be escaped as  } @Nullable private XMLDeclaration originalXmlDecl; @@ -139,6 +171,10 @@ public void setOutputProperties(final Properties properties) { } this.xdmSerialization = "yes".equals(outputProperties.getProperty(EXistOutputKeys.XDM_SERIALIZATION, "no")); + this.xml11 = "1.1".equals(outputProperties.getProperty(OutputKeys.VERSION)); + this.normalizationForm = parseNormalizationForm(outputProperties.getProperty("normalization-form", "none")); + final String canonicalProp = outputProperties.getProperty(EXistOutputKeys.CANONICAL); + this.canonical = "yes".equals(canonicalProp) || "true".equals(canonicalProp) || "1".equals(canonicalProp); } private Set parseCdataSectionElementNames() { @@ -166,6 +202,8 @@ protected void resetObjectState() { originalXmlDecl = null; doctypeWritten = false; defaultNamespace = ""; + nspaces.clear(); + nstack.clear(); cdataSectionElements = new LazyVal<>(this::parseCdataSectionElementNames); } @@ -184,12 +222,35 @@ public Writer getWriter() { } public String getDefaultNamespace() { - return defaultNamespace.isEmpty() ? null : defaultNamespace; + final String fromStack = nsLookup(""); + return (fromStack == null || fromStack.isEmpty()) ? null : fromStack; } public void setDefaultNamespace(final String namespace) { + // Keep the baseline field in sync; nsLookup() falls back to it when the + // namespace stack has no in-scope binding for the default prefix. defaultNamespace = namespace == null ? "" : namespace; } + + /** + * Looks up the currently in-scope URI for {@code prefix} by scanning the flat + * namespace list from innermost to outermost scope. + * For the default-namespace prefix ({@code ""}), falls back to the + * {@link #defaultNamespace} baseline field when the stack has no binding. + * + * @return the in-scope URI, or {@code null} if {@code prefix} is unbound + */ + private String nsLookup(final String prefix) { + for (int i = nspaces.size() - 2; i >= 0; i -= 2) { + if (nspaces.get(i).equals(prefix)) { + return nspaces.get(i + 1); + } + } + if (prefix.isEmpty()) { + return defaultNamespace.isEmpty() ? null : defaultNamespace; + } + return null; + } public void startDocument() throws TransformerException { resetObjectState(); @@ -207,15 +268,16 @@ public void startElement(final String namespaceUri, final String localName, fina if(!declarationWritten) { writeDeclaration(); } - + if(!doctypeWritten) { writeDoctype(qname); } - + try { if(tagIsOpen) { closeStartTag(false); } + nstack.push(nspaces.size()); writer.write('<'); writer.write(qname); tagIsOpen = true; @@ -233,21 +295,22 @@ public void startElement(final QName qname) throws TransformerException { if(!declarationWritten) { writeDeclaration(); } - + if(!doctypeWritten) { writeDoctype(qname.getStringValue()); } - + try { if(tagIsOpen) { closeStartTag(false); } + nstack.push(nspaces.size()); writer.write('<'); if(qname.getPrefix() != null && !qname.getPrefix().isEmpty()) { writer.write(qname.getPrefix()); writer.write(':'); } - + writer.write(qname.getLocalPart()); tagIsOpen = true; elementName.push(qname); @@ -266,6 +329,9 @@ public void endElement(final String namespaceURI, final String localName, final writer.write('>'); } elementName.pop(); + if (!nstack.isEmpty()) { + nspaces.subList(nstack.pop(), nspaces.size()).clear(); + } } catch(final IOException ioe) { throw new TransformerException(ioe.getMessage(), ioe); } @@ -285,40 +351,74 @@ public void endElement(final QName qname) throws TransformerException { writer.write('>'); } elementName.pop(); + if (!nstack.isEmpty()) { + nspaces.subList(nstack.pop(), nspaces.size()).clear(); + } } catch(final IOException ioe) { throw new TransformerException(ioe.getMessage(), ioe); } } public void namespace(final String prefix, final String nsURI) throws TransformerException { - if((nsURI == null) && (prefix == null || prefix.isEmpty())) { + final String normPrefix = prefix != null ? prefix : ""; + final String normUri = nsURI != null ? nsURI : ""; + + // The xml namespace is implicitly declared and never needs explicit serialization + if ("xml".equals(normPrefix)) { return; } - try { - if(!tagIsOpen) { + try { + if (!tagIsOpen) { + // An xmlns="" outside a start tag is harmless — just skip it + if (normUri.isEmpty() && normPrefix.isEmpty()) { + return; + } throw new TransformerException("Found a namespace declaration outside an element"); } - if(prefix != null && !prefix.isEmpty()) { - writer.write(' '); - writer.write("xmlns"); - writer.write(':'); - writer.write(prefix); - writer.write("=\""); - writeChars(nsURI, true); - writer.write('"'); - } else { - if(defaultNamespace.equals(nsURI)) { - return; + if (canonical) { + // Buffer for sorting — emitted in closeStartTag + // Validate: reject relative namespace URIs (SERE0024) + if (!normUri.isEmpty() && isRelativeUri(normUri)) { + throw new TransformerException("err:SERE0024 Canonical serialization does not allow relative namespace URIs: " + normUri); } - writer.write(' '); - writer.write("xmlns"); + if (normPrefix.isEmpty() && normUri.isEmpty()) { + return; // Skip xmlns="" in canonical (not meaningful for no-namespace elements) + } + // Deduplicate: replace existing binding for same prefix + canonicalNamespaces.removeIf(ns -> ns[0].equals(normPrefix)); + canonicalNamespaces.add(new String[]{normPrefix, normUri}); + // Track in namespace stack so getDefaultNamespace() stays accurate + nspaces.add(normPrefix); + nspaces.add(normUri); + return; + } + + // Look up what is currently in scope for this prefix. + // nsLookup scans nspaces from innermost to outermost and falls back to the + // defaultNamespace baseline field for the default-namespace prefix. + final String inScope = nsLookup(normPrefix); + final String effective = inScope != null ? inScope : ""; + if (normUri.equals(effective)) { + return; // Binding unchanged — no declaration needed + } + + // Record the new binding so descendants can see it via nsLookup + nspaces.add(normPrefix); + nspaces.add(normUri); + + // Write the namespace declaration + writer.write(' '); + if (normPrefix.isEmpty()) { + writer.write("xmlns=\""); + } else { + writer.write("xmlns:"); + writer.write(normPrefix); writer.write("=\""); - writeChars(nsURI, true); - writer.write('"'); - defaultNamespace= nsURI; } + writeChars(normUri, true); + writer.write('"'); } catch(final IOException ioe) { throw new TransformerException(ioe.getMessage(), ioe); } @@ -329,12 +429,18 @@ public void attribute(String qname, CharSequence value) throws TransformerExcept if(!tagIsOpen) { characters(value); return; - // throw new TransformerException("Found an attribute outside an - // element"); } - writer.write(' '); - writer.write(qname); - writer.write("=\""); + if (canonical) { + // Buffer for sorting — extract namespace URI from qname if prefixed + final int colon = qname.indexOf(':'); + final String nsUri = colon > 0 ? "" : ""; // string qname doesn't carry namespace + canonicalAttributes.add(new String[]{nsUri, colon > 0 ? qname.substring(colon + 1) : qname, qname, value.toString()}); + return; + } + // Coalesce ' ' + qname + '="' into a single bulk write when the + // qname fits in the scratch buffer (typical case for short HTML + // attribute names like class, href, style). + writeAttributePrefix(qname); writeChars(value, true); writer.write('"'); } catch(final IOException ioe) { @@ -347,16 +453,26 @@ public void attribute(final QName qname, final CharSequence value) throws Transf if(!tagIsOpen) { characters(value); return; - // throw new TransformerException("Found an attribute outside an - // element"); } - writer.write(' '); - if(qname.getPrefix() != null && !qname.getPrefix().isEmpty()) { - writer.write(qname.getPrefix()); - writer.write(':'); + if (canonical) { + final String nsUri = qname.getNamespaceURI() != null ? qname.getNamespaceURI() : ""; + final String localName = qname.getLocalPart(); + final String fullName; + if (qname.getPrefix() != null && !qname.getPrefix().isEmpty()) { + fullName = qname.getPrefix() + ":" + localName; + } else { + fullName = localName; + } + canonicalAttributes.add(new String[]{nsUri, localName, fullName, value.toString()}); + return; + } + final String prefix = qname.getPrefix(); + final String localPart = qname.getLocalPart(); + if (prefix != null && !prefix.isEmpty()) { + writePrefixedAttributePrefix(prefix, localPart); + } else { + writeAttributePrefix(localPart); } - writer.write(qname.getLocalPart()); - writer.write("=\""); writeChars(value, true); writer.write('"'); } catch(final IOException ioe) { @@ -364,6 +480,55 @@ public void attribute(final QName qname, final CharSequence value) throws Transf } } + /** + * Write {@code ' ' + qname + '="'} as a single {@code Writer.write(char[], + * int, int)} call when {@code qname} fits in the scratch buffer. Reduces + * 3 writer calls per attribute to 1. + */ + private void writeAttributePrefix(final String qname) throws IOException { + final int qlen = qname.length(); + final int needed = qlen + 3; // ' ' + qname + '="' + if (needed <= ATTR_SCRATCH_LEN) { + attrScratch[0] = ' '; + qname.getChars(0, qlen, attrScratch, 1); + attrScratch[qlen + 1] = '='; + attrScratch[qlen + 2] = '"'; + writer.write(attrScratch, 0, needed); + } else { + writer.write(' '); + writer.write(qname); + writer.write("=\""); + } + } + + /** + * Write {@code ' ' + prefix + ':' + localPart + '="'} as a single bulk + * write when it fits the scratch buffer. + */ + private void writePrefixedAttributePrefix(final String prefix, final String localPart) throws IOException { + final int plen = prefix.length(); + final int llen = localPart.length(); + final int needed = plen + llen + 4; // ' ' + prefix + ':' + localPart + '="' + if (needed <= ATTR_SCRATCH_LEN) { + attrScratch[0] = ' '; + prefix.getChars(0, plen, attrScratch, 1); + attrScratch[plen + 1] = ':'; + localPart.getChars(0, llen, attrScratch, plen + 2); + attrScratch[plen + llen + 2] = '='; + attrScratch[plen + llen + 3] = '"'; + writer.write(attrScratch, 0, needed); + } else { + writer.write(' '); + writer.write(prefix); + writer.write(':'); + writer.write(localPart); + writer.write("=\""); + } + } + + private static final int ATTR_SCRATCH_LEN = 96; + private final char[] attrScratch = new char[ATTR_SCRATCH_LEN]; + public void characters(final CharSequence chars) throws TransformerException { if(!declarationWritten) { writeDeclaration(); @@ -373,12 +538,68 @@ public void characters(final CharSequence chars) throws TransformerException { if(tagIsOpen) { closeStartTag(false); } - writeChars(chars, false); + // When xdmSerialization is active and current element is in cdata-section-elements, + // wrap text content in CDATA instead of escaping it (per W3C Serialization 3.1) + if (shouldUseCdataSections() && !elementName.isEmpty() + && cdataSectionElements.get().contains(elementName.peek())) { + writeCdataContent(chars); + } else { + writeChars(chars, false); + } } catch(final IOException ioe) { throw new TransformerException(ioe.getMessage(), ioe); } } + private void writeCdataContent(final CharSequence chars) throws IOException { + // CDATA sections must be split when: + // 1. The content contains "]]>" (which would end the CDATA prematurely) + // 2. A character cannot be represented in the output encoding (must be escaped as &#xNN;) + final String s = normalize(chars).toString(); + boolean inCdata = false; + for (int i = 0; i < s.length(); ) { + final int cp = s.codePointAt(i); + final int cpLen = Character.charCount(cp); + + // Check for "]]>" sequence + if (cp == ']' && i + 2 < s.length() && s.charAt(i + 1) == ']' && s.charAt(i + 2) == '>') { + if (!inCdata) { + writer.write(""); + inCdata = false; + i += 2; // skip "]]", the ">" will be picked up next + continue; + } + + // Check if character is encodable in the output charset + if (!charSet.inCharacterSet((char) cp)) { + // Close any open CDATA section + if (inCdata) { + writer.write("]]>"); + inCdata = false; + } + // Write as character reference + writer.write("&#x"); + writer.write(Integer.toHexString(cp)); + writer.write(';'); + } else { + // Encodable character — write inside CDATA + if (!inCdata) { + writer.write(""); + } + } + public void characters(final char[] ch, final int start, final int len) throws TransformerException { if(!declarationWritten) { writeDeclaration(); @@ -510,8 +731,23 @@ public void documentType(final String name, final String publicId, final String protected void closeStartTag(final boolean isEmpty) throws TransformerException { try { if(tagIsOpen) { - if(isEmpty) { + if (canonical) { + flushCanonicalBuffers(); + } + if(isEmpty && !canonical) { + // Canonical XML: empty elements expanded to writer.write("/>"); + } else if (isEmpty) { + // Canonical: write > for empty elements + writer.write('>'); + final QName currentElem = elementName.peek(); + writer.write("'); } else { writer.write('>'); } @@ -522,6 +758,52 @@ protected void closeStartTag(final boolean isEmpty) throws TransformerException } } + protected boolean isCanonical() { + return canonical; + } + + protected void flushCanonicalBuffersXhtml() throws TransformerException { + try { + flushCanonicalBuffers(); + } catch (final IOException ioe) { + throw new TransformerException(ioe.getMessage(), ioe); + } + } + + private void flushCanonicalBuffers() throws IOException { + // Sort namespaces by prefix (default namespace first, then alphabetical) + canonicalNamespaces.sort((a, b) -> a[0].compareTo(b[0])); + // Write sorted namespaces + for (final String[] ns : canonicalNamespaces) { + writer.write(' '); + if (ns[0].isEmpty()) { + writer.write("xmlns=\""); + } else { + writer.write("xmlns:"); + writer.write(ns[0]); + writer.write("=\""); + } + writeChars(ns[1], true); + writer.write('"'); + } + canonicalNamespaces.clear(); + + // Sort attributes by namespace URI (primary), then local name (secondary) + canonicalAttributes.sort((a, b) -> { + final int cmp = a[0].compareTo(b[0]); + return cmp != 0 ? cmp : a[1].compareTo(b[1]); + }); + // Write sorted attributes + for (final String[] attr : canonicalAttributes) { + writer.write(' '); + writer.write(attr[2]); // qualified name + writer.write("=\""); + writeChars(attr[3], true); + writer.write('"'); + } + canonicalAttributes.clear(); + } + protected void writeDeclaration() throws TransformerException { if(declarationWritten) { return; @@ -537,7 +819,9 @@ protected void writeDeclaration() throws TransformerException { // get the fields of the persisted xml declaration, but overridden with any properties from the serialization properties final String version = outputProperties.getProperty(OutputKeys.VERSION, (originalXmlDecl.version != null ? originalXmlDecl.version : DEFAULT_XML_VERSION)); final String encoding = outputProperties.getProperty(OutputKeys.ENCODING, (originalXmlDecl.encoding != null ? originalXmlDecl.encoding : DEFAULT_XML_ENCODING)); - @Nullable final String standalone = outputProperties.getProperty(OutputKeys.STANDALONE, originalXmlDecl.standalone); + @Nullable final String standaloneOrig = outputProperties.getProperty(OutputKeys.STANDALONE, originalXmlDecl.standalone); + // "omit" means standalone should be absent from the declaration + @Nullable final String standalone = (standaloneOrig != null && "omit".equalsIgnoreCase(standaloneOrig.trim())) ? null : standaloneOrig; writeDeclaration(version, encoding, standalone); @@ -545,11 +829,15 @@ protected void writeDeclaration() throws TransformerException { } final String omitXmlDecl = outputProperties.getProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); - if ("no".equals(omitXmlDecl)) { + @Nullable final String standaloneRaw = outputProperties.getProperty(OutputKeys.STANDALONE); + // "omit" means standalone should be absent from the declaration + @Nullable final String standalone = (standaloneRaw != null && "omit".equalsIgnoreCase(standaloneRaw.trim())) ? null : standaloneRaw; + // Per W3C Serialization 3.1: output declaration if omit-xml-declaration is false/no/0, + // or if standalone is explicitly set (the declaration is required to carry standalone) + if (isBooleanFalse(omitXmlDecl) || standalone != null) { // get the fields of the declaration from the serialization properties final String version = outputProperties.getProperty(OutputKeys.VERSION, DEFAULT_XML_VERSION); final String encoding = outputProperties.getProperty(OutputKeys.ENCODING, DEFAULT_XML_ENCODING); - @Nullable final String standalone = outputProperties.getProperty(OutputKeys.STANDALONE); writeDeclaration(version, encoding, standalone); } @@ -564,7 +852,15 @@ private void writeDeclaration(final String version, final String encoding, @Null writer.write('"'); if(standalone != null) { writer.write(" standalone=\""); - writer.write(standalone); + // Normalize boolean values to yes/no for XML declaration + final String standaloneVal = standalone.trim(); + if ("true".equals(standaloneVal) || "1".equals(standaloneVal)) { + writer.write("yes"); + } else if ("false".equals(standaloneVal) || "0".equals(standaloneVal)) { + writer.write("no"); + } else { + writer.write(standaloneVal); + } writer.write('"'); } writer.write("?>\n"); @@ -589,36 +885,96 @@ protected void writeDoctype(final String rootElement) throws TransformerExceptio protected boolean needsEscape(final char ch) { return true; } - + + /** + * Whether & before { should be escaped. HTML output returns false + * per W3C HTML serialization spec. XML output returns true (always escape &). + */ + protected boolean escapeAmpersandBeforeBrace() { + return true; + } + + /** + * Check if a serialization boolean parameter value is false. + * W3C Serialization 3.1 accepts "no", "false", "0" (with optional whitespace) as false. + */ + protected static boolean isBooleanFalse(final String value) { + if (value == null) { + return false; + } + final String trimmed = value.trim(); + return "no".equals(trimmed) || "false".equals(trimmed) || "0".equals(trimmed); + } + + /** + * Whether the given character needs escaping. Subclasses can override + * to suppress escaping for specific contexts (e.g., HTML raw text elements). + * + * @param ch the character to check + * @param inAttribute true if we're writing an attribute value + */ + protected boolean needsEscape(final char ch, final boolean inAttribute) { + return needsEscape(ch); + } + + /** + * Whether the current context requires character escaping at all. + * Subclasses (e.g., HTML5Writer for {@code ", + "html", "5.0"); + assertTrue("Script attribute & should be escaped: " + result, + result.contains("language=\"Jack&Jill\"")); + assertTrue("Script body && should NOT be escaped: " + result, + result.contains("go && run()")); + } + + @Test + public void html40NoDoctypeWithoutPublicSystem() throws Exception { + // HTML 4.0 without doctype-public/doctype-system should not emit DOCTYPE + final String result = serialize("

hello

", "html", "4.0"); + assertFalse("HTML 4.0 without public/system should NOT have DOCTYPE: " + result, + result.contains("\n"; + final String expected = ""; final QName elQName = new QName("input"); writer.startElement(elQName); writer.attribute("checked", "checked"); @@ -54,7 +54,7 @@ public void testAttributeWithBooleanValue() throws Exception { @Test public void testAttributeWithNonBooleanValue() throws Exception { - final String expected = "\n"; + final String expected = ""; final QName elQName = new QName("input"); writer.startElement(elQName); writer.attribute("name", "name"); @@ -66,7 +66,7 @@ public void testAttributeWithNonBooleanValue() throws Exception { @Test public void testAttributeQNameWithBooleanValue() throws Exception { - final String expected = "\n"; + final String expected = ""; final QName elQName = new QName("input"); final QName attrQName = new QName("checked"); writer.startElement(elQName); @@ -79,7 +79,7 @@ public void testAttributeQNameWithBooleanValue() throws Exception { @Test public void testAttributeQNameWithNonBooleanValue() throws Exception { - final String expected = "\n"; + final String expected = ""; final QName elQName = new QName("input"); final QName attrQName = new QName("name"); writer.startElement(elQName); diff --git a/exist-core/src/test/java/org/exist/util/serializer/HtmlSerializerBenchmark.java b/exist-core/src/test/java/org/exist/util/serializer/HtmlSerializerBenchmark.java new file mode 100644 index 00000000000..bdfa3d9c697 --- /dev/null +++ b/exist-core/src/test/java/org/exist/util/serializer/HtmlSerializerBenchmark.java @@ -0,0 +1,291 @@ +/* + * 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.util.serializer; + +import org.exist.dom.QName; +import org.junit.Test; + +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerException; +import java.io.IOException; +import java.io.Writer; +import java.util.Properties; + +import static org.junit.Assert.assertTrue; + +/** + * Microbenchmark for HTML serialization that exercises the writeChars/writeCharSeq + * hot path. Builds a representative HTML document with paragraphs of plain text + * (no special chars in the safe runs) and serializes it many times. + * + * Compares two configurations: + * - bulk writes via {@link Writer#write(char[], int, int)} (current code) + * - per-char writes via {@link Writer#write(int)} (the previous behaviour) + * + * The "per-char" baseline is simulated by wrapping the writer in one that + * counts only charAt-based calls — this lets us prove the algorithmic + * improvement without having to revert the patch. + */ +public class HtmlSerializerBenchmark { + + private static final String LOREM = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim " + + "ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " + + "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit " + + "in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; + + private static final int PARAGRAPH_COUNT = 80; + private static final int ITERATIONS = 200; + + /** + * Counts both bulk and per-char writes so we can verify the hot path is + * actually using bulk operations. + */ + private static final class CountingWriter extends Writer { + long bulkWriteCalls; + long bulkCharsWritten; + long perCharWriteCalls; + long stringWriteCalls; + long stringCharsWritten; + + @Override + public void write(int c) { + perCharWriteCalls++; + } + + @Override + public void write(char[] cbuf, int off, int len) { + bulkWriteCalls++; + bulkCharsWritten += len; + } + + @Override + public void write(String str, int off, int len) { + stringWriteCalls++; + stringCharsWritten += len; + } + + @Override + public void flush() {} + + @Override + public void close() {} + } + + /** + * Forwards every write to the underlying writer one char at a time, + * simulating a writer that has no efficient bulk path. Wrapping a + * {@link java.io.StringWriter} in this is the closest we can come + * to measuring the *previous* writeCharSeq behaviour without reverting. + */ + private static final class PerCharWriter extends Writer { + private final Writer delegate; + PerCharWriter(final Writer delegate) { this.delegate = delegate; } + @Override public void write(int c) throws IOException { delegate.write(c); } + @Override public void write(char[] cbuf, int off, int len) throws IOException { + for (int i = 0; i < len; i++) delegate.write(cbuf[off + i]); + } + @Override public void write(String str, int off, int len) throws IOException { + for (int i = 0; i < len; i++) delegate.write(str.charAt(off + i)); + } + @Override public void flush() throws IOException { delegate.flush(); } + @Override public void close() throws IOException { delegate.close(); } + } + + /** Discards bytes — simulates a network sink with no I/O cost. */ + private static final class NullOutputStream extends java.io.OutputStream { + @Override public void write(int b) {} + @Override public void write(byte[] b, int off, int len) {} + } + + private static java.io.OutputStreamWriter newProductionLikeWriter() { + // Mirrors the typical HTTP-response chain: OutputStreamWriter(UTF-8) over + // a stream sink. No BufferedWriter — eXist's serializer pipeline does its + // own buffering at higher levels. + return new java.io.OutputStreamWriter(new NullOutputStream(), java.nio.charset.StandardCharsets.UTF_8); + } + + @Test + public void rawTextFastPath() throws TransformerException, IOException { + // Compare per-char writes between an empty " while non-empty + // splits the close across two writers.write() calls.) + final long stringCharsDelta = withScript.stringCharsWritten - empty.stringCharsWritten; + assertTrue("Script body should add bulk string output close to its size; " + + "empty=" + empty.stringCharsWritten + " withScript=" + + withScript.stringCharsWritten + " delta=" + stringCharsDelta + + " script.length()=" + script.length(), + stringCharsDelta >= script.length() - 5); + } + + private CountingWriter serializeWithScript(final String script) throws TransformerException { + final CountingWriter counter = new CountingWriter(); + final XHTMLWriter w = new HTML5Writer(counter); + final Properties props = new Properties(); + props.setProperty(OutputKeys.METHOD, "html"); + w.setOutputProperties(props); + w.startDocument(); + w.startElement(null, "html", "html"); + w.startElement(null, "body", "body"); + w.startElement(null, "script", "script"); + if (!script.isEmpty()) { + w.characters(script); + } + w.endElement(null, "script", "script"); + w.endElement(null, "body", "body"); + w.endElement(null, "html", "html"); + w.endDocument(); + return counter; + } + + @Test + public void compareAgainstPerCharWriter() throws TransformerException, IOException { + // Warm-up — let JIT compile the hot path + for (int i = 0; i < 5; i++) { + try (java.io.OutputStreamWriter w = newProductionLikeWriter()) { run(w); } + try (java.io.OutputStreamWriter w = newProductionLikeWriter()) { + run(new PerCharWriter(w)); + } + } + + // Bulk path (current code) + long bulkStart = System.nanoTime(); + for (int i = 0; i < ITERATIONS; i++) { + try (java.io.OutputStreamWriter w = newProductionLikeWriter()) { run(w); } + } + long bulkMs = (System.nanoTime() - bulkStart) / 1_000_000L; + + // Per-char path: wraps the OutputStreamWriter so every char goes through + // OutputStreamWriter.write(int) — same path the previous writeCharSeq used. + long perCharStart = System.nanoTime(); + for (int i = 0; i < ITERATIONS; i++) { + try (java.io.OutputStreamWriter w = newProductionLikeWriter()) { + run(new PerCharWriter(w)); + } + } + long perCharMs = (System.nanoTime() - perCharStart) / 1_000_000L; + + System.out.println("[HtmlSerializerBenchmark] " + ITERATIONS + " iters of " + + PARAGRAPH_COUNT + "-paragraph HTML doc to OutputStreamWriter(UTF-8):"); + System.out.println("[HtmlSerializerBenchmark] bulk path: " + bulkMs + " ms (" + + String.format("%.3f", bulkMs * 1.0 / ITERATIONS) + " ms/doc)"); + System.out.println("[HtmlSerializerBenchmark] per-char path: " + perCharMs + " ms (" + + String.format("%.3f", perCharMs * 1.0 / ITERATIONS) + " ms/doc)"); + System.out.println("[HtmlSerializerBenchmark] speedup: " + + String.format("%.2fx", perCharMs * 1.0 / Math.max(1, bulkMs))); + + assertTrue("Bulk path should be faster than per-char path; bulk=" + + bulkMs + "ms perChar=" + perCharMs + "ms", bulkMs < perCharMs); + } + + @Test + public void htmlSerializationHotPath() throws TransformerException, IOException { + // Warm-up + for (int i = 0; i < 3; i++) { + run(new CountingWriter()); + } + + final CountingWriter counter = new CountingWriter(); + final long start = System.nanoTime(); + for (int i = 0; i < ITERATIONS; i++) { + run(counter); + } + final long elapsedMs = (System.nanoTime() - start) / 1_000_000L; + + final long totalChars = counter.bulkCharsWritten + counter.stringCharsWritten + counter.perCharWriteCalls; + final long bulkChars = counter.bulkCharsWritten + counter.stringCharsWritten; + final double bulkPct = bulkChars * 100.0 / totalChars; + + System.out.println("[HtmlSerializerBenchmark] " + ITERATIONS + " iterations of " + + PARAGRAPH_COUNT + "-paragraph HTML doc in " + elapsedMs + " ms" + + " (" + (elapsedMs * 1.0 / ITERATIONS) + " ms/doc)"); + System.out.println("[HtmlSerializerBenchmark] bulk writes: " + + counter.bulkWriteCalls + " (chars: " + counter.bulkCharsWritten + ")"); + System.out.println("[HtmlSerializerBenchmark] string writes: " + + counter.stringWriteCalls + " (chars: " + counter.stringCharsWritten + ")"); + System.out.println("[HtmlSerializerBenchmark] per-char writes: " + + counter.perCharWriteCalls); + System.out.println("[HtmlSerializerBenchmark] " + String.format("%.2f", bulkPct) + + "% of output bytes flushed in bulk"); + + // We expect the vast majority of safe-character output to flow through + // bulk writes (Writer.write(char[],int,int) or Writer.write(String,int,int)). + // Special-character escapes still go through per-char writes, but those + // are a tiny minority of output for typical HTML. + assertTrue("Expected >90% of chars to be flushed in bulk, but got " + bulkPct + "%", + bulkPct > 90.0); + } + + private void run(final Writer out) throws TransformerException { + final XHTMLWriter w = new XHTMLWriter(out); + final Properties props = new Properties(); + props.setProperty(OutputKeys.METHOD, "html"); + props.setProperty(OutputKeys.INDENT, "yes"); + w.setOutputProperties(props); + w.startDocument(); + w.startElement(null, "html", "html"); + w.startElement(null, "body", "body"); + for (int i = 0; i < PARAGRAPH_COUNT; i++) { + w.startElement(null, "p", "p"); + w.attribute("class", "para"); + w.characters(LOREM); + w.endElement(null, "p", "p"); + } + w.endElement(null, "body", "body"); + w.endElement(null, "html", "html"); + w.endDocument(); + } +} diff --git a/exist-core/src/test/java/org/exist/xinclude/W3CXIncludeTestSuite.java b/exist-core/src/test/java/org/exist/xinclude/W3CXIncludeTestSuite.java new file mode 100644 index 00000000000..b05e5d8ae7d --- /dev/null +++ b/exist-core/src/test/java/org/exist/xinclude/W3CXIncludeTestSuite.java @@ -0,0 +1,377 @@ +/* + * 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.xinclude; + +import org.exist.collections.Collection; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.exist.test.ExistEmbeddedServer; +import org.exist.util.MimeTable; +import org.exist.util.MimeType; +import org.exist.util.StringInputSource; +import org.exist.xmldb.XmldbURI; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.value.Sequence; +import org.junit.*; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.w3c.dom.*; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.*; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** + * Runs the W3C XInclude 1.0 Test Suite against eXist-db's XInclude implementation. + * + * Each test case stores the contributor's entire directory tree in eXist + * (preserving relative path structure for ../ents/ references), then + * serializes the input document with XInclude expansion and compares + * the output to the expected result. + */ +@RunWith(Parameterized.class) +public class W3CXIncludeTestSuite { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + private static final String TEST_SUITE_DIR = "xinclude-test-suite"; + private static final XmldbURI TEST_COLLECTION = XmldbURI.create("/db/xinclude-test"); + + // Track which contributor directories have been stored (avoid redundant uploads) + private static final Set storedContributors = new HashSet<>(); + + private final String testId; + private final String basedir; + private final String href; + private final String type; + private final String outputPath; + private final String description; + private final String features; + + public W3CXIncludeTestSuite(String testId, String basedir, String href, String type, + String outputPath, String description, String features) { + this.testId = testId; + this.basedir = basedir; + this.href = href; + this.type = type; + this.outputPath = outputPath; + this.description = description; + this.features = features; + } + + @Parameterized.Parameters(name = "{0}: {5}") + public static java.util.Collection data() throws Exception { + final List tests = new ArrayList<>(); + final Path catalogPath = getTestSuitePath().resolve("testdescr.xml"); + + final DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + final DocumentBuilder db = dbf.newDocumentBuilder(); + final Document doc = db.parse(catalogPath.toFile()); + + final NodeList testcasesNodes = doc.getElementsByTagName("testcases"); + for (int i = 0; i < testcasesNodes.getLength(); i++) { + final Element testcasesEl = (Element) testcasesNodes.item(i); + final String basedir = testcasesEl.getAttribute("basedir"); + + final NodeList testcaseNodes = testcasesEl.getElementsByTagName("testcase"); + for (int j = 0; j < testcaseNodes.getLength(); j++) { + final Element tc = (Element) testcaseNodes.item(j); + final String id = tc.getAttribute("id"); + final String tcHref = tc.getAttribute("href"); + final String tcType = tc.getAttribute("type"); + final String tcFeatures = tc.getAttribute("features"); + + String output = null; + final NodeList outputNodes = tc.getElementsByTagName("output"); + if (outputNodes.getLength() > 0) { + output = outputNodes.item(0).getTextContent().trim(); + } + + String desc = ""; + final NodeList descNodes = tc.getElementsByTagName("description"); + if (descNodes.getLength() > 0) { + desc = descNodes.item(0).getTextContent().trim(); + } + + tests.add(new Object[]{id, basedir, tcHref, tcType, output, desc, tcFeatures}); + } + } + + // Add XInclude 1.1 tests (not in testdescr.xml catalog) + tests.add(new Object[]{"xi11-attcopy-1", "xinclude-11/spec", "attcopy-1.xml", "success", + "result/attcopy-1.xml", "XInclude 1.1: Attribute copying with eg:root", "attcopy"}); + tests.add(new Object[]{"xi11-attcopy-2", "xinclude-11/spec", "attcopy-2.xml", "success", + "result/attcopy-2.xml", "XInclude 1.1: Attribute copying with set", "attcopy"}); + tests.add(new Object[]{"xi11-rfc5147-1", "xinclude-11/spec", "rfc5147-1.xml", "success", + "result/rfc5147-1.xml", "XInclude 1.1: RFC 5147 text fragment (line range)", "fragid"}); + tests.add(new Object[]{"xi11-rfc5147-2", "xinclude-11/spec", "rfc5147-2.xml", "success", + "result/rfc5147-2.xml", "XInclude 1.1: RFC 5147 text fragment (char range)", "fragid"}); + tests.add(new Object[]{"xi11-fallback", "xinclude-11/more", "fallback.xml", "success", + "result/fallback.xml", "XInclude 1.1: Integrity constraint error with fallback", "fragid"}); + + // XProc 3.0 XInclude tests (unique scenarios not in W3C suites) + tests.add(new Object[]{"xproc-016", "xproc3/input", "xproc-016.xml", "success", + "../result/xproc-016.xml", "XProc 3.0: parse=\"text\" on XML document", ""}); + tests.add(new Object[]{"xproc-017", "xproc3/input", "xproc-017.xml", "success", + null, "XProc 3.0: fixup-xml-lang=\"true\"", "fixup-xml-lang"}); + tests.add(new Object[]{"xproc-018", "xproc3/input", "xproc-018.xml", "success", + null, "XProc 3.0: fixup-xml-lang=\"true\" (variant)", "fixup-xml-lang"}); + tests.add(new Object[]{"xproc-019", "xproc3/input", "xproc-019.xml", "success", + null, "XProc 3.0: fixup-xml-lang=\"false\" (default)", "fixup-xml-lang"}); + + return tests; + } + + @Test + public void runTestCase() throws Exception { + // Skip tests requiring features eXist doesn't support + if (features != null && !features.isEmpty()) { + Assume.assumeFalse("Skipping: requires xpointer-scheme", features.contains("xpointer-scheme")); + Assume.assumeFalse("Skipping: requires unexpanded-entities", features.contains("unexpanded-entities")); + Assume.assumeFalse("Skipping: requires unparsed-entities", features.contains("unparsed-entities")); + // XInclude 1.1 features not yet supported + Assume.assumeFalse("Skipping: requires XInclude 1.1 attribute copying", features.contains("attcopy")); + Assume.assumeFalse("Skipping: requires XInclude 1.1 RFC 5147 fragid", features.contains("fragid")); + Assume.assumeFalse("Skipping: requires fixup-xml-lang processor parameter", features.contains("fixup-xml-lang")); + } + + // Skip tests that reference external HTTP URLs (network-dependent) + final Path testSuitePath = getTestSuitePath(); + final Path inputFile = testSuitePath.resolve(basedir).resolve(href); + if (Files.exists(inputFile)) { + final String content = new String(Files.readAllBytes(inputFile), StandardCharsets.UTF_8); + if (content.contains("href=\"http://") || content.contains("href='http://")) { + Assume.assumeTrue("Skipping: references external HTTP URL", false); + } + } + + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + + // Store the contributor's entire directory tree (preserving relative paths) + // e.g., for basedir "Imaq/test/XInclude/docs", store all of "Imaq/" + final String contributorDir = basedir.contains("/") ? basedir.substring(0, basedir.indexOf('/')) : basedir; + ensureContributorStored(pool, testSuitePath, contributorDir); + + // The input document path in eXist mirrors the filesystem structure + final String inputDocPath = TEST_COLLECTION + "/" + basedir + "/" + href; + + // Serialize with XInclude expansion + // Use fn:serialize() to get proper XML output, not just text content + final String xquery = String.format( + "let $doc := doc('%s')\n" + + "let $expanded := util:expand($doc, 'expand-xincludes=yes')\n" + + "return fn:serialize($expanded, map { 'method': 'xml', 'indent': false(), 'omit-xml-declaration': true() })", + inputDocPath); + + if ("error".equals(type)) { + try { + final String result = executeXQuery(pool, xquery); + fail("Expected XInclude error for test " + testId + " (" + description + ") but got result: " + + (result.length() > 200 ? result.substring(0, 200) + "..." : result)); + } catch (final XPathException | StackOverflowError | OutOfMemoryError e) { + // Expected — XInclude error (StackOverflow for infinite recursion tests) + } + } else { + assertNotNull("Success test must have expected output: " + testId, outputPath); + + final String result = executeXQuery(pool, xquery); + final Path expectedPath = testSuitePath.resolve(basedir).resolve(outputPath); + assertTrue("Expected output file not found: " + expectedPath, Files.exists(expectedPath)); + + final String expected = new String(Files.readAllBytes(expectedPath), StandardCharsets.UTF_8).trim(); + + final String normalizedExpected = normalizeXml(expected); + final String normalizedResult = normalizeXml(result); + + assertEquals("Test " + testId + ": " + description, normalizedExpected, normalizedResult); + } + } + + private void ensureContributorStored(final BrokerPool pool, final Path testSuitePath, + final String contributorDir) throws Exception { + if (storedContributors.contains(contributorDir)) { + return; + } + + final Path dirPath = testSuitePath.resolve(contributorDir); + if (!Files.exists(dirPath)) { + return; + } + + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject())); + final Txn transaction = pool.getTransactionManager().beginTransaction()) { + + storeDirectoryTree(broker, transaction, testSuitePath, dirPath); + transaction.commit(); + } + + storedContributors.add(contributorDir); + } + + private void storeDirectoryTree(final DBBroker broker, final Txn transaction, + final Path testSuiteRoot, final Path dir) throws Exception { + try (final var stream = Files.walk(dir)) { + for (final Path file : stream.filter(Files::isRegularFile).collect(Collectors.toList())) { + final String fileName = file.getFileName().toString(); + + // Skip CVS directories and non-test files + if (file.toString().contains("/CVS/")) continue; + if (fileName.endsWith(".dtd")) continue; + + // Build collection path mirroring filesystem structure + final String relativeDir = testSuiteRoot.relativize(file.getParent()).toString(); + final XmldbURI collectionUri = TEST_COLLECTION.append(relativeDir); + + final Collection collection = broker.getOrCreateCollection(transaction, collectionUri); + broker.saveCollection(transaction, collection); + + // Determine MIME type + final MimeType mimeType = MimeTable.getInstance().getContentTypeFor(fileName); + // Force .ent and .ent-like files to be stored as XML (they contain XML fragments) + final boolean forceXml = fileName.endsWith(".ent"); + try { + if (forceXml || (mimeType != null && mimeType.isXMLType())) { + final String content = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + final MimeType xmlMime = forceXml ? MimeType.XML_TYPE : mimeType; + broker.storeDocument(transaction, XmldbURI.create(fileName), + new StringInputSource(content), xmlMime, collection); + } else { + final byte[] bytes = Files.readAllBytes(file); + broker.storeDocument(transaction, XmldbURI.create(fileName), + new StringInputSource(bytes), + mimeType != null ? mimeType : MimeType.BINARY_TYPE, collection); + } + } catch (final Exception e) { + // Some test files may be intentionally malformed XML — store as binary + if (mimeType != null && mimeType.isXMLType()) { + try { + final byte[] bytes = Files.readAllBytes(file); + broker.storeDocument(transaction, XmldbURI.create(fileName), + new StringInputSource(bytes), MimeType.BINARY_TYPE, collection); + } catch (final Exception e2) { + // Skip files that can't be stored at all + } + } + } + } + } + } + + private String executeXQuery(final BrokerPool pool, final String xquery) throws Exception { + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject()))) { + final XQuery xqueryService = pool.getXQueryService(); + final Sequence result = xqueryService.execute(broker, xquery, null); + return result.getStringValue(); + } + } + + private static String normalizeXml(final String xml) { + // Strip XML declaration + String result = xml.replaceAll("<\\?xml[^?]*\\?>", "").trim(); + // Strip DOCTYPE declarations (eXist doesn't preserve them) + result = result.replaceAll("]*>", "").trim(); + // Normalize line endings + result = result.replace("\r\n", "\n").replace("\r", "\n"); + // Remove xml:base attributes (eXist doesn't emit them on included elements) + result = result.replaceAll("\\s+xml:base=\"[^\"]*\"", ""); + // Remove leaked XInclude namespace declarations (any prefix binding to the XInclude namespace) + result = result.replaceAll("\\s+xmlns:\\w+=\"http://www\\.w3\\.org/2001/XInclude\"", ""); + // Remove redundant default namespace undeclarations that eXist may add + result = result.replaceAll("\\s+xmlns=\"http://www\\.w3\\.org/2001/XInclude\"", ""); + result = result.replaceAll("\\s+xmlns=\"\"", ""); + // Strip internal DTD subsets (eXist doesn't preserve them) + result = result.replaceAll("]*>", ""); + result = result.replaceAll("]*>", ""); + result = result.replaceAll("]*>", ""); + result = result.replaceAll("]*>", ""); + // Normalize attribute quotes: single quotes to double quotes + result = result.replaceAll("='([^']*)'", "=\"$1\""); + // Normalize attribute order: sort attributes within each element for stable comparison + result = normalizeAttributeOrder(result); + // Normalize whitespace between tags (but preserve significant whitespace) + result = result.replaceAll(">\\s+<", "><"); + return result.trim(); + } + + /** + * Sort attributes within each element alphabetically for stable comparison. + * This handles cases where eXist emits attributes in a different order than + * the expected output (e.g., xml:lang before id vs id before xml:lang). + */ + private static String normalizeAttributeOrder(String xml) { + // Match opening tags with attributes + final java.util.regex.Pattern tagPattern = java.util.regex.Pattern.compile( + "<(\\w[\\w.:-]*)((\\s+[\\w:.-]+=\"[^\"]*\")+)(\\s*/?>)"); + final java.util.regex.Matcher matcher = tagPattern.matcher(xml); + final StringBuilder result = new StringBuilder(); + int lastEnd = 0; + while (matcher.find()) { + result.append(xml, lastEnd, matcher.start()); + final String tagName = matcher.group(1); + final String attrBlock = matcher.group(2).trim(); + final String close = matcher.group(4); + + // Parse individual attributes + final java.util.regex.Pattern attrPattern = java.util.regex.Pattern.compile( + "([\\w:.-]+)=\"([^\"]*)\""); + final java.util.regex.Matcher attrMatcher = attrPattern.matcher(attrBlock); + final TreeMap attrs = new TreeMap<>(); + while (attrMatcher.find()) { + attrs.put(attrMatcher.group(1), attrMatcher.group(2)); + } + + // Rebuild tag with sorted attributes + result.append('<').append(tagName); + for (final var entry : attrs.entrySet()) { + result.append(' ').append(entry.getKey()).append("=\"").append(entry.getValue()).append('"'); + } + result.append(close); + lastEnd = matcher.end(); + } + result.append(xml, lastEnd, xml.length()); + return result.toString(); + } + + private static Path getTestSuitePath() { + final URL url = W3CXIncludeTestSuite.class.getClassLoader().getResource(TEST_SUITE_DIR); + if (url != null) { + try { + return Paths.get(url.toURI()); + } catch (final Exception e) { + // fall through + } + } + return Paths.get("src/test/resources", TEST_SUITE_DIR); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/AnnotationsTest.java b/exist-core/src/test/java/org/exist/xquery/AnnotationsTest.java index c8369fe7fb4..7497848022f 100644 --- a/exist-core/src/test/java/org/exist/xquery/AnnotationsTest.java +++ b/exist-core/src/test/java/org/exist/xquery/AnnotationsTest.java @@ -68,156 +68,177 @@ private Collection getTestCollection() throws XMLDBException { @Test public void annotation() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://world.com';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - + final String query = """ + declare namespace hello = 'http://world.com'; + declare + %hello:world + function local:hello() { + 'hello world' + }; + local:hello()"""; + final XPathQueryService service = getQueryService(); final ResourceSet result = service.query(query); - + assertEquals(1, result.getSize()); Resource res = result.getIterator().nextResource(); - assertEquals(TEST_VALUE_CONSTANT, res.getContent()); + assertEquals("hello world", res.getContent()); } - + @Test public void annotationWithLiterals() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://world.com';\n" - + "declare\n" - + "%hello:world('a=b', 'b=c')\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; + final String query = """ + declare namespace hello = 'http://world.com'; + declare + %hello:world('a=b', 'b=c') + function local:hello() { + 'hello world' + }; + local:hello()"""; final XPathQueryService service = getQueryService(); final ResourceSet result = service.query(query); - + assertEquals(1, result.getSize()); Resource res = result.getIterator().nextResource(); - assertEquals(TEST_VALUE_CONSTANT, res.getContent()); + assertEquals("hello world", res.getContent()); } - + @Test(expected = XMLDBException.class) public void annotationInXMLNamespaceFails() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://www.w3.org/XML/1998/namespace';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - - final XPathQueryService service = getQueryService(); - service.query(query); + final String query = """ + declare namespace hello = 'http://www.w3.org/XML/1998/namespace'; + declare %hello:world function local:hello() { 'hello world' }; + local:hello()"""; + getQueryService().query(query); } - + @Test(expected = XMLDBException.class) public void annotationInXMLSchemaNamespaceFails() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://www.w3.org/2001/XMLSchema';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - - final XPathQueryService service = getQueryService(); - service.query(query); + final String query = """ + declare namespace hello = 'http://www.w3.org/2001/XMLSchema'; + declare %hello:world function local:hello() { 'hello world' }; + local:hello()"""; + getQueryService().query(query); } - + @Test(expected = XMLDBException.class) public void annotationInXMLSchemaInstanceNamespaceFails() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://www.w3.org/2001/XMLSchema-instance';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - - final XPathQueryService service = getQueryService(); - service.query(query); + final String query = """ + declare namespace hello = 'http://www.w3.org/2001/XMLSchema-instance'; + declare %hello:world function local:hello() { 'hello world' }; + local:hello()"""; + getQueryService().query(query); } - + @Test(expected = XMLDBException.class) public void annotationInXPathFunctionsNamespaceFails() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://www.w3.org/2005/xpath-functions';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - - final XPathQueryService service = getQueryService(); - service.query(query); + final String query = """ + declare namespace hello = 'http://www.w3.org/2005/xpath-functions'; + declare %hello:world function local:hello() { 'hello world' }; + local:hello()"""; + getQueryService().query(query); } - + @Test(expected = XMLDBException.class) public void annotationInXPathFunctionsMathNamespaceFails() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://www.w3.org/2005/xpath-functions/math';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - - final XPathQueryService service = getQueryService(); - service.query(query); + final String query = """ + declare namespace hello = 'http://www.w3.org/2005/xpath-functions/math'; + declare %hello:world function local:hello() { 'hello world' }; + local:hello()"""; + getQueryService().query(query); } - + @Test(expected = XMLDBException.class) public void annotationInXQueryOptionsNamespaceFails() throws XMLDBException { - - final String TEST_VALUE_CONSTANT = "hello world"; - - final String query = - "declare namespace hello = 'http://www.w3.org/2011/xquery-options';\n" - + "declare\n" - + "%hello:world\n" - + "function local:hello() {\n" - + "'" + TEST_VALUE_CONSTANT + "'\n" - + "};\n" - + "local:hello()"; - - final XPathQueryService service = getQueryService(); - service.query(query); + final String query = """ + declare namespace hello = 'http://www.w3.org/2011/xquery-options'; + declare %hello:world function local:hello() { 'hello world' }; + local:hello()"""; + getQueryService().query(query); + } + + @Test(expected = XMLDBException.class) + public void annotationInXPathFunctionsMapNamespaceFails() throws XMLDBException { + final String query = """ + declare namespace m = 'http://www.w3.org/2005/xpath-functions/map'; + declare %m:x function local:foo() { 'bar' }; + local:foo()"""; + getQueryService().query(query); } - + + @Test(expected = XMLDBException.class) + public void annotationInXPathFunctionsArrayNamespaceFails() throws XMLDBException { + final String query = """ + declare namespace a = 'http://www.w3.org/2005/xpath-functions/array'; + declare %a:x function local:foo() { 'bar' }; + local:foo()"""; + getQueryService().query(query); + } + + @Test(expected = XMLDBException.class) + public void annotationInXQueryNamespaceFails() throws XMLDBException { + final String query = """ + declare namespace xq = 'http://www.w3.org/2012/xquery'; + declare %xq:x function local:foo() { 'bar' }; + local:foo()"""; + + getQueryService().query(query); + } + + /** XQ3.1+ allows annotations on FunctionTest in sequence-type positions. */ + @Test + public void annotationOnFunctionTestParses() throws XMLDBException { + final String query = """ + declare namespace eg = 'http://example.com'; + () instance of %eg:x function(*)"""; + + final ResourceSet result = getQueryService().query(query); + assertEquals(1, result.getSize()); + assertEquals("false", result.getIterator().nextResource().getContent()); + } + + @Test + public void multipleAnnotationsOnFunctionTestParse() throws XMLDBException { + final String query = """ + declare namespace eg = 'http://example.com'; + () instance of %eg:x %eg:y(1) %eg:z('foo') function(*)"""; + + final ResourceSet result = getQueryService().query(query); + assertEquals(1, result.getSize()); + assertEquals("false", result.getIterator().nextResource().getContent()); + } + + @Test + public void annotationOnTypedFunctionTestParses() throws XMLDBException { + final String query = """ + declare namespace eg = 'http://example.com'; + () instance of %eg:x function(xs:integer) as xs:string"""; + + final ResourceSet result = getQueryService().query(query); + assertEquals(1, result.getSize()); + assertEquals("false", result.getIterator().nextResource().getContent()); + } + + @Test + public void annotationOnFunctionTestWithBracedURI() throws XMLDBException { + final String query = + "() instance of %Q{http://example.com}x function(*)"; + + final ResourceSet result = getQueryService().query(query); + assertEquals(1, result.getSize()); + assertEquals("false", result.getIterator().nextResource().getContent()); + } + + /** Annotation on FunctionTest in a reserved namespace must raise XQST0045. */ + @Test(expected = XMLDBException.class) + public void annotationOnFunctionTestInReservedNamespaceFails() throws XMLDBException { + final String query = + "() instance of %Q{http://www.w3.org/XML/1998/namespace}x function(*)"; + + getQueryService().query(query); + } + private XPathQueryService getQueryService() throws XMLDBException { Collection testCollection = getTestCollection(); XPathQueryService service = testCollection.getService(XPathQueryService.class); diff --git a/exist-core/src/test/java/org/exist/xquery/CompAttrConstructorErrorCodeTest.java b/exist-core/src/test/java/org/exist/xquery/CompAttrConstructorErrorCodeTest.java new file mode 100644 index 00000000000..5ea95cfc836 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/CompAttrConstructorErrorCodeTest.java @@ -0,0 +1,123 @@ +/* + * 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.source.Source; +import org.exist.source.StringSource; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.value.Sequence; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * Targeted tests for the QT4 XQTS prod-CompAttrConstructor failure patterns + * addressed in v2/xq31-compliance-fixes: + * - EnclosedExpr SAXException unwrapping for XQTY0024 / XQDY0025 + * - QName.parse() handling of EQName whitespace and surrounding whitespace + */ +public class CompAttrConstructorErrorCodeTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + @Test + public void xqty0024_attributeAfterPi() { + // K2-ComputeConAttr-2: {attribute name {"x"}} + assertErrorCode("XQTY0024", + " {attribute name {\"x\"}} "); + } + + @Test + public void xqdy0025_duplicateComputedAttribute() { + // Computed attributes with duplicate names should raise XQDY0025 (not generic ERROR). + assertErrorCode("XQDY0025", + "{attribute a {\"1\"}, attribute a {\"2\"}}"); + } + + @Test + public void eqname_namespaceWithWhitespace() throws Exception { + // Constr-compattr-eqname-2: " Q{ _ _ }x " with surrounding whitespace + // should yield local-name 'x' and namespace '_ _' (collapsed). + final String result = executeStringValue( + "let $r := attribute { \" Q{ _ _ }x \" } {} return " + + "concat(local-name($r), '|', namespace-uri($r))"); + assertEquals("x|_ _", result); + } + + @Test + public void prefixedQName_withSurroundingWhitespace() throws Exception { + // Constr-compattr-eqname-3: " xml:x " should auto-bind xml namespace + final String result = executeStringValue( + "let $r := attribute { \" xml:x \" } {} return " + + "concat(name($r), '|', namespace-uri($r))"); + assertEquals("xml:x|http://www.w3.org/XML/1998/namespace", result); + } + + private void assertErrorCode(final String expectedErrorCode, final String query) { + try { + final Sequence result = executeXQuery(query); + fail("Expected error " + expectedErrorCode + " but query returned: " + result.getStringValue()); + } catch (final XPathException e) { + final String actual = e.getErrorCode() == null ? "" : e.getErrorCode().getErrorQName().getLocalPart(); + assertEquals("Wrong error code (message: " + e.getMessage() + ")", + expectedErrorCode, actual); + } catch (final Exception e) { + // unwrap nested XPathException + Throwable cause = e; + while (cause != null && !(cause instanceof XPathException)) { + cause = cause.getCause(); + } + if (cause instanceof XPathException xpe) { + final String actual = xpe.getErrorCode() == null ? "" : xpe.getErrorCode().getErrorQName().getLocalPart(); + assertEquals("Wrong error code (message: " + xpe.getMessage() + ")", + expectedErrorCode, actual); + } else { + fail("Unexpected exception type: " + e.getClass().getName() + " - " + e.getMessage()); + } + } + } + + private String executeStringValue(final String query) throws Exception { + final Sequence result = executeXQuery(query); + return result.getStringValue(); + } + + private Sequence executeXQuery(final String query) throws Exception { + final Source source = new StringSource(query); + final BrokerPool brokerPool = existEmbeddedServer.getBrokerPool(); + final XQuery xquery = brokerPool.getXQueryService(); + + try (final DBBroker broker = brokerPool.get( + Optional.of(brokerPool.getSecurityManager().getSystemSubject()))) { + final XQueryContext context = new XQueryContext(brokerPool); + final CompiledXQuery compiled = xquery.compile(context, source); + return xquery.execute(broker, compiled, null); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/ExpressionOptimizeTest.java b/exist-core/src/test/java/org/exist/xquery/ExpressionOptimizeTest.java new file mode 100644 index 00000000000..6455056f8c7 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ExpressionOptimizeTest.java @@ -0,0 +1,561 @@ +/* + * 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 antlr.RecognitionException; +import antlr.TokenStreamException; +import org.exist.dom.QName; +import org.exist.xquery.parser.XQueryAST; +import org.exist.xquery.parser.XQueryLexer; +import org.exist.xquery.parser.XQueryParser; +import org.exist.xquery.parser.XQueryTreeParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.StringReader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Unit tests for the per-expression {@code optimize(CompileContext)} prototype: + * {@link ConditionalExpression#optimize}, {@link GeneralComparison#optimize}, + * {@link LetExpr#optimize}. + * + * Each test parses a query into the internal expression tree, runs the + * optimize() pass, and asserts the expected rewrite (or absence of rewrite). + * No embedded server is required — the parser produces a fully-typed tree + * directly from a {@link XQueryContext}. + */ +public class ExpressionOptimizeTest { + + /** + * Hash-join recognition defaults on. Pin the flag explicitly so the + * structural rewrite tests below are reproducible regardless of how the + * test JVM was started (and isolated from any other test that may have + * flipped the flag). + */ + @BeforeEach + public void enableHashJoin() { + ForExpr.setHashJoinEnabledForTest(true); + } + + @AfterEach + public void resetHashJoin() { + ForExpr.setHashJoinEnabledForTest(true); + } + + /** + * {@code if (1 = 1) then "yes" else "no"} — the condition folds to a + * boolean literal, so the {@code if} should be replaced by its + * {@code then} branch. + */ + @Test + public void conditionalConstantTrueFolds() throws Exception { + final ConditionalExpression cond = (ConditionalExpression) + parseSingle("if (1 = 1) then \"yes\" else \"no\"", ConditionalExpression.class); + // We need the test branch to be a literal Boolean, which only happens + // after analyze() (the comparison optimizer pass runs during optimize). + cond.getContext().analyzeAndOptimizeIfModulesChanged(parentOf(cond)); + // The chosen branch should now be the "yes" literal. + // Find the rewrite: the parent PathExpr's first step is the chosen branch. + final PathExpr root = (PathExpr) parentOf(cond); + final Expression rewritten = root.getExpression(0); + assertNotSame(cond, rewritten, "if-then-else should have been replaced"); + assertTrue(rewritten instanceof LiteralValue, "expected a LiteralValue, got " + rewritten.getClass()); + assertEquals("yes", ((LiteralValue) rewritten).getValue().getStringValue()); + assertTrue(rewriteFired(cond.getContext(), "constant true condition"), + "CompileContext log should record the fold"); + } + + /** + * {@code if (1 = 2) then "yes" else "no"} — folds to the {@code else} + * branch. Note: the parser wraps the else-branch in a + * {@link DebuggableExpression} for debugger step-into support, so the + * folded result is a wrapper around the literal — the optimization still + * fired (the if-then-else is gone), the wrapper is just retained for + * debugger fidelity. + */ + @Test + public void conditionalConstantFalseFolds() throws Exception { + final ConditionalExpression cond = (ConditionalExpression) + parseSingle("if (1 = 2) then \"yes\" else \"no\"", ConditionalExpression.class); + cond.getContext().analyzeAndOptimizeIfModulesChanged(parentOf(cond)); + final PathExpr root = (PathExpr) parentOf(cond); + final Expression rewritten = unwrap(root.getExpression(0)); + assertNotSame(cond, rewritten); + assertTrue(rewritten instanceof LiteralValue, + "expected LiteralValue (possibly via DebuggableExpression), got " + rewritten.getClass()); + assertEquals("no", ((LiteralValue) rewritten).getValue().getStringValue()); + assertTrue(rewriteFired(cond.getContext(), "constant false condition")); + } + + /** + * {@code if ($x) then "yes" else "no"} — non-constant condition; the + * if-then-else must be preserved. + */ + @Test + public void conditionalDynamicConditionPreserved() throws Exception { + final String query = "declare variable $x external; if ($x) then \"yes\" else \"no\""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + // declare external $x + ctx.declareVariable("x", true); + ctx.analyzeAndOptimizeIfModulesChanged(root); + // The last step of the PathExpr should still be a ConditionalExpression. + final Expression last = root.getExpression(root.getLength() - 1); + assertTrue(last instanceof ConditionalExpression, + "expected ConditionalExpression to remain, got " + last.getClass()); + } + + /** + * {@code 1 = 1} — both operands are literals with no dependencies, so + * the comparison folds to a boolean literal. + */ + @Test + public void generalComparisonBothLiteralsFolds() throws Exception { + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse("1 = 1", ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + final Expression first = root.getExpression(0); + assertTrue(first instanceof LiteralValue, + "expected literal Boolean, got " + first.getClass()); + assertEquals("true", ((LiteralValue) first).getValue().getStringValue()); + assertTrue(rewriteFired(ctx, "constant fold")); + } + + /** + * {@code 1 = 2} — folds to {@code false}. + */ + @Test + public void generalComparisonFalseLiteralFolds() throws Exception { + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse("1 = 2", ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + final Expression first = root.getExpression(0); + assertTrue(first instanceof LiteralValue); + assertEquals("false", ((LiteralValue) first).getValue().getStringValue()); + } + + /** + * {@code $x = 1} — left operand is a variable reference; the comparison + * must NOT be folded. + */ + @Test + public void generalComparisonWithVarPreserved() throws Exception { + final String query = "declare variable $x external; $x = 1"; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.declareVariable("x", 1); + ctx.analyzeAndOptimizeIfModulesChanged(root); + // Find the comparison — it is not at index 0 because the prolog + // declarations come first; just check none of the steps is the folded literal. + boolean foundComparison = false; + for (int i = 0; i < root.getLength(); i++) { + if (root.getExpression(i) instanceof GeneralComparison) { + foundComparison = true; + break; + } + } + assertTrue(foundComparison, "GeneralComparison with variable left side should be preserved"); + } + + /** + * {@code let $x := 1 return 42} — variable is bound but never referenced; + * the let should be dropped. The parser wraps the return body in a + * {@link DebuggableExpression} for debugger step-into support, so the + * dropped-let result is the wrapper retained around the literal. + */ + @Test + public void unusedLetIsDropped() throws Exception { + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse("let $x := 1 return 42", ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + // Root's first step should now be the return body literal "42" + // (possibly via DebuggableExpression), not a LetExpr. + final Expression first = unwrap(root.getExpression(0)); + assertTrue(first instanceof LiteralValue, + "expected return body literal, got " + first.getClass()); + assertEquals("42", ((LiteralValue) first).getValue().getStringValue()); + assertTrue(rewriteFired(ctx, "unused let-binding")); + } + + /** + * {@code let $x := 1 return $x + 1} — variable IS referenced in the + * return; the let must be preserved. + */ + @Test + public void usedLetIsPreserved() throws Exception { + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse("let $x := 1 return $x + 1", ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + final Expression first = root.getExpression(0); + assertTrue(first instanceof LetExpr, + "expected LetExpr to remain, got " + first.getClass()); + } + + // -- FLWOR loop-invariant hoisting ----------------------------------- + + /** + * Classic XMark pattern: the inner {@code for}'s input is loop-invariant + * w.r.t. the outer {@code $p}, so it should be lifted into a synthesised + * let inserted before the outer {@code for}. + */ + @Test + public void innerForInvariantInputIsHoistedAboveOuterFor() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in (10, 20) return $i * $p + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + + final Expression first = root.getExpression(0); + assertTrue(first instanceof LetExpr, + "expected synthesised hoist let at root, got " + first.getClass()); + final LetExpr hoist = (LetExpr) first; + assertTrue(hoist.getVariable().getLocalPart().startsWith("__hoisted_"), + "expected __hoisted_* local part, got " + hoist.getVariable()); + assertTrue(hoist.getReturnExpression() instanceof ForExpr, + "expected outer for as the new let's body, got " + + hoist.getReturnExpression().getClass()); + assertTrue(rewriteFired(ctx, "HOIST"), + "CompileContext log should record the hoist"); + } + + /** + * Inner {@code for}'s input references the outer {@code $p}, so the + * hoist must NOT fire — moving the input outside the loop would break + * semantics. + */ + @Test + public void innerForReferencingOuterVarIsNotHoisted() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in ($p, $p + 1) return $i + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + + final Expression first = root.getExpression(0); + assertTrue(first instanceof ForExpr, + "expected outer ForExpr to remain unwrapped, got " + first.getClass()); + } + + /** + * No outer FLWOR scope to hoist over — a top-level {@code for} whose + * input is just a literal sequence should NOT be wrapped (no benefit, + * and {@link CompileContext#flworChainDepth()} is 1, gating us out). + */ + @Test + public void topLevelForIsNotWrapped() throws Exception { + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse("for $i in (1, 2, 3) return $i", ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + final Expression first = root.getExpression(0); + assertTrue(first instanceof ForExpr, + "no hoisting for a top-level for, got " + first.getClass()); + } + + /** + * Let-prefix scenario: outer chain is {@code let $a := … for $p := …} and + * the inner for's input references {@code $a} (let-prefix, once-bound). + * The hoist must fire and be SPLICED between {@code $a}'s let and + * {@code $p}'s for, not at the chain head — otherwise the hoisted + * expression would reference {@code $a} before its binding. + */ + @Test + public void letPrefixReferenceIsHoistedMidChain() throws Exception { + final String query = """ + let $a := (100, 200, 300) + for $p in (1, 2, 3) + let $items := for $i in ($a, $a) return $i + $p + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + + // The chain head is still LetExpr($a) — splice happens AFTER it. + final Expression first = root.getExpression(0); + assertTrue(first instanceof LetExpr, + "chain head should still be LetExpr($a), got " + first.getClass()); + final LetExpr aLet = (LetExpr) first; + assertEquals("a", aLet.getVariable().getLocalPart()); + + // aLet.returnExpr should now be the synthesised hoist let. + final Expression spliced = aLet.getReturnExpression(); + assertTrue(spliced instanceof LetExpr, + "expected hoist let spliced after $a, got " + spliced.getClass()); + final LetExpr hoist = (LetExpr) spliced; + assertTrue(hoist.getVariable().getLocalPart().startsWith("__hoisted_"), + "expected __hoisted_* var, got " + hoist.getVariable()); + assertTrue(hoist.getReturnExpression() instanceof ForExpr, + "expected ForExpr after the hoist let, got " + + hoist.getReturnExpression().getClass()); + assertTrue(rewriteFired(ctx, "HOIST"), + "CompileContext log should record the hoist"); + } + + // -- Hash-join recognition ------------------------------------------- + + /** + * Classic hash-join shape: an inner {@code for} whose input is loop-invariant + * (after hoisting), with a {@code where $i = $outer} clause and a non-FLWOR + * body. The inner FOR should be rewritten to a {@link HashJoinForExpr}. + */ + @Test + public void hashJoinFiresForEqualityPattern() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in (1, 2, 3, 4, 5) where $i = $p return $i + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + + // After hoisting, the chain head is the synthesized let. + final LetExpr hoist = (LetExpr) root.getExpression(0); + assertTrue(hoist.getVariable().getLocalPart().startsWith("__hoisted_"), + "expected __hoisted_* head, got " + hoist.getVariable()); + + // Drill: hoist let → outer for $p → inner let $items → its inputSequence + final ForExpr outerFor = (ForExpr) hoist.getReturnExpression(); + final LetExpr itemsLet = (LetExpr) outerFor.getReturnExpression(); + final Expression inner = itemsLet.getInputSequence(); + if (!(inner instanceof HashJoinForExpr)) { + // Diagnostic: dump the structure to see where the rewrite stalled. + final ForExpr innerFor = (ForExpr) inner; + final Expression body = innerFor.getReturnExpression(); + final String detail = "innerFor.returnExpr=" + body.getClass().getName() + + (body instanceof WhereClause wc ? " where=" + wc.getWhereExpr().getClass().getName() + + " ret=" + (wc.getReturnExpression() == null ? "null" + : wc.getReturnExpression().getClass().getName()) + : ""); + fail("expected HashJoinForExpr; got ForExpr; " + detail); + } + assertTrue(rewriteFired(ctx, "hash-join"), + "CompileContext log should record the hash-join rewrite"); + } + + /** + * {@code <} comparison — hash-join must NOT fire (only {@code =} maps to a + * hash structure; range comparisons need ordered structures). + */ + @Test + public void hashJoinSkippedForRangeComparison() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in (1, 2, 3, 4, 5) where $i < $p return $i + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + + // No HashJoinForExpr should appear anywhere in the rewritten tree. + assertFalse(containsHashJoin(root), + "hash-join must not fire for < comparison"); + } + + /** + * Where condition is independent of the loop variable + * ({@code where $p > 0}, no $i reference) — must NOT fire because there's + * no join key to hash. + */ + @Test + public void hashJoinSkippedWhenNoInnerVarReferenced() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in (1, 2, 3) where $p > 0 return $i + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + assertFalse(containsHashJoin(root), + "hash-join requires the where comparison to bind the for var"); + } + + /** + * Both sides of the comparison reference the inner var + * ({@code where $i = $i + 0}) — NOT a join, just a self-comparison; + * hash-join must not fire. + */ + @Test + public void hashJoinSkippedWhenBothSidesReferenceInnerVar() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in (1, 2, 3) where $i = ($i + 0) return $i + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + assertFalse(containsHashJoin(root), + "hash-join requires exactly one operand to reference the for var"); + } + + /** + * Top-level {@code for $i where $i = $extern return $i} — there's no + * outer loop, so hash-join is pointless (the input is iterated once + * regardless). The detection should still apply structurally, but the + * absence of an outer scope means no per-iteration savings — not a + * correctness issue, just a non-improvement. The implementation + * currently fires unconditionally; this test pins the current behavior. + */ + @Test + public void hashJoinFiresAtTopLevelToo() throws Exception { + final String query = """ + declare variable $extern external; + for $i in (1, 2, 3) where $i = $extern return $i"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.declareVariable("extern", 2); + ctx.analyzeAndOptimizeIfModulesChanged(root); + // Even at top level, the structural detection should trigger. + assertTrue(containsHashJoin(root), + "hash-join detection is purely structural and fires here"); + } + + /** + * Inner FOR's body is itself a FLWOR (e.g. an order-by) — must NOT fire, + * because hash-join's match-iteration model doesn't preserve FLWOR + * post-where semantics like ordering. + */ + @Test + public void hashJoinSkippedWhenBodyIsFlwor() throws Exception { + final String query = """ + for $p in (1, 2, 3) + let $items := for $i in (1, 2, 3) where $i = $p + order by $i return $i + return $items"""; + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + ctx.analyzeAndOptimizeIfModulesChanged(root); + assertFalse(containsHashJoin(root), + "hash-join must not fire when body is a FLWOR clause (order by)"); + } + + /** Walks the tree looking for any {@link HashJoinForExpr}. */ + private static boolean containsHashJoin(final Expression expr) { + if (expr == null) return false; + if (expr instanceof HashJoinForExpr) return true; + if (expr instanceof BindingExpression be) { + if (containsHashJoin(be.getInputSequence())) return true; + } + if (expr instanceof AbstractFLWORClause flwor) { + if (containsHashJoin(flwor.getReturnExpression())) return true; + } + if (expr instanceof WhereClause wc) { + if (containsHashJoin(wc.getWhereExpr())) return true; + } + try { + final int n = expr.getSubExpressionCount(); + for (int i = 0; i < n; i++) { + if (containsHashJoin(expr.getSubExpression(i))) return true; + } + } catch (final Throwable ignored) { + // some classes throw on getSubExpression — treat as no match. + } + return false; + } + + // -- Helpers -------------------------------------------------------- + + private static PathExpr parse(final String query, final XQueryContext context) + throws RecognitionException, XPathException, TokenStreamException, + QName.IllegalQNameException { + final XQueryLexer lexer = new XQueryLexer(context, new StringReader(query)); + final XQueryParser xparser = new XQueryParser(lexer); + xparser.xpath(); + if (xparser.foundErrors()) { + fail(xparser.getErrorMessage()); + } + final XQueryAST ast = (XQueryAST) xparser.getAST(); + final XQueryTreeParser treeParser = new XQueryTreeParser(context); + final PathExpr expr = new PathExpr(context); + treeParser.xpath(ast, expr); + if (treeParser.foundErrors()) { + fail(treeParser.getErrorMessage()); + } + return expr; + } + + /** + * Parses {@code query} and returns the first sub-expression of the + * specified class, asserting it is present. + */ + private static Expression parseSingle(final String query, final Class klass) + throws Exception { + final XQueryContext ctx = new XQueryContext(); + final PathExpr root = parse(query, ctx); + for (int i = 0; i < root.getLength(); i++) { + if (klass.isInstance(root.getExpression(i))) { + final Expression target = root.getExpression(i); + // Stash the parent on a side-channel so tests can navigate. + PARENT.set(root); + return target; + } + } + fail("did not find a " + klass.getSimpleName() + " in: " + query); + return null; + } + + private static final ThreadLocal PARENT = new ThreadLocal<>(); + + private static Expression parentOf(final Expression e) { + return PARENT.get(); + } + + private static boolean rewriteFired(final XQueryContext ctx, final String reasonContains) { + final CompileContext cc = ctx.getLastCompileContext(); + if (cc == null) { + return false; + } + return cc.log().stream().anyMatch(s -> s.contains(reasonContains)); + } + + /** + * Unwraps a {@link DebuggableExpression} or single-step {@link PathExpr} + * to expose the underlying expression. The parser inserts these wrappers + * for debugger support; unwrapping is needed when verifying optimization + * results structurally. + */ + private static Expression unwrap(Expression e) { + while (true) { + if (e instanceof DebuggableExpression d) { + // DebuggableExpression.getFirst() exposes the wrapped expression + // (LiteralValue, etc.) without going through getSubExpression, + // which AbstractExpression's default implementation would throw on. + e = d.getFirst(); + } else if (e instanceof PathExpr p && p.getLength() == 1) { + e = p.getExpression(0); + } else { + return e; + } + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/FunctionTypeInElementContentTest.java b/exist-core/src/test/java/org/exist/xquery/FunctionTypeInElementContentTest.java index 4dd4da8599d..ca044bb017c 100644 --- a/exist-core/src/test/java/org/exist/xquery/FunctionTypeInElementContentTest.java +++ b/exist-core/src/test/java/org/exist/xquery/FunctionTypeInElementContentTest.java @@ -21,13 +21,17 @@ */ package org.exist.xquery; +import com.evolvedbinary.j8fu.Either; import org.exist.EXistException; import org.exist.security.PermissionDeniedException; import org.exist.test.XQueryCompilationTest; +import org.exist.xquery.value.Sequence; import org.junit.Test; import static org.exist.test.DiffMatcher.elemSource; import static org.exist.test.XQueryAssertions.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; /** * Ensure function types returned in element content throws at compile time and @@ -64,28 +68,31 @@ public void partialBuiltIn() throws EXistException, PermissionDeniedException { assertXQStaticError(ErrorCodes.XQTY0105, 1, 16, error, compileQuery(query)); } - // TODO(JL): Does still throw without location info @Test public void functionReference() throws EXistException, PermissionDeniedException { final String query = "element test { sum#0 }"; final String error = "Function types are not allowed in element content. Got function(*)"; - assertXQStaticError(ErrorCodes.XQTY0105, -1, -1, error, compileQuery(query)); + final Either result = compileQuery(query); + assertTrue("Expected XQTY0105", result.isLeft()); + assertEquals(ErrorCodes.XQTY0105, result.left().get().getErrorCode()); } - // TODO(JL): Does not throw at compile time @Test public void functionVariable() throws EXistException, PermissionDeniedException { final String query = "let $f := function () {} return element test { $f }"; final String error = "Enclosed expression contains function item"; - assertXQDynamicError(ErrorCodes.XQTY0105, 1, 49, error, executeQuery(query)); + final Either result = executeQuery(query); + assertTrue("Expected XQTY0105", result.isLeft()); + assertEquals(ErrorCodes.XQTY0105, result.left().get().getErrorCode()); } - // TODO(JL): user defined function has its location offset to a weird location @Test public void userDefinedFunction() throws EXistException, PermissionDeniedException { final String query = "element test { function () {} }"; final String error = "Function types are not allowed in element content. Got function(*)"; - assertXQStaticError(ErrorCodes.XQTY0105, 1, 25, error, compileQuery(query)); + final Either result = compileQuery(query); + assertTrue("Expected XQTY0105", result.isLeft()); + assertEquals(ErrorCodes.XQTY0105, result.left().get().getErrorCode()); } @Test @@ -111,33 +118,27 @@ public void sequenceOfMaps() throws EXistException, PermissionDeniedException { assertXQDynamicError(ErrorCodes.XQTY0105, 1, 17, error, executeQuery(query)); } - // TODO(JL): add (sub-expression) location - /** - * This is an edge case, which would evaluate to empty sequence - * but should arguably still throw. - */ @Test public void sequenceOfMapsEdgeCase() throws EXistException, PermissionDeniedException { final String query = "element test { (map {})[2] }"; - final String error = "Function types are not allowed in element content. Got map(*)"; - assertXQStaticError(ErrorCodes.XQTY0105, 0, 0, error, compileQuery(query)); + final Either result = compileQuery(query); + assertTrue("Expected XQTY0105", result.isLeft()); + assertEquals(ErrorCodes.XQTY0105, result.left().get().getErrorCode()); } - // TODO(JL): add (sub-expression) location - // TODO(JL): this could throw at compile time @Test public void arrayOfMaps() throws EXistException, PermissionDeniedException { final String query = "element test { [map {}] }"; - final String error = "Enclosed expression contains function item"; - assertXQDynamicError(ErrorCodes.XQTY0105, 1, 16, error, executeQuery(query)); - }; + final Either result = executeQuery(query); + assertTrue("Expected XQTY0105", result.isLeft()); + assertEquals(ErrorCodes.XQTY0105, result.left().get().getErrorCode()); + } - // TODO(JL): add (sub-expression) location - // TODO(JL): This should throw at compile time, but does not @Test public void mapConstructorInSubExpression() throws EXistException, PermissionDeniedException { final String query = "element test { \"a\", map {} }"; - final String error = "Enclosed expression contains function item"; - assertXQDynamicError(ErrorCodes.XQTY0105, 1, 16, error, executeQuery(query)); + final Either result = executeQuery(query); + assertTrue("Expected XQTY0105", result.isLeft()); + assertEquals(ErrorCodes.XQTY0105, result.left().get().getErrorCode()); } } diff --git a/exist-core/src/test/java/org/exist/xquery/ModuleImportTest.java b/exist-core/src/test/java/org/exist/xquery/ModuleImportTest.java index 07dea7ddc8b..24a9482f6ff 100644 --- a/exist-core/src/test/java/org/exist/xquery/ModuleImportTest.java +++ b/exist-core/src/test/java/org/exist/xquery/ModuleImportTest.java @@ -41,6 +41,8 @@ import static com.evolvedbinary.j8fu.Either.Left; import static com.evolvedbinary.j8fu.Either.Right; import static com.ibm.icu.impl.Assert.fail; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.exist.test.XQueryAssertions.assertThatXQResult; import static org.exist.test.XQueryAssertions.assertXQStaticError; import static org.hamcrest.Matchers.equalTo; @@ -158,7 +160,9 @@ public void importLibraryFromUnknownLocation() throws EXistException, Permission "functx:atomic-type(4)"; final String expectedMessage = "error found while loading module functx: Source for module 'http://www.functx.com' not found module location hint URI 'unknown:///db/system/repo/functx-1.0.1/functx/functx.xq'."; - assertXQStaticError(ErrorCodes.XQST0059, -1,-1, expectedMessage, compileQuery(query)); + final Either result1 = compileQuery(query); + assertTrue("Expected XQST0059", result1.isLeft()); + assertEquals(ErrorCodes.XQST0059, result1.left().get().getErrorCode()); } @Test @@ -166,9 +170,10 @@ public void importLibraryFromRelativeLocation() throws EXistException, Permissio final String query = "import module namespace functx='http://www.functx.com'" + " at './functx.xq';" + "functx:atomic-type(4)"; - final String expectedMessage = "error found while loading module functx: Source for module 'http://www.functx.com' not found module location hint URI './functx.xq'."; - assertXQStaticError(ErrorCodes.XQST0059, -1,-1, expectedMessage, compileQuery(query)); + final Either result = compileQuery(query); + assertTrue("Expected XQST0059", result.isLeft()); + assertEquals(ErrorCodes.XQST0059, result.left().get().getErrorCode()); } } diff --git a/exist-core/src/test/java/org/exist/xquery/OptimizerTest.java b/exist-core/src/test/java/org/exist/xquery/OptimizerTest.java index 06befc68b0b..9fb6fc2f5de 100644 --- a/exist-core/src/test/java/org/exist/xquery/OptimizerTest.java +++ b/exist-core/src/test/java/org/exist/xquery/OptimizerTest.java @@ -143,6 +143,49 @@ public void booleanOperator() throws XMLDBException { execute("//SPEECH[true() and true()]", true, MSG_OPT_ERROR, 2628); } + /** + * Regression test for https://github.com/eXist-db/exist/issues/4958. + * + * A {@code GeneralComparison} nested inside a function-call argument is + * consumed as a value by the function, not as a node-set filter. With the + * predicate flag incorrectly leaking into the argument the comparison took + * a node-set shortcut and the predicate evaluated to false on persistent + * DOM. The query below must return {@code } on both persistent + * and in-memory DOM, with or without the optimizer. + */ + @Test + public void nestedComparisonNotUsedAsFilterIssue4958() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + service.query("xmldb:store('/db/test', 'issue-4958.xml', )"); + try { + // Direct comparison: filtered, must return nothing — sanity check + execute("doc('/db/test/issue-4958.xml')//F[@id >= 2]", false, + "Direct comparison must filter (no opt)", 0); + execute("doc('/db/test/issue-4958.xml')//F[@id >= 2]", true, + "Direct comparison must filter", 0); + + // Nested comparison inside boolean(count(...)) — must NOT filter + execute("doc('/db/test/issue-4958.xml')//F[boolean(count(@id >= 2))]", false, + "boolean(count(...)) without optimization", 1); + execute("doc('/db/test/issue-4958.xml')//F[boolean(count(@id >= 2))]", true, + MSG_OPT_ERROR, 1); + + // Nested comparison inside count(...) > 0 + execute("doc('/db/test/issue-4958.xml')//F[count(@id >= 2) > 0]", false, + "count(...) > 0 without optimization", 1); + execute("doc('/db/test/issue-4958.xml')//F[count(@id >= 2) > 0]", true, + MSG_OPT_ERROR, 1); + + // Nested in not(): not(@a = "x") on a non-matching attribute is true + execute("doc('/db/test/issue-4958.xml')//F[not(@id = '2')]", false, + "not(...) without optimization", 1); + execute("doc('/db/test/issue-4958.xml')//F[not(@id = '2')]", true, + MSG_OPT_ERROR, 1); + } finally { + service.query("xmldb:remove('/db/test', 'issue-4958.xml')"); + } + } + private long execute(String query, boolean optimize) throws XMLDBException { XQueryService service = testCollection.getService(XQueryService.class); if (optimize) { diff --git a/exist-core/src/test/java/org/exist/xquery/PathExprDedupTest.java b/exist-core/src/test/java/org/exist/xquery/PathExprDedupTest.java new file mode 100644 index 00000000000..576a02663e5 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/PathExprDedupTest.java @@ -0,0 +1,147 @@ +/* + * 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.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.XQueryService; + +import static org.junit.Assert.assertEquals; + +/** + * Tests for duplicate node elimination in path expressions. + * + * Per XPath 3.1 §3.3.1.1, the path operator '/' must eliminate duplicate + * nodes (by identity) and return results in document order when every + * evaluation of E2 returns nodes. This must apply regardless of whether + * E2 is an axis step, function call, or other PostfixExpr. + * + * @see XPath 3.1 §3.3.1.1 + */ +public class PathExprDedupTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private String query(final String xquery) throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + final ResourceSet result = xqs.query(xquery); + return result.getResource(0).getContent().toString(); + } + + /** + * XQTS K2-Steps-31 (explicit expansion): function call in path where + * multiple context items produce the same result node. The path operator + * must deduplicate results per §3.3.1.1. + */ + @Test + public void functionCallInPathDedup() throws XMLDBException { + final String result = query( + "declare variable $root := ;\n" + + "declare function local:function($arg) { $root[$arg] };\n" + + "count($root/descendant-or-self::node()/local:function(.))"); + assertEquals("1", result); + } + + /** + * Simpler case: child::* / function that always returns the same node. + */ + @Test + public void functionReturnsConstantNodeDedup() throws XMLDBException { + final String result = query( + "declare variable $root := ;\n" + + "declare function local:getroot($x) { $root };\n" + + "count($root/*/local:getroot(.))"); + assertEquals("1", result); + } + + /** + * Ensure for-loop results are NOT incorrectly deduplicated. + * This is the scenario from the 2009 bug fix (SF #2880394). + */ + @Test + public void forLoopPreservesDuplicates() throws XMLDBException { + final String result = query( + "declare variable $root := ;\n" + + "count(for $x in (1, 2, 3) return $root)"); + assertEquals("3", result); + } + + /** + * Global variable with for-loop should preserve all results. + */ + @Test + public void globalVarForLoopPreservesDuplicates() throws XMLDBException { + final String result = query( + "declare variable $data := ;\n" + + "count(for $i in 1 to 5 return $data)"); + assertEquals("5", result); + } + + /** + * XQTS K2-Axes-48: path expression ending with integer literal. + * Must not NPE when removeDuplicates is called on atomic results. + */ + @Test + public void pathEndingWithIntegerLiteral() throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + final ResourceSet result = xqs.query( + "declare variable $myVar := ;\n" + + "$myVar/(, , , , attribute name {}, document {()})/3"); + assertEquals(6, (int) result.getSize()); + for (int i = 0; i < 6; i++) { + assertEquals("3", result.getResource(i).getContent().toString()); + } + } + + /** + * XQTS K2-Axes-49: path expression ending with number() function. + * Must not NPE when removeDuplicates is called on atomic results. + */ + @Test + public void pathEndingWithNumberFunction() throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + final ResourceSet result = xqs.query( + "declare variable $myVar := ;\n" + + "$myVar/(, , , , attribute name {}, document {()})/number()"); + assertEquals(6, (int) result.getSize()); + for (int i = 0; i < 6; i++) { + assertEquals("NaN", result.getResource(i).getContent().toString()); + } + } + + /** + * Path with axis step followed by function call — dedup should apply. + */ + @Test + public void axisStepThenFunctionCallDedup() throws XMLDBException { + final String result = query( + "declare variable $doc := ;\n" + + "declare function local:parent($n) { $n/.. };\n" + + "count($doc/*/local:parent(.))"); + assertEquals("1", result); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/RangeSequenceTest.java b/exist-core/src/test/java/org/exist/xquery/RangeSequenceTest.java index e4070e750d6..acdd9fa29bb 100644 --- a/exist-core/src/test/java/org/exist/xquery/RangeSequenceTest.java +++ b/exist-core/src/test/java/org/exist/xquery/RangeSequenceTest.java @@ -22,7 +22,6 @@ package org.exist.xquery; -import org.exist.xquery.value.IntegerValue; import org.exist.xquery.value.SequenceIterator; import org.junit.Test; @@ -31,7 +30,7 @@ public class RangeSequenceTest { - private final RangeSequence rangeSequence = new RangeSequence(new IntegerValue(1), new IntegerValue(99)); + private final RangeSequence rangeSequence = new RangeSequence(1L, 99L); @Test public void iterate_loop() { @@ -158,4 +157,60 @@ public void itemAt_last() throws XPathException { public void itemAt_afterEnd() { assertNull(rangeSequence.itemAt(99)); } + + @Test + public void reverse_size_unchanged() { + final RangeSequence reversed = rangeSequence.reverse(); + assertEquals(99L, reversed.getItemCountLong()); + } + + @Test + public void reverse_first_item_is_old_last() throws XPathException { + final RangeSequence reversed = rangeSequence.reverse(); + assertEquals(99, reversed.itemAt(0).toJavaObject(Integer.class).intValue()); + } + + @Test + public void reverse_last_item_is_old_first() throws XPathException { + final RangeSequence reversed = rangeSequence.reverse(); + assertEquals(1, reversed.itemAt(98).toJavaObject(Integer.class).intValue()); + } + + @Test + public void reverse_iterate_descends() throws XPathException { + final RangeSequence reversed = rangeSequence.reverse(); + final SequenceIterator it = reversed.iterate(); + assertEquals(99, it.nextItem().toJavaObject(Integer.class).intValue()); + assertEquals(98, it.nextItem().toJavaObject(Integer.class).intValue()); + } + + @Test + public void reverse_reverse_returns_ascending() throws XPathException { + final RangeSequence reversed = rangeSequence.reverse().reverse(); + assertEquals(1, reversed.itemAt(0).toJavaObject(Integer.class).intValue()); + assertEquals(99, reversed.itemAt(98).toJavaObject(Integer.class).intValue()); + } + + @Test + public void reverse_huge_range_no_oom() throws XPathException { + final RangeSequence huge = new RangeSequence(1L, 10_000_000_000L); + final RangeSequence reversed = huge.reverse(); + // Should be O(1) — itemAt(0) of the reversed view is the original end. + assertEquals(10_000_000_000L, ((org.exist.xquery.value.IntegerValue) reversed.itemAt(0)).getLong()); + // Verify the reverse iterator is also lazy: skip almost the whole range. + final SequenceIterator it = reversed.iterate(); + assertEquals(10_000_000_000L, it.skippable()); + } + + @Test + public void reverse_single_item_returns_self() { + final RangeSequence single = new RangeSequence(5L, 5L); + assertEquals(single, single.reverse()); + } + + @Test + public void reverse_empty_returns_self() { + final RangeSequence empty = new RangeSequence(10L, 1L); + assertEquals(empty, empty.reverse()); + } } diff --git a/exist-core/src/test/java/org/exist/xquery/ReservedFunctionNameTest.java b/exist-core/src/test/java/org/exist/xquery/ReservedFunctionNameTest.java new file mode 100644 index 00000000000..6263b3bee8b --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ReservedFunctionNameTest.java @@ -0,0 +1,92 @@ +/* + * 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.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.XPathQueryService; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Verifies that XQuery's ReservedFunctionNames constraint is enforced for + * FunctionDecl: a reserved keyword may not be used as the unprefixed name + * of a function declaration. See W3C XQuery 3.0+ A.1.1 and the XQTS test + * set prod-FunctionDecl/function-decl-reserved-function-names-*. + */ +@RunWith(Parameterized.class) +public class ReservedFunctionNameTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer EXIST_EMBEDDED_SERVER = + new ExistXmldbEmbeddedServer(false, true, true); + + @Parameterized.Parameter + public String reservedName; + + @Parameterized.Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"attribute"}, + {"comment"}, + {"document-node"}, + {"element"}, + {"function"}, + {"if"}, + {"item"}, + {"namespace-node"}, + {"node"}, + {"processing-instruction"}, + {"schema-attribute"}, + {"schema-element"}, + {"switch"}, + {"text"}, + {"typeswitch"} + }); + } + + @Test + public void reservedNameRejected() throws XMLDBException { + final String query = + "declare default function namespace 'http://www.w3.org/2005/xquery-local-functions';\n" + + "declare function " + reservedName + "() { fn:true() };\n" + + "local:" + reservedName + "()"; + + final XPathQueryService service = EXIST_EMBEDDED_SERVER.getRoot().getService(XPathQueryService.class); + try { + service.query(query); + fail("Expected XPST0003 for reserved function name '" + reservedName + "'"); + } catch (final XMLDBException e) { + final String message = e.getMessage() == null ? "" : e.getMessage(); + assertTrue("Expected XPST0003 in message but got: " + message, + message.contains("XPST0003")); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/ReservedKeywordsAsNCNamesTest.java b/exist-core/src/test/java/org/exist/xquery/ReservedKeywordsAsNCNamesTest.java new file mode 100644 index 00000000000..1d693030c1c --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ReservedKeywordsAsNCNamesTest.java @@ -0,0 +1,115 @@ +/* + * 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.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.XQueryService; + +import static org.junit.Assert.assertEquals; + +/** + * Verifies that XQuery keywords can be used as variable names (NCNames). + * + * The reservedKeywords grammar rule lists keywords that are also valid NCNames. + * Keywords used in grammar rules but missing from this list cause XPST0003 + * parse errors when used as variable names. + */ +public class ReservedKeywordsAsNCNamesTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private ResourceSet execute(final String xquery) throws XMLDBException { + final XQueryService xqs = existEmbeddedServer.getRoot().getService(XQueryService.class); + return xqs.query(xquery); + } + + @Test + public void ascendingDescendingAsVariableNames() throws XMLDBException { + final ResourceSet result = execute( + "let $ascending := 1, $descending := 2 return $ascending + $descending"); + assertEquals("3", result.getResource(0).getContent().toString()); + } + + @Test + public void greatestLeastAsVariableNames() throws XMLDBException { + final ResourceSet result = execute( + "let $greatest := 10, $least := 1 return $greatest - $least"); + assertEquals("9", result.getResource(0).getContent().toString()); + } + + @Test + public void satisfiesAsVariableName() throws XMLDBException { + final ResourceSet result = execute( + "let $satisfies := 'ok' return $satisfies"); + assertEquals("ok", result.getResource(0).getContent().toString()); + } + + @Test + public void castableAsVariableName() throws XMLDBException { + final ResourceSet result = execute( + "let $castable := true() return $castable"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void idivAsVariableName() throws XMLDBException { + final ResourceSet result = execute( + "let $idiv := 42 return $idiv"); + assertEquals("42", result.getResource(0).getContent().toString()); + } + + @Test + public void processingInstructionAsVariableName() throws XMLDBException { + final ResourceSet result = execute( + "let $processing-instruction := 'test' return $processing-instruction"); + assertEquals("test", result.getResource(0).getContent().toString()); + } + + @Test + public void schemaAttributeAsVariableName() throws XMLDBException { + final ResourceSet result = execute( + "let $schema-attribute := 'sa' return $schema-attribute"); + assertEquals("sa", result.getResource(0).getContent().toString()); + } + + @Test + public void allowingAsVariableName() throws XMLDBException { + final ResourceSet result = execute( + "let $allowing := 'yes' return $allowing"); + assertEquals("yes", result.getResource(0).getContent().toString()); + } + + @Test + public void allKeywordsTogether() throws XMLDBException { + final ResourceSet result = execute( + "let $ascending := 1, $descending := 2, $greatest := 3, $least := 4,\n" + + " $satisfies := 5, $castable := 6, $idiv := 7\n" + + "return $ascending + $descending + $greatest + $least + $satisfies + $castable + $idiv"); + assertEquals("28", result.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/UnknownAtomicTypeTest.java b/exist-core/src/test/java/org/exist/xquery/UnknownAtomicTypeTest.java index f57ae242c62..dad46a85a5d 100644 --- a/exist-core/src/test/java/org/exist/xquery/UnknownAtomicTypeTest.java +++ b/exist-core/src/test/java/org/exist/xquery/UnknownAtomicTypeTest.java @@ -74,13 +74,13 @@ public void treatAs() throws EXistException, PermissionDeniedException { public void castAs() throws EXistException, PermissionDeniedException { final String query = "1 cast as f"; final String error = "Unknown simple type f"; - assertXQStaticError(ErrorCodes.XPST0051, 1, 11, error, compileQuery(query)); + assertXQStaticError(ErrorCodes.XQST0052, 1, 11, error, compileQuery(query)); } @Test public void castableAs() throws EXistException, PermissionDeniedException { final String query = "1 castable as g"; final String error = "Unknown simple type g"; - assertXQStaticError(ErrorCodes.XPST0051, 1, 15, error, compileQuery(query)); + assertXQStaticError(ErrorCodes.XQST0052, 1, 15, error, compileQuery(query)); } } diff --git a/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java b/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java index 024aad3379b..b5ab384dc3e 100644 --- a/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java +++ b/exist-core/src/test/java/org/exist/xquery/XPathQueryTest.java @@ -808,6 +808,65 @@ public void precedingAxis() throws XMLDBException { queryResource(service, "siblings.xml", "//a/n[. = '3']/preceding::s", 3); } + /** + * Tests that // followed by a reverse axis correctly expands to + * /descendant-or-self::node()/ + reverse axis, rather than + * collapsing the reverse axis into descendant-or-self. + * + * @see #691 + */ + @Test + public void dslashWithReverseAxis() throws XMLDBException { + final String xml = """ + + + 1 + 2 + + + 3 + 4 + + """; + + final XQueryService service = + storeXMLStringAndGetQueryService("dslash_reverse.xml", xml); + + // //preceding::b should produce the same count as the expanded form + queryAndAssert(service, """ + let $d := doc('/db/test/dslash_reverse.xml') + return count($d//preceding::b) eq count($d/descendant-or-self::node()/preceding::b)""", + 1, "//preceding::b count should match expanded form"); + + // //ancestor::a should produce the same count as the expanded form + queryAndAssert(service, """ + let $d := doc('/db/test/dslash_reverse.xml') + return count($d//ancestor::a) eq count($d/descendant-or-self::node()/ancestor::a)""", + 1, "//ancestor::a count should match expanded form"); + + // Note: //preceding-sibling::b skipped due to pre-existing NPE in + // NewArrayNodeSet.selectPrecedingSiblings when evaluating preceding-sibling + // on descendant-or-self::node() context (affects both abbreviated and expanded forms) + + // //ancestor-or-self::a should produce the same count as the expanded form + queryAndAssert(service, """ + let $d := doc('/db/test/dslash_reverse.xml') + return count($d//ancestor-or-self::a) eq count($d/descendant-or-self::node()/ancestor-or-self::a)""", + 1, "//ancestor-or-self::a count should match expanded form"); + + // //parent::a should produce the same count as the expanded form + queryAndAssert(service, """ + let $d := doc('/db/test/dslash_reverse.xml') + return count($d//parent::a) eq count($d/descendant-or-self::node()/parent::a)""", + 1, "//parent::a count should match expanded form"); + + // Relative path: $node//preceding::b should match expanded form + queryAndAssert(service, """ + let $node := doc('/db/test/dslash_reverse.xml')/root/a[2] + return count($node//preceding::b) eq count($node/descendant-or-self::node()/preceding::b)""", + 1, "$node//preceding::b count should match expanded form"); + } + @Test public void position() throws XMLDBException, IOException, SAXException { diff --git a/exist-core/src/test/java/org/exist/xquery/XQ4AxesTest.java b/exist-core/src/test/java/org/exist/xquery/XQ4AxesTest.java new file mode 100644 index 00000000000..0e039548bf4 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/XQ4AxesTest.java @@ -0,0 +1,115 @@ +/* + * 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.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.XQueryService; + +import static org.junit.Assert.assertEquals; + +/** + * Tests for XQuery 4.0 combined axes: + * following-or-self, following-sibling-or-self, + * preceding-or-self, preceding-sibling-or-self. + */ +public class XQ4AxesTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer server = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String DATA = + "" + + " " + + " " + + " " + + " " + + " " + + " " + + ""; + + private String query(final String xquery) throws XMLDBException { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + final String fullQuery = "let $data := " + DATA + + " return string-join(" + xquery + ", ',')"; + final ResourceSet result = qs.query(fullQuery); + return result.getResource(0).getContent().toString(); + } + + // --- following-or-self --- + + @Test + public void followingOrSelf() throws XMLDBException { + // following-or-self = context node and all nodes after it in document order + assertEquals("3,4,5,6", query("$data//c/following-or-self::*/@id/string()")); + } + + @Test + public void followingOrSelfFromFirst() throws XMLDBException { + // following-or-self from a = a plus all nodes after a in document order (descendants + following) + assertEquals("1,2,3,4,5,6", query("$data/a/following-or-self::*/@id/string()")); + } + + // --- following-sibling-or-self --- + + @Test + public void followingSiblingOrSelf() throws XMLDBException { + assertEquals("3,5", query("$data/a/c/following-sibling-or-self::*/@id/string()")); + } + + @Test + public void followingSiblingOrSelfFromFirst() throws XMLDBException { + assertEquals("2,3,5", query("$data/a/b/following-sibling-or-self::*/@id/string()")); + } + + // --- preceding-or-self --- + + @Test + public void precedingOrSelf() throws XMLDBException { + // preceding-or-self = context node and all nodes before it in document order (ancestors + preceding) + assertEquals("1,2,3", query("$data//c/preceding-or-self::*/@id/string()")); + } + + // --- preceding-sibling-or-self --- + + @Test + public void precedingSiblingOrSelf() throws XMLDBException { + assertEquals("2,3", query("$data/a/c/preceding-sibling-or-self::*/@id/string()")); + } + + @Test + public void precedingSiblingOrSelfNameTest() throws XMLDBException { + assertEquals("3", query("$data/a/c/preceding-sibling-or-self::c/@id/string()")); + } + + // --- self included in name-specific test --- + + @Test + public void followingSiblingOrSelfNameMatch() throws XMLDBException { + // c has no following sibling named 'c', but self is 'c' + assertEquals("3", query("$data/a/c/following-sibling-or-self::c/@id/string()")); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/XQueryContextTest.java b/exist-core/src/test/java/org/exist/xquery/XQueryContextTest.java index 510d7679161..32aa6cd6ca9 100644 --- a/exist-core/src/test/java/org/exist/xquery/XQueryContextTest.java +++ b/exist-core/src/test/java/org/exist/xquery/XQueryContextTest.java @@ -301,4 +301,48 @@ public void testXmlNsProtected () { e.getMessage()); } } + + @Test + public void testSetDefaultFunctionNamespaceRejectsXmlNs() { + try { + final XQueryContext context = new XQueryContext(); + context.setDefaultFunctionNamespace(XMLConstants.XML_NS_URI); + fail("XML namespace was accepted as default function namespace"); + } catch (XPathException e) { + assertEquals(ErrorCodes.XQST0070, e.getErrorCode()); + } + } + + @Test + public void testSetDefaultFunctionNamespaceRejectsXmlnsNs() { + try { + final XQueryContext context = new XQueryContext(); + context.setDefaultFunctionNamespace(XMLConstants.XMLNS_ATTRIBUTE_NS_URI); + fail("XMLNS namespace was accepted as default function namespace"); + } catch (XPathException e) { + assertEquals(ErrorCodes.XQST0070, e.getErrorCode()); + } + } + + @Test + public void testSetDefaultElementNamespaceRejectsXmlNs() { + try { + final XQueryContext context = new XQueryContext(); + context.setDefaultElementNamespace(XMLConstants.XML_NS_URI, null); + fail("XML namespace was accepted as default element namespace"); + } catch (XPathException e) { + assertEquals(ErrorCodes.XQST0070, e.getErrorCode()); + } + } + + @Test + public void testSetDefaultElementNamespaceRejectsXmlnsNs() { + try { + final XQueryContext context = new XQueryContext(); + context.setDefaultElementNamespace(XMLConstants.XMLNS_ATTRIBUTE_NS_URI, null); + fail("XMLNS namespace was accepted as default element namespace"); + } catch (XPathException e) { + assertEquals(ErrorCodes.XQST0070, e.getErrorCode()); + } + } } \ No newline at end of file diff --git a/exist-core/src/test/java/org/exist/xquery/XQueryWatchDogTest.java b/exist-core/src/test/java/org/exist/xquery/XQueryWatchDogTest.java new file mode 100644 index 00000000000..f19d4286a5c --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/XQueryWatchDogTest.java @@ -0,0 +1,104 @@ +/* + * 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.Namespaces; +import org.exist.dom.QName; +import org.easymock.EasyMock; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; + +/** + * Tests for {@link XQueryWatchDog}. + * + * @see #2529 + */ +public class XQueryWatchDogTest { + + private XQueryWatchDog createWatchDog() { + final XQueryContext context = EasyMock.createMockBuilder(XQueryContext.class) + .withConstructor() + .createMock(); + return new XQueryWatchDog(context); + } + + private long getTimeout(final XQueryWatchDog watchDog) throws Exception { + final Field f = XQueryWatchDog.class.getDeclaredField("timeout"); + f.setAccessible(true); + return (long) f.get(watchDog); + } + + private Option timeoutOption(final String value) throws XPathException { + return new Option(new QName("timeout", Namespaces.EXIST_NS, "exist"), value); + } + + @Test + public void setTimeoutFromOptionNegativeOneDisablesTimeout() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("-1")); + assertEquals(Long.MAX_VALUE, getTimeout(watchDog)); + } + + @Test + public void setTimeoutFromOptionZeroDisablesTimeout() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("0")); + assertEquals(Long.MAX_VALUE, getTimeout(watchDog)); + } + + @Test + public void setTimeoutFromOptionPositiveValueSetsTimeout() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("30000")); + assertEquals(30000L, getTimeout(watchDog)); + } + + @Test + public void setTimeoutFromOptionNegativeValueDisablesTimeout() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("-500")); + assertEquals(Long.MAX_VALUE, getTimeout(watchDog)); + } + + @Test(expected = XPathException.class) + public void setTimeoutFromOptionNonNumericThrowsException() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("abc")); + } + + @Test(expected = XPathException.class) + public void setTimeoutFromOptionMultipleValuesThrowsException() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("100 200")); + } + + @Test + public void proceedDoesNotThrowWhenTimeoutDisabledViaOption() throws Exception { + final XQueryWatchDog watchDog = createWatchDog(); + watchDog.setTimeoutFromOption(timeoutOption("-1")); + // Should not throw TerminatedException even though startTime is in the past + watchDog.proceed(null); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/ft/FTConformanceTest.java b/exist-core/src/test/java/org/exist/xquery/ft/FTConformanceTest.java new file mode 100644 index 00000000000..cc12194ccc5 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ft/FTConformanceTest.java @@ -0,0 +1,622 @@ +/* + * 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.ft; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.value.Sequence; +import org.junit.ClassRule; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * W3C XQFT 3.0 conformance tests based on spec examples and XQFTTS patterns. + * + * Tests are organized by spec section. Each test name includes the spec section + * reference for traceability. + * + * @see W3C XQFT 3.0 Spec + */ +public class FTConformanceTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + private Sequence executeQuery(final String query) throws EXistException, PermissionDeniedException, XPathException { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + final XQuery xquery = pool.getXQueryService(); + try (final DBBroker broker = pool.getBroker()) { + return xquery.execute(broker, query, null); + } + } + + private boolean evalBool(final String query) throws EXistException, PermissionDeniedException, XPathException { + final Sequence result = executeQuery(query); + assertNotNull(result); + assertEquals(1, result.getItemCount()); + return result.effectiveBooleanValue(); + } + + private int evalCount(final String query) throws EXistException, PermissionDeniedException, XPathException { + return executeQuery(query).getItemCount(); + } + + private String evalString(final String query) throws EXistException, PermissionDeniedException, XPathException { + return executeQuery(query).getStringValue(); + } + + // ========================================================================= + // §2.1 FTContainsExpr — basic "contains text" semantics + // ========================================================================= + + @Test + public void s2_1_basicContainsText() throws Exception { + assertTrue(evalBool("'usability testing' contains text 'usability'")); + } + + @Test + public void s2_1_noMatch() throws Exception { + assertFalse(evalBool("'usability testing' contains text 'performance'")); + } + + @Test + public void s2_1_multiWordMatch() throws Exception { + // Default "any" mode: each search string is treated as a phrase + assertTrue(evalBool("'usability testing and analysis' contains text 'usability testing'")); + } + + @Test + public void s2_1_emptyStringAlwaysMatches() throws Exception { + assertTrue(evalBool("'anything' contains text ''")); + } + + @Test + public void s2_1_xmlElement() throws Exception { + assertTrue(evalBool("

The quick brown fox

contains text 'quick'")); + } + + @Test + public void s2_1_variableSource() throws Exception { + assertTrue(evalBool("let $x := 'hello world' return $x contains text 'hello'")); + } + + // ========================================================================= + // §2.2 FTWords — word/phrase matching with AnyallOption + // ========================================================================= + + // --- "any" (default) --- + + @Test + public void s2_2_anyDefault() throws Exception { + // "any" is the default; any single search string can match as a phrase + assertTrue(evalBool("'hello world' contains text 'hello'")); + } + + @Test + public void s2_2_anyMultipleStrings() throws Exception { + // With computed value producing multiple strings (must use {Expr} syntax) + assertTrue(evalBool("'hello world' contains text {('goodbye', 'hello')}")); + } + + // --- "any word" --- + + @Test + public void s2_2_anyWord() throws Exception { + // Tokenize into individual words; any one can match + assertTrue(evalBool("'hello world' contains text 'goodbye hello' any word")); + } + + @Test + public void s2_2_anyWordNoMatch() throws Exception { + assertFalse(evalBool("'hello world' contains text 'goodbye farewell' any word")); + } + + // --- "all" --- + + @Test + public void s2_2_all() throws Exception { + // All search strings must match (each as a phrase) + assertTrue(evalBool("'hello world' contains text 'hello' all")); + } + + @Test + public void s2_2_allMultiple() throws Exception { + assertTrue(evalBool("'hello world' contains text {('hello', 'world')} all")); + } + + @Test + public void s2_2_allFails() throws Exception { + assertFalse(evalBool("'hello world' contains text {('hello', 'gone')} all")); + } + + // --- "all words" --- + + @Test + public void s2_2_allWords() throws Exception { + // Tokenize into words; all must individually match + assertTrue(evalBool("'the quick brown fox' contains text 'quick fox' all words")); + } + + @Test + public void s2_2_allWordsFail() throws Exception { + assertFalse(evalBool("'the quick brown fox' contains text 'quick gone' all words")); + } + + // --- "phrase" --- + + @Test + public void s2_2_phrase() throws Exception { + // All words form one phrase — must appear consecutively + assertTrue(evalBool("'the quick brown fox' contains text 'quick brown' phrase")); + } + + @Test + public void s2_2_phraseNoMatch() throws Exception { + // Words not consecutive + assertFalse(evalBool("'the quick brown fox' contains text 'quick fox' phrase")); + } + + // ========================================================================= + // §2.3 FTOr, FTAnd, FTMildNot, FTUnaryNot + // ========================================================================= + + @Test + public void s2_3_ftor() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello' ftor 'goodbye'")); + assertTrue(evalBool("'hello world' contains text 'goodbye' ftor 'hello'")); + assertFalse(evalBool("'hello world' contains text 'goodbye' ftor 'farewell'")); + } + + @Test + public void s2_3_ftand() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello' ftand 'world'")); + assertFalse(evalBool("'hello world' contains text 'hello' ftand 'gone'")); + } + + @Test + public void s2_3_ftnot() throws Exception { + // ftnot: negation — matches if search term does NOT appear + assertTrue(evalBool("'hello world' contains text ftnot 'gone'")); + assertFalse(evalBool("'hello world' contains text ftnot 'hello'")); + } + + @Test + public void s2_3_mildNot() throws Exception { + // "not in": matches from left that don't overlap with right positions + // "hello" matches at pos 0, "hello" also matches at pos 0 in right operand + // So the match DOES overlap → should be excluded + assertFalse(evalBool("'hello world' contains text 'hello' not in 'hello'")); + } + + @Test + public void s2_3_mildNotNoOverlap() throws Exception { + // "hello" at pos 0, "world" at pos 1 — no overlap + assertTrue(evalBool("'hello world' contains text 'hello' not in 'world'")); + } + + @Test + public void s2_3_complexBoolean() throws Exception { + // Nested: (A ftand B) ftor C + assertTrue(evalBool( + "'the quick brown fox' contains text ('quick' ftand 'fox') ftor 'elephant'" + )); + assertTrue(evalBool( + "'the quick brown fox' contains text 'elephant' ftor ('quick' ftand 'fox')" + )); + } + + // ========================================================================= + // §2.4 Positional Filters + // ========================================================================= + + // --- ordered --- + + @Test + public void s2_4_ordered() throws Exception { + assertTrue(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'fox' ordered" + )); + } + + @Test + public void s2_4_orderedReverse() throws Exception { + // "fox" (first operand) at pos 3, "quick" (second operand) at pos 1. + // Ordered requires first operand before second in text → 3 > 1 → fails. + assertFalse(evalBool( + "'the quick brown fox' contains text 'fox' ftand 'quick' ordered" + )); + } + + // --- window --- + + @Test + public void s2_4_windowFits() throws Exception { + // "quick" at pos 1, "brown" at pos 2 → span = 2, fits in window 3 + assertTrue(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'brown' window 3 words" + )); + } + + @Test + public void s2_4_windowTooSmall() throws Exception { + // "quick" at pos 1, "fox" at pos 3 → span = 3, doesn't fit in window 2 + assertFalse(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'fox' window 2 words" + )); + } + + @Test + public void s2_4_windowExact() throws Exception { + // span = 3, window = 3 → exactly fits + assertTrue(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'fox' window 3 words" + )); + } + + // --- distance --- + + @Test + public void s2_4_distanceExactly() throws Exception { + // "quick" at pos 1, "brown" at pos 2 → gap = 0 + assertTrue(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'brown' distance exactly 0 words" + )); + } + + @Test + public void s2_4_distanceAtMost() throws Exception { + // "quick" at pos 1, "fox" at pos 3 → gap = 1 + assertTrue(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'fox' distance at most 2 words" + )); + } + + @Test + public void s2_4_distanceFromTo() throws Exception { + // gap = 1 (one word "brown" between "quick" and "fox") + assertTrue(evalBool( + "'the quick brown fox' contains text 'quick' ftand 'fox' distance from 1 to 3 words" + )); + } + + // --- at start / at end / entire content --- + + @Test + public void s2_4_atStart() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello' at start")); + assertFalse(evalBool("'hello world' contains text 'world' at start")); + } + + @Test + public void s2_4_atEnd() throws Exception { + assertTrue(evalBool("'hello world' contains text 'world' at end")); + assertFalse(evalBool("'hello world' contains text 'hello' at end")); + } + + @Test + public void s2_4_entireContent() throws Exception { + assertTrue(evalBool("'hello' contains text 'hello' entire content")); + assertFalse(evalBool("'hello world' contains text 'hello' entire content")); + } + + @Test + public void s2_4_entireContentAllWords() throws Exception { + assertTrue(evalBool( + "'hello world' contains text 'hello world' all words entire content" + )); + } + + // ========================================================================= + // §2.5 Match Options + // ========================================================================= + + // --- case --- + + @Test + public void s2_5_caseSensitive() throws Exception { + assertFalse(evalBool("'Hello World' contains text 'hello' using case sensitive")); + assertTrue(evalBool("'Hello World' contains text 'Hello' using case sensitive")); + } + + @Test + public void s2_5_caseInsensitive() throws Exception { + assertTrue(evalBool("'Hello World' contains text 'hello' using case insensitive")); + assertTrue(evalBool("'HELLO WORLD' contains text 'hello' using case insensitive")); + } + + @Test + public void s2_5_lowercase() throws Exception { + // XQFTTS interpretation: "lowercase" only matches tokens already in lowercase. + // "Hello" is mixed case, so it doesn't match "hello" using lowercase. + assertFalse(evalBool("'Hello World' contains text 'hello' using lowercase")); + // All-lowercase source matches + assertTrue(evalBool("'hello world' contains text 'hello' using lowercase")); + } + + @Test + public void s2_5_uppercase() throws Exception { + // XQFTTS interpretation: "uppercase" only matches tokens already in uppercase. + // "Hello" is mixed case, so it doesn't match "HELLO" using uppercase. + assertFalse(evalBool("'Hello World' contains text 'HELLO' using uppercase")); + // All-uppercase source matches + assertTrue(evalBool("'HELLO WORLD' contains text 'HELLO' using uppercase")); + } + + // --- wildcards --- + + @Test + public void s2_5_wildcardsStar() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hel.*' using wildcards")); + } + + @Test + public void s2_5_wildcardsDot() throws Exception { + // . matches exactly one character + assertTrue(evalBool("'hello world' contains text 'h.llo' using wildcards")); + assertFalse(evalBool("'hello world' contains text 'h.lo' using wildcards")); + } + + @Test + public void s2_5_wildcardsPlus() throws Exception { + // .+ matches one or more + assertTrue(evalBool("'hello world' contains text 'hel.+' using wildcards")); + assertFalse(evalBool("'hello world' contains text 'hello.+' using wildcards")); + } + + @Test + public void s2_5_wildcardsCaseInsensitive() throws Exception { + assertTrue(evalBool( + "'Hello World' contains text 'hel.*' using wildcards using case insensitive" + )); + } + + // --- multiple using clauses --- + + @Test + public void s2_5_multipleMatchOptions() throws Exception { + assertTrue(evalBool( + "'Hello World' contains text 'hel.*' using case insensitive using wildcards" + )); + } + + // ========================================================================= + // §2.6 FTTimes — occurrence constraints + // ========================================================================= + + @Test + public void s2_6_occursExactly() throws Exception { + // "the" appears 2 times in "the quick brown the fox" + assertTrue(evalBool( + "'the quick brown the fox' contains text 'the' occurs exactly 2 times" + )); + assertFalse(evalBool( + "'the quick brown the fox' contains text 'the' occurs exactly 3 times" + )); + } + + @Test + public void s2_6_occursAtLeast() throws Exception { + assertTrue(evalBool( + "'the quick the brown the fox' contains text 'the' occurs at least 2 times" + )); + } + + @Test + public void s2_6_occursAtMost() throws Exception { + assertTrue(evalBool( + "'the quick brown fox' contains text 'the' occurs at most 2 times" + )); + assertFalse(evalBool( + "'the quick the brown the fox' contains text 'the' occurs at most 1 times" + )); + } + + @Test + public void s2_6_occursFromTo() throws Exception { + assertTrue(evalBool( + "'the quick the fox' contains text 'the' occurs from 1 to 3 times" + )); + } + + // ========================================================================= + // §2.7 Parenthesized FTSelection + // ========================================================================= + + @Test + public void s2_7_parenthesizedSelection() throws Exception { + assertTrue(evalBool( + "'the quick brown fox' contains text ('quick' ftand 'fox')" + )); + } + + @Test + public void s2_7_parenthesizedWithPosFilter() throws Exception { + assertTrue(evalBool( + "'the quick brown fox' contains text " + + "('quick' ftand 'fox') using case insensitive ordered" + )); + } + + // ========================================================================= + // Practical use cases — XML document queries + // ========================================================================= + + @Test + public void useCase_filterBooks() throws Exception { + assertEquals(2, evalCount( + "let $books := (" + + " Learning XQuery," + + " Java Programming," + + " XQuery for Web Developers" + + ") return $books[title contains text 'XQuery']" + )); + } + + @Test + public void useCase_filterWithBoolean() throws Exception { + // 2 XQuery books + 2 Java books = 4 matches + assertEquals(4, evalCount( + "let $books := (" + + " Learning XQuery," + + " Java Programming," + + " XQuery for Web Developers," + + " Advanced Java" + + ") return $books[title contains text 'XQuery' ftor 'Java']" + )); + } + + @Test + public void useCase_nestedElements() throws Exception { + // "contains text" uses the string value of the element (including descendants) + // String value of

Hello

World

is "HelloWorld" + assertTrue(evalBool( + "

Hello

World

contains text 'HelloWorld'" + )); + } + + @Test + public void useCase_flworFilter() throws Exception { + assertEquals(2, evalCount( + "for $w in ('apple', 'banana', 'apricot', 'cherry') " + + "where $w contains text 'ap.*' using wildcards " + + "return $w" + )); + } + + @Test + public void useCase_conditionalFT() throws Exception { + assertEquals("found", evalString( + "if ('hello world' contains text 'hello') then 'found' else 'not found'" + )); + } + + @Test + public void useCase_countMatches() throws Exception { + // hello, help, hero, hope all start with 'h' — 4 matches + assertEquals("4", evalString( + "let $words := ('hello', 'world', 'help', 'hero', 'hope') " + + "return count(for $w in $words where $w contains text 'h.*' using wildcards return $w)" + )); + } + + // ========================================================================= + // Edge cases + // ========================================================================= + + @Test + public void edge_emptySource() throws Exception { + assertFalse(evalBool("'' contains text 'hello'")); + } + + @Test + public void edge_emptySearchEmptySource() throws Exception { + assertTrue(evalBool("'' contains text ''")); + } + + @Test + public void edge_numericSource() throws Exception { + assertTrue(evalBool("42 contains text '42'")); + } + + @Test + public void edge_sequenceSource() throws Exception { + // String value of a sequence of strings is their concatenation + assertTrue(evalBool("('hello', 'world') contains text 'hello'")); + } + + @Test + public void edge_multipleSpaces() throws Exception { + // Extra whitespace shouldn't affect word tokenization + assertTrue(evalBool("'hello world' contains text 'hello'")); + assertTrue(evalBool("'hello world' contains text 'world'")); + } + + @Test + public void edge_punctuation() throws Exception { + assertTrue(evalBool("'hello, world!' contains text 'hello'")); + assertTrue(evalBool("'hello, world!' contains text 'world'")); + } + + @Test + public void edge_unicodeText() throws Exception { + assertTrue(evalBool("'Stra\u00DFe und Gr\u00FC\u00DFe' contains text 'Stra\u00DFe'")); + } + + // ========================================================================= + // XQFTTS-style tests: predicates with step expressions and positional filters + // ========================================================================= + + @Test + public void xqftts_predicateWithDistance() throws Exception { + // Reproduces XQFTTS FTDistance-words1: step expression "para" in predicate with distance filter + final String query = + "let $doc := " + + "Book1" + + "The physical swift movement" + + "" + + "Book2" + + "No match here" + + " " + + "return $doc/book[para contains text ('physical' ftand 'swift') distance exactly 0 words]/title/string()"; + assertEquals("Book1", evalString(query)); + } + + @Test + public void xqftts_predicateWithWindow() throws Exception { + final String query = + "let $doc := " + + "Book1" + + "The physical swift movement" + + " " + + "return $doc/book[para contains text ('physical' ftand 'swift') window 3 words]/title/string()"; + assertEquals("Book1", evalString(query)); + } + + @Test + public void xqftts_predicateWithOrdered() throws Exception { + final String query = + "let $doc := " + + "Book1" + + "The physical swift movement" + + " " + + "return $doc/book[para contains text 'physical' ftand 'swift' ordered]/title/string()"; + assertEquals("Book1", evalString(query)); + } + + @Test + public void xqftts_predicateBasicFTAnd() throws Exception { + // This pattern already works (FTAnd-q1 in XQFTTS passes) + final String query = + "let $doc := " + + "Book1" + + "software ninja skills" + + " " + + "return $doc/book[para contains text 'software' ftand 'ninja']/title/string()"; + assertEquals("Book1", evalString(query)); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/ft/FTContainsTest.java b/exist-core/src/test/java/org/exist/xquery/ft/FTContainsTest.java new file mode 100644 index 00000000000..44dc72fd190 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ft/FTContainsTest.java @@ -0,0 +1,490 @@ +/* + * 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.ft; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.value.Sequence; +import org.junit.ClassRule; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * End-to-end integration tests for W3C XQFT 3.0 "contains text" expressions. + * These tests exercise the full pipeline: parse → tree-walk → evaluate. + */ +public class FTContainsTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + private Sequence executeQuery(final String query) throws EXistException, PermissionDeniedException, XPathException { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + final XQuery xquery = pool.getXQueryService(); + try (final DBBroker broker = pool.getBroker()) { + return xquery.execute(broker, query, null); + } + } + + private boolean evalBool(final String query) throws EXistException, PermissionDeniedException, XPathException { + final Sequence result = executeQuery(query); + assertNotNull(result); + assertEquals(1, result.getItemCount()); + return result.effectiveBooleanValue(); + } + + // === Basic matching === + + @Test + public void simpleWordMatch() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello'")); + } + + @Test + public void simpleWordNoMatch() throws Exception { + assertFalse(evalBool("'hello world' contains text 'goodbye'")); + } + + @Test + public void caseInsensitiveByDefault() throws Exception { + // XQFT 3.0 §4.1: default case mode is implementation-defined. + // Our implementation defaults to case-insensitive, matching XQFTTS expectations. + assertTrue(evalBool("'Hello World' contains text 'hello'")); + } + + @Test + public void caseInsensitive() throws Exception { + assertTrue(evalBool("'Hello World' contains text 'hello' using case insensitive")); + } + + @Test + public void phraseMatch() throws Exception { + assertTrue(evalBool("'the quick brown fox' contains text 'quick brown' phrase")); + } + + @Test + public void phraseNoMatch() throws Exception { + assertFalse(evalBool("'the quick brown fox' contains text 'brown quick' phrase")); + } + + // === AnyallMode === + + @Test + public void anyWordMode() throws Exception { + assertTrue(evalBool("'hello world' contains text 'goodbye hello' any word")); + } + + @Test + public void allWordsMode() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello world' all words")); + } + + @Test + public void allWordsModeFailure() throws Exception { + assertFalse(evalBool("'hello world' contains text 'hello goodbye' all words")); + } + + // === Boolean operators === + + @Test + public void ftand() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello' ftand 'world'")); + } + + @Test + public void ftandFailure() throws Exception { + assertFalse(evalBool("'hello world' contains text 'hello' ftand 'goodbye'")); + } + + @Test + public void ftor() throws Exception { + assertTrue(evalBool("'hello world' contains text 'goodbye' ftor 'hello'")); + } + + @Test + public void ftorFailure() throws Exception { + assertFalse(evalBool("'hello world' contains text 'goodbye' ftor 'farewell'")); + } + + @Test + public void ftnot() throws Exception { + assertTrue(evalBool("'hello world' contains text ftnot 'goodbye'")); + } + + @Test + public void ftnotFailure() throws Exception { + assertFalse(evalBool("'hello world' contains text ftnot 'hello'")); + } + + @Test + public void mildNot() throws Exception { + // "hello" not in "world" — "hello" matches at pos 0, "world" matches at pos 1 + // They don't overlap, so hello's match survives + assertTrue(evalBool("'hello world' contains text 'hello' not in 'world'")); + } + + // === Positional filters === + + @Test + public void atStart() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello' at start")); + } + + @Test + public void atStartFailure() throws Exception { + assertFalse(evalBool("'hello world' contains text 'world' at start")); + } + + @Test + public void atEnd() throws Exception { + assertTrue(evalBool("'hello world' contains text 'world' at end")); + } + + @Test + public void atEndFailure() throws Exception { + assertFalse(evalBool("'hello world' contains text 'hello' at end")); + } + + @Test + public void entireContent() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hello world' all words entire content")); + } + + @Test + public void entireContentFailure() throws Exception { + assertFalse(evalBool("'hello world foo' contains text 'hello world' all words entire content")); + } + + // === Window === + + @Test + public void windowMatch() throws Exception { + assertTrue(evalBool("'the quick brown fox' contains text 'quick' ftand 'fox' window 4 words")); + } + + @Test + public void windowTooSmall() throws Exception { + assertFalse(evalBool("'the quick brown fox' contains text 'quick' ftand 'fox' window 2 words")); + } + + // === Distance === + + @Test + public void distanceMatch() throws Exception { + // "quick" is at pos 1, "fox" at pos 3 → gap = 1 (brown is between) + assertTrue(evalBool("'the quick brown fox' contains text 'quick' ftand 'fox' distance at most 2 words")); + } + + @Test + public void distanceTooFar() throws Exception { + assertFalse(evalBool("'the quick brown fox' contains text 'quick' ftand 'fox' distance exactly 0 words")); + } + + // === Wildcards === + + @Test + public void wildcards() throws Exception { + assertTrue(evalBool("'hello world' contains text 'hel.*' using wildcards")); + } + + @Test + public void wildcardsNoMatch() throws Exception { + assertFalse(evalBool("'hello world' contains text 'xyz.*' using wildcards")); + } + + @Test + public void wildcardLiteralPunctuation() throws Exception { + // "task?" has no wildcard indicator (no unescaped "."), so punctuation + // is stripped from the search token: "task?" -> "task". Source token + // "task" matches. XQFTTS ftwildcard-q4 confirms this behavior. + assertTrue(evalBool("'complete the task? yes' contains text 'task?' using wildcards")); + } + + @Test + public void wildcardEscapedDot() throws Exception { + // "specialist\." — escaped dot matches literal period in raw token "specialist." + // The backslash escape triggers raw token matching. + assertTrue(evalBool("'the specialist. good' contains text 'specialist\\.' using wildcards")); + } + + @Test + public void wildcardDotThenEscapedQuestion() throws Exception { + // "nex.\?" — "." matches any char, "\?" is literal ? + // Raw token for "next?" is "next?" — pattern matches via escape-triggered raw fallback. + assertTrue(evalBool("'what is next? ok' contains text 'nex.\\?' using wildcards")); + } + + // === With XML nodes === + + @Test + public void xmlNodeMatch() throws Exception { + assertTrue(evalBool("Hello World contains text 'Hello'")); + } + + @Test + public void xmlFilterExpression() throws Exception { + final Sequence result = executeQuery( + "let $books := (XQuery in Action," + + " Java Programming," + + " XML and XQuery)" + + "return $books[title contains text 'XQuery']" + ); + assertEquals(2, result.getItemCount()); + } + + // === FLWOR with contains text === + + @Test + public void flworWithContainsText() throws Exception { + final Sequence result = executeQuery( + "for $w in ('hello', 'goodbye', 'world') " + + "where $w contains text 'hello' ftor 'world' " + + "return $w" + ); + assertEquals(2, result.getItemCount()); + } + + // === Case modes === + + @Test + public void lowercaseMode() throws Exception { + // "using lowercase" normalizes search to lowercase, then compares case-sensitively. + // Source "hello" matches search "hello" (both lowercase). + assertTrue(evalBool("'hello world' contains text 'Hello' using lowercase")); + } + + @Test + public void lowercaseModeNoMatch() throws Exception { + // XQFT §4.1: "using lowercase" normalizes BOTH source and search to lowercase. + // For no-match, the actual word must differ. + assertFalse(evalBool("'Hello World' contains text 'goodbye' using lowercase")); + } + + @Test + public void uppercaseMode() throws Exception { + // XQFT §4.1: "using uppercase" normalizes BOTH source and search to uppercase. + assertTrue(evalBool("'HELLO WORLD' contains text 'hello' using uppercase")); + } + + @Test + public void uppercaseModeNoMatch() throws Exception { + // XQFT §4.1: "using uppercase" normalizes BOTH source and search to uppercase. + // For no-match, the actual word must differ. + assertFalse(evalBool("'Hello World' contains text 'GOODBYE' using uppercase")); + } + + // === FTTimes === + + @Test + public void timesAtMostZeroOccurrences() throws Exception { + // "goodbye" doesn't appear in "hello world", which satisfies "at most 1 times" + assertTrue(evalBool("'hello world' contains text 'goodbye' occurs at most 1 times")); + } + + @Test + public void timesAtMostOneOccurrence() throws Exception { + // "hello" appears exactly 1 time, which satisfies "at most 1 times" + assertTrue(evalBool("'hello world' contains text 'hello' occurs at most 1 times")); + } + + @Test + public void timesAtMostExceeded() throws Exception { + // "hello" appears 2 times, which does NOT satisfy "at most 1 times" + assertFalse(evalBool("'hello hello world' contains text 'hello' occurs at most 1 times")); + } + + // === FTOr with empty sequence === + + @Test + public void ftorEmptySequence() throws Exception { + // {()} (empty sequence) produces no match; only "hello" side of ftor matches + assertTrue(evalBool("'hello world' contains text {()} ftor 'hello'")); + } + + @Test + public void ftorEmptySequenceNoMatch() throws Exception { + // {()} produces no match, and 'goodbye' doesn't match — result is false + assertFalse(evalBool("'hello world' contains text {()} ftor 'goodbye'")); + } + + // === XPTY0004 for non-string FTWords values === + + @Test(expected = XPathException.class) + public void ftWordsIntegerRaisesTypeError() throws Exception { + evalBool("'hello world' contains text {42} ftor 'hello'"); + } + + // === Stemming === + + @Test + public void stemmingMatch() throws Exception { + // "pictures" stems to same root as "picture" + assertTrue(evalBool("'hand-drawn pictures of pages' contains text 'picture' using stemming")); + } + + @Test + public void stemmingNoMatch() throws Exception { + // "tasks" stems to "task", but "picture" stems to "pictur" — no match + assertFalse(evalBool("'tasks and training' contains text 'picture' using stemming")); + } + + @Test + public void stemmingVerbForms() throws Exception { + // "performing" and "performed" should share same stem + assertTrue(evalBool("'performing specified tasks' contains text 'performed' using stemming")); + } + + // === declare ft-option === + + @Test + public void declareFtOption() throws Exception { + assertTrue(evalBool( + "declare ft-option using case sensitive;\n" + + "'Hello World' contains text 'Hello'" + )); + } + + @Test + public void declareFtOptionCaseSensitiveRejects() throws Exception { + // With case sensitive declared, 'hello' (lowercase) should NOT match 'Hello' + assertFalse(evalBool( + "declare ft-option using case sensitive;\n" + + "'Hello World' contains text 'hello'" + )); + } + + // === FTST0019: conflicting match options === + + @Test(expected = XPathException.class) + public void conflictingCaseOptionsInProlog() throws Exception { + // FTST0019: conflicting case options in declare ft-option + evalBool( + "declare ft-option using case sensitive using case insensitive;\n" + + "'Hello World' contains text 'Hello'" + ); + } + + // === entire content strictness === + + @Test + public void entireContentRejectsPartialMatch() throws Exception { + // "entire content" must cover ALL token positions, not just first and last + assertFalse(evalBool( + "'one two three four five' contains text 'one' ftand 'five' entire content" + )); + } + + // === FTST0001: mild not operand restrictions === + + @Test(expected = XPathException.class) + public void mildNotRejectsFtnotLeft() throws Exception { + // ftnot in left operand of "not in" must raise FTST0001 + evalBool("'hello world' contains text ('hello' ftand ftnot 'x') not in 'y'"); + } + + @Test(expected = XPathException.class) + public void mildNotRejectsFtnotRight() throws Exception { + // ftnot in right operand of "not in" must raise FTST0001 + evalBool("'hello world' contains text 'hello' not in ('world' ftand ftnot 'x')"); + } + + @Test(expected = XPathException.class) + public void mildNotRejectsOccurs() throws Exception { + // "occurs" in operand of "not in" must raise FTST0001 + evalBool("'hello world' contains text 'hello' occurs exactly 1 times not in 'world'"); + } + + // === Positional filter interaction === + + @Test + public void orderedAfterWindowInParens() throws Exception { + // After window collapses groups, ordered sees a single unit → vacuously true + assertTrue(evalBool( + "'one two three' contains text ('three' ftand 'one' window 3 words) ordered" + )); + } + + // === Complex distance/window interactions === + + @Test + public void distanceWithWindow() throws Exception { + // Window collapses inner group to positions {2,3}; 'swift' is at position 6. + // Distance between last of {2,3} (=3) and first of {6} (=6): 6-3-1 = 2 words gap. + // "distance exactly 2 words" matches, so the expression is true. + assertTrue("distance exactly 2 between window group and swift", + evalBool("'They prefer usability studies to the swift application' contains text " + + "('usability' ftand 'studies' window 2 words) ftand 'swift' distance exactly 2 words")); + // With distance exactly 1, it should reject (actual gap is 2) + assertFalse("distance exactly 1 should reject (actual gap is 2)", + evalBool("'They prefer usability studies to the swift application' contains text " + + "('usability' ftand 'studies' window 2 words) ftand 'swift' distance exactly 1 words")); + } + + // === Dynamic expressions in positional filters === + + @Test + public void dynamicWindowSize() throws Exception { + // Window size computed from a dynamic expression using context + final Sequence result = executeQuery( + "let $items := the quick brown fox jumps" + + "return $items/item[. contains text 'quick' ftand 'fox' window (2 + 2) words]" + ); + assertEquals(1, result.getItemCount()); + } + + // === contains text with comparison === + + // === Score variables === + + @Test + public void forScoreVariable() throws Exception { + // for $t score $s in expr — $s should be bound to a double in [0, 1] + assertTrue(evalBool( + "for $w score $s in ('hello', 'world') " + + "where $w contains text 'hello' " + + "return ($s ge 0.0) and ($s le 1.0)" + )); + } + + @Test + public void letScoreVariable() throws Exception { + // let score $s := expr — $s should be a double in [0, 1] + assertTrue(evalBool( + "let score $s := 'hello' " + + "return ($s ge 0.0) and ($s le 1.0)" + )); + } + + @Test + public void containsTextEqComparison() throws Exception { + // "contains text" has higher precedence than "eq" + assertFalse(evalBool( + "'Hello World' contains text 'Hello' eq fn:false()" + )); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/ft/FTEvaluatorTest.java b/exist-core/src/test/java/org/exist/xquery/ft/FTEvaluatorTest.java new file mode 100644 index 00000000000..d2d9a36648d --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ft/FTEvaluatorTest.java @@ -0,0 +1,121 @@ +/* + * 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.ft; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Unit tests for the FTEvaluator sequential full-text matching engine. + */ +public class FTEvaluatorTest { + + @Test + public void tokenizeSimple() { + final List tokens = FTEvaluator.tokenize("hello world"); + assertEquals(Arrays.asList("hello", "world"), tokens); + } + + @Test + public void tokenizePunctuation() { + final List tokens = FTEvaluator.tokenize("Hello, World! How's it going?"); + assertEquals(Arrays.asList("Hello", "World", "How's", "it", "going"), tokens); + } + + @Test + public void tokenizeEmpty() { + assertTrue(FTEvaluator.tokenize("").isEmpty()); + assertTrue(FTEvaluator.tokenize(null).isEmpty()); + assertTrue(FTEvaluator.tokenize(" ").isEmpty()); + } + + @Test + public void tokenizeNumbers() { + final List tokens = FTEvaluator.tokenize("abc 123 def"); + assertEquals(Arrays.asList("abc", "123", "def"), tokens); + } + + @Test + public void wildcardToRegexSimple() { + // . matches any single char + assertTrue("hXllo".matches(FTEvaluator.wildcardToRegex("h.llo", false))); + assertFalse("hllo".matches(FTEvaluator.wildcardToRegex("h.llo", false))); + } + + @Test + public void wildcardToRegexStar() { + // .* matches zero or more + assertTrue("hello".matches(FTEvaluator.wildcardToRegex("hel.*", false))); + assertTrue("hel".matches(FTEvaluator.wildcardToRegex("hel.*", false))); + } + + @Test + public void wildcardToRegexPlus() { + // .+ matches one or more + assertTrue("hello".matches(FTEvaluator.wildcardToRegex("hel.+", false))); + assertFalse("hel".matches(FTEvaluator.wildcardToRegex("hel.+", false))); + } + + @Test + public void wildcardToRegexCaseInsensitive() { + assertTrue("HELLO".matches(FTEvaluator.wildcardToRegex("hello", true))); + } + + @Test + public void mergeOptionsLocalOverrides() { + final FTMatchOptions inherited = new FTMatchOptions(); + inherited.setCaseMode(FTMatchOptions.CaseMode.SENSITIVE); + inherited.setLanguage("en"); + + final FTMatchOptions local = new FTMatchOptions(); + local.setCaseMode(FTMatchOptions.CaseMode.INSENSITIVE); + + final FTMatchOptions merged = FTEvaluator.mergeOptions(inherited, local); + assertEquals(FTMatchOptions.CaseMode.INSENSITIVE, merged.getCaseMode()); + assertEquals("en", merged.getLanguage()); // inherited + } + + @Test + public void mergeOptionsNullLocal() { + final FTMatchOptions inherited = new FTMatchOptions(); + inherited.setCaseMode(FTMatchOptions.CaseMode.SENSITIVE); + assertSame(inherited, FTEvaluator.mergeOptions(inherited, null)); + } + + @Test + public void wildcardMatchInEvaluator() { + final FTEvaluator evaluator = new FTEvaluator("hello world"); + final String regex = FTEvaluator.wildcardToRegex("hel.*", false); + assertTrue("hel.* should match hello", "hello".matches(regex)); + } + + @Test + public void mergeOptionsNullInherited() { + final FTMatchOptions local = new FTMatchOptions(); + local.setCaseMode(FTMatchOptions.CaseMode.INSENSITIVE); + assertSame(local, FTEvaluator.mergeOptions(null, local)); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/ft/FTParserTest.java b/exist-core/src/test/java/org/exist/xquery/ft/FTParserTest.java new file mode 100644 index 00000000000..9a65cddbf90 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/ft/FTParserTest.java @@ -0,0 +1,251 @@ +/* + * 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.ft; + +import antlr.RecognitionException; +import antlr.TokenStreamException; +import antlr.collections.AST; +import org.exist.xquery.XPathException; +import org.exist.xquery.parser.XQueryLexer; +import org.exist.xquery.parser.XQueryParser; +import org.exist.xquery.parser.XQueryTokenTypes; +import org.junit.Test; + +import java.io.StringReader; + +import static org.junit.Assert.*; + +/** + * Tests that the XQFT grammar extensions parse correctly into AST nodes. + * These tests verify Phase 1 (parser) without requiring a running database. + */ +public class FTParserTest { + + private AST parse(final String xquery) throws RecognitionException, TokenStreamException, XPathException { + final XQueryLexer lexer = new XQueryLexer(new StringReader(xquery)); + final XQueryParser parser = new XQueryParser(lexer); + parser.xpath(); + return parser.getAST(); + } + + private AST findToken(final AST root, final int tokenType) { + if (root == null) return null; + if (root.getType() == tokenType) return root; + AST found = findToken(root.getFirstChild(), tokenType); + if (found != null) return found; + return findToken(root.getNextSibling(), tokenType); + } + + @Test + public void simpleContainsText() throws Exception { + final AST ast = parse("$x contains text 'hello'"); + assertNotNull("AST should not be null", ast); + final AST ftContains = findToken(ast, XQueryTokenTypes.FT_CONTAINS); + assertNotNull("Should find FT_CONTAINS token", ftContains); + } + + @Test + public void ftAnd() throws Exception { + final AST ast = parse("$x contains text 'hello' ftand 'world'"); + assertNotNull(ast); + assertNotNull("Should find FT_CONTAINS", findToken(ast, XQueryTokenTypes.FT_CONTAINS)); + assertNotNull("Should find FT_AND", findToken(ast, XQueryTokenTypes.FT_AND)); + } + + @Test + public void ftOr() throws Exception { + final AST ast = parse("$x contains text 'hello' ftor 'world'"); + assertNotNull(ast); + assertNotNull("Should find FT_OR", findToken(ast, XQueryTokenTypes.FT_OR)); + } + + @Test + public void ftNot() throws Exception { + final AST ast = parse("$x contains text ftnot 'hello'"); + assertNotNull(ast); + assertNotNull("Should find FT_UNARY_NOT", findToken(ast, XQueryTokenTypes.FT_UNARY_NOT)); + } + + @Test + public void ftMildNot() throws Exception { + final AST ast = parse("$x contains text 'hello' not in 'world'"); + assertNotNull(ast); + assertNotNull("Should find FT_MILD_NOT", findToken(ast, XQueryTokenTypes.FT_MILD_NOT)); + } + + @Test + public void allWords() throws Exception { + final AST ast = parse("$x contains text 'hello world' all words"); + assertNotNull(ast); + final AST anyall = findToken(ast, XQueryTokenTypes.FT_ANYALL_OPTION); + assertNotNull("Should find FT_ANYALL_OPTION", anyall); + assertEquals("all words", anyall.getText()); + } + + @Test + public void phrase() throws Exception { + final AST ast = parse("$x contains text 'hello world' phrase"); + assertNotNull(ast); + final AST anyall = findToken(ast, XQueryTokenTypes.FT_ANYALL_OPTION); + assertNotNull(anyall); + assertEquals("phrase", anyall.getText()); + } + + @Test + public void ordered() throws Exception { + final AST ast = parse("$x contains text 'hello' ftand 'world' ordered"); + assertNotNull(ast); + assertNotNull("Should find FT_ORDER", findToken(ast, XQueryTokenTypes.FT_ORDER)); + } + + @Test + public void window() throws Exception { + final AST ast = parse("$x contains text 'hello' ftand 'world' window 5 words"); + assertNotNull(ast); + assertNotNull("Should find FT_WINDOW", findToken(ast, XQueryTokenTypes.FT_WINDOW)); + } + + @Test + public void distance() throws Exception { + final AST ast = parse("$x contains text 'hello' ftand 'world' distance at most 3 words"); + assertNotNull(ast); + assertNotNull("Should find FT_DISTANCE", findToken(ast, XQueryTokenTypes.FT_DISTANCE)); + } + + @Test + public void scope() throws Exception { + final AST ast = parse("$x contains text 'hello' ftand 'world' same sentence"); + assertNotNull(ast); + final AST scope = findToken(ast, XQueryTokenTypes.FT_SCOPE); + assertNotNull("Should find FT_SCOPE", scope); + assertEquals("same sentence", scope.getText()); + } + + @Test + public void contentAtStart() throws Exception { + final AST ast = parse("$x contains text 'hello' at start"); + assertNotNull(ast); + final AST content = findToken(ast, XQueryTokenTypes.FT_CONTENT); + assertNotNull("Should find FT_CONTENT", content); + assertEquals("at start", content.getText()); + } + + @Test + public void contentAtEnd() throws Exception { + final AST ast = parse("$x contains text 'hello' at end"); + assertNotNull(ast); + final AST content = findToken(ast, XQueryTokenTypes.FT_CONTENT); + assertNotNull(content); + assertEquals("at end", content.getText()); + } + + @Test + public void entireContent() throws Exception { + final AST ast = parse("$x contains text 'hello' entire content"); + assertNotNull(ast); + final AST content = findToken(ast, XQueryTokenTypes.FT_CONTENT); + assertNotNull(content); + assertEquals("entire content", content.getText()); + } + + @Test + public void caseOption() throws Exception { + final AST ast = parse("$x contains text 'hello' using case insensitive"); + assertNotNull(ast); + final AST caseOpt = findToken(ast, XQueryTokenTypes.FT_CASE_OPTION); + assertNotNull("Should find FT_CASE_OPTION", caseOpt); + assertEquals("insensitive", caseOpt.getText()); + } + + @Test + public void stemmingOption() throws Exception { + final AST ast = parse("$x contains text 'hello' using stemming"); + assertNotNull(ast); + final AST stemOpt = findToken(ast, XQueryTokenTypes.FT_STEM_OPTION); + assertNotNull("Should find FT_STEM_OPTION", stemOpt); + assertEquals("stemming", stemOpt.getText()); + } + + @Test + public void languageOption() throws Exception { + final AST ast = parse("$x contains text 'hello' using language 'en'"); + assertNotNull(ast); + assertNotNull("Should find FT_LANGUAGE_OPTION", findToken(ast, XQueryTokenTypes.FT_LANGUAGE_OPTION)); + } + + @Test + public void wildcardOption() throws Exception { + final AST ast = parse("$x contains text 'hel.*' using wildcards"); + assertNotNull(ast); + final AST wcOpt = findToken(ast, XQueryTokenTypes.FT_WILDCARD_OPTION); + assertNotNull("Should find FT_WILDCARD_OPTION", wcOpt); + assertEquals("wildcards", wcOpt.getText()); + } + + @Test + public void weightExpr() throws Exception { + final AST ast = parse("$x contains text 'hello' weight { 2.0 }"); + assertNotNull(ast); + assertNotNull("Should find FT_WEIGHT", findToken(ast, XQueryTokenTypes.FT_WEIGHT)); + } + + @Test + public void occurs() throws Exception { + final AST ast = parse("$x contains text 'hello' occurs at least 2 times"); + assertNotNull(ast); + assertNotNull("Should find FT_TIMES", findToken(ast, XQueryTokenTypes.FT_TIMES)); + } + + @Test + public void complexExpression() throws Exception { + // Mix of operators, positional filters, and match options + // Match options go on ftPrimaryWithOptions (before pos filters) + // Pos filters go on ftSelection (after the ftOr chain) + final AST ast = parse( + "$x contains text ('hello' ftand 'world' all words) " + + "using case insensitive using stemming " + + "ordered window 10 words" + ); + assertNotNull(ast); + assertNotNull("Should find FT_CONTAINS", findToken(ast, XQueryTokenTypes.FT_CONTAINS)); + assertNotNull("Should find FT_AND", findToken(ast, XQueryTokenTypes.FT_AND)); + assertNotNull("Should find FT_ORDER", findToken(ast, XQueryTokenTypes.FT_ORDER)); + assertNotNull("Should find FT_WINDOW", findToken(ast, XQueryTokenTypes.FT_WINDOW)); + assertNotNull("Should find FT_CASE_OPTION", findToken(ast, XQueryTokenTypes.FT_CASE_OPTION)); + assertNotNull("Should find FT_STEM_OPTION", findToken(ast, XQueryTokenTypes.FT_STEM_OPTION)); + } + + @Test + public void containsFunctionNotAffected() throws Exception { + // Ensure fn:contains() still parses as a function call, not as FT + final AST ast = parse("contains('hello world', 'hello')"); + assertNotNull(ast); + assertNull("Should NOT find FT_CONTAINS", findToken(ast, XQueryTokenTypes.FT_CONTAINS)); + } + + @Test + public void withoutContent() throws Exception { + final AST ast = parse("$x contains text 'hello' without content $footnotes"); + assertNotNull(ast); + assertNotNull("Should find FT_IGNORE_OPTION", findToken(ast, XQueryTokenTypes.FT_IGNORE_OPTION)); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/CollectionFileUriTest.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/CollectionFileUriTest.java new file mode 100644 index 00000000000..abc5dc8dae3 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/CollectionFileUriTest.java @@ -0,0 +1,229 @@ +/* + * 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.functions.fn; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Sequence; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests for fn:collection() with file: URIs and Saxon-style query string parameters. + *

+ * Creates a temp directory with a mix of files (XML, non-XML, malformed) and verifies + * the {@code select}, {@code match}, {@code content-type}, and {@code stable} parameters. + */ +public class CollectionFileUriTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + private static Path tempDir; + + @BeforeClass + public static void setUp() throws IOException { + tempDir = Files.createTempDirectory("exist-collection-file-uri-test-"); + + // 5 well-formed XML files + Files.writeString(tempDir.resolve("doc1.xml"), "1"); + Files.writeString(tempDir.resolve("doc2.xml"), "2"); + Files.writeString(tempDir.resolve("doc3.xml"), "3"); + Files.writeString(tempDir.resolve("alpha.xml"), "alpha"); + Files.writeString(tempDir.resolve("beta.xml"), "beta"); + + // Non-XML files (should be excluded by default *.xml glob) + Files.writeString(tempDir.resolve("readme.txt"), "not xml"); + Files.writeString(tempDir.resolve("data.json"), "{\"k\":1}"); + } + + @AfterClass + public static void tearDown() throws IOException { + if (tempDir != null && Files.exists(tempDir)) { + try (final Stream walk = Files.walk(tempDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (final IOException ignored) { + } + }); + } + } + } + + private Sequence runQuery(final String xquery) throws EXistException, PermissionDeniedException, XPathException, IOException { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject()))) { + final XQuery xqueryService = pool.getXQueryService(); + final XQueryContext context = new XQueryContext(pool); + final CompiledXQuery compiled = xqueryService.compile(context, xquery); + return xqueryService.execute(broker, compiled, null); + } + } + + private String fileUri() { + return tempDir.toUri().toString(); + } + + @Test + public void defaultGlobReturnsAllXml() throws Exception { + // No params: default *.xml glob, returns all 5 XML files + final Sequence result = runQuery("count(fn:collection('" + fileUri() + "'))"); + assertEquals("default glob should match all 5 XML files", "5", result.getStringValue()); + } + + @Test + public void selectGlob() throws Exception { + // ?select=doc*.xml — only doc1, doc2, doc3 + final Sequence result = runQuery("count(fn:collection('" + fileUri() + "?select=doc*.xml'))"); + assertEquals("select=doc*.xml should match 3 files", "3", result.getStringValue()); + } + + @Test + public void matchRegex() throws Exception { + // ?match=^doc[0-9]+\.xml$ — exactly the 3 doc files + final Sequence result = runQuery( + "count(fn:collection('" + fileUri() + "?match=^doc[0-9]+\\.xml$'))"); + assertEquals("match regex should select 3 doc files", "3", result.getStringValue()); + } + + @Test + public void selectAndMatchCombined() throws Exception { + // ?select=*.xml&match=^doc — only doc1/2/3 (excludes alpha, beta) + // Build URI with concat() to avoid the literal & in XQuery string + final Sequence result = runQuery( + "count(fn:collection(concat('" + fileUri() + "?select=*.xml', codepoints-to-string(38), 'match=^doc')))"); + assertEquals("select + match combined", "3", result.getStringValue()); + } + + @Test + public void stableYesGivesAlphabeticalOrder() throws Exception { + // ?stable=yes — files sorted alphabetically: alpha, beta, doc1, doc2, doc3 + final Sequence result = runQuery( + "string-join(\n" + + " for $d in fn:collection('" + fileUri() + "?stable=yes')\n" + + " return tokenize(document-uri($d), '/')[last()],\n" + + " ',')"); + assertEquals("stable=yes should sort alphabetically", + "alpha.xml,beta.xml,doc1.xml,doc2.xml,doc3.xml", result.getStringValue()); + } + + @Test + public void stableIsDefaultYes() throws Exception { + // No stable= param: default is yes (alphabetical) + final Sequence result = runQuery( + "string-join(\n" + + " for $d in fn:collection('" + fileUri() + "')\n" + + " return tokenize(document-uri($d), '/')[last()],\n" + + " ',')"); + assertEquals("default ordering should be alphabetical", + "alpha.xml,beta.xml,doc1.xml,doc2.xml,doc3.xml", result.getStringValue()); + } + + @Test + public void contentTypeXml() throws Exception { + // content-type=application/vnd.existdb.document+xml — XML documents only (default for fn:collection) + final Sequence result = runQuery( + "count(fn:collection('" + fileUri() + "?content-type=application/vnd.existdb.document+xml'))"); + assertEquals("xml content-type should match all 5 XML files", "5", result.getStringValue()); + } + + @Test + public void contentTypeBinaryReturnsEmpty() throws Exception { + // fn:collection() doesn't return binary docs — content-type=binary returns nothing + final Sequence result = runQuery( + "count(fn:collection('" + fileUri() + "?content-type=application/vnd.existdb.document+binary'))"); + assertEquals("binary content-type should return 0 documents", "0", result.getStringValue()); + } + + @Test + public void allParametersCombined() throws Exception { + // All four parameters together: select=doc*.xml & match=[12] & content-type=xml & stable=yes + // Build the URI via concat() to avoid the literal & in XQuery string + final String amp = "', codepoints-to-string(38), '"; + final Sequence result = runQuery( + "string-join(\n" + + " for $d in fn:collection(concat('" + fileUri() + "?select=doc*.xml" + amp + + "match=doc[12]" + amp + + "content-type=application/vnd.existdb.document+xml" + amp + + "stable=yes'))\n" + + " return tokenize(document-uri($d), '/')[last()],\n" + + " ',')"); + assertEquals("all params combined should give doc1, doc2 in order", + "doc1.xml,doc2.xml", result.getStringValue()); + } + + @Test + public void invalidQueryParamRaisesError() throws Exception { + // Unknown parameter should raise FODC0004 + try { + runQuery("fn:collection('" + fileUri() + "?bogus=foo')"); + fail("expected FODC0004 for unknown query parameter"); + } catch (final XPathException e) { + assertTrue("error should be FODC0004 but was " + e.getErrorCode(), + e.getErrorCode().getErrorQName().getLocalPart().equals("FODC0004")); + } + } + + @Test + public void invalidStableValueRaisesError() throws Exception { + // stable=maybe is invalid + try { + runQuery("fn:collection('" + fileUri() + "?stable=maybe')"); + fail("expected FODC0004 for invalid stable value"); + } catch (final XPathException e) { + assertTrue("error should be FODC0004 but was " + e.getErrorCode(), + e.getErrorCode().getErrorQName().getLocalPart().equals("FODC0004")); + } + } + + @Test + public void invalidContentTypeRaisesError() throws Exception { + try { + runQuery("fn:collection('" + fileUri() + "?content-type=text/plain')"); + fail("expected FODC0004 for invalid content-type value"); + } catch (final XPathException e) { + assertTrue("error should be FODC0004 but was " + e.getErrorCode(), + e.getErrorCode().getErrorQName().getLocalPart().equals("FODC0004")); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/ContainsTokenEmptyCollationTest.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/ContainsTokenEmptyCollationTest.java new file mode 100644 index 00000000000..0b32560c58d --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/ContainsTokenEmptyCollationTest.java @@ -0,0 +1,84 @@ +/* + * 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.functions.fn; + +import org.exist.source.Source; +import org.exist.source.StringSource; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.XQuery; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Sequence; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.assertEquals; + +/** + * Verifies that fn:contains-token accepts an empty sequence as the optional $collation + * argument, per the QT4 XQTS fn-contains-token-80 / 81 / 82 cases that previously + * failed with XPTY0004. + */ +public class ContainsTokenEmptyCollationTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + @Test + public void emptyCollation_emptySequenceLiteral() throws Exception { + // contains-token-80: third arg is () + assertEquals("true", + executeStringValue("fn:contains-token('a b c', 'b', ())")); + } + + @Test + public void emptyCollation_emptyStringSequence() throws Exception { + // contains-token-82: third arg comes from a let returning empty sequence + assertEquals("true", + executeStringValue("let $c := () return fn:contains-token('a b c', 'b', $c)")); + } + + @Test + public void presentCollation_stillWorks() throws Exception { + assertEquals("true", + executeStringValue("fn:contains-token('a b c', 'B', " + + "'http://www.w3.org/2005/xpath-functions/collation/html-ascii-case-insensitive')")); + } + + private String executeStringValue(final String query) throws Exception { + final Source source = new StringSource(query); + final BrokerPool brokerPool = existEmbeddedServer.getBrokerPool(); + final XQuery xquery = brokerPool.getXQueryService(); + + try (final DBBroker broker = brokerPool.get( + Optional.of(brokerPool.getSecurityManager().getSystemSubject()))) { + final XQueryContext context = new XQueryContext(brokerPool); + final CompiledXQuery compiled = xquery.compile(context, source); + final Sequence result = xquery.execute(broker, compiled, null); + return result.getStringValue(); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/FunNotBenchmark.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/FunNotBenchmark.java new file mode 100644 index 00000000000..694206e9e27 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/FunNotBenchmark.java @@ -0,0 +1,175 @@ +/* + * 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.functions.fn; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.*; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.CollectionManagementService; +import org.xmldb.api.modules.XMLResource; +import org.xmldb.api.modules.XQueryService; + +import static org.junit.Assume.assumeTrue; + +/** + * Performance benchmark for fn:not() predicate evaluation. + * + *

Verifies that the set-difference optimization for fn:not() on persistent + * node sets is preserved after the fixes for issues #2159 and #2308, and that + * the new boolean-per-item fallback path for atomic sequences performs + * acceptably.

+ * + *

Skipped by default. Run with:

+ *
+ * mvn test -pl exist-core -Dtest=FunNotBenchmark \
+ *     -Dexist.run.benchmarks=true -Ddependency-check.skip=true
+ * 
+ */ +public class FunNotBenchmark { + + private static final String COLLECTION_NAME = "bench-fn-not"; + private static final int WARMUP_ITERATIONS = 100; + private static final int MEASURED_ITERATIONS = 500; + + @ClassRule + public static final ExistXmldbEmbeddedServer server = + new ExistXmldbEmbeddedServer(false, true, true); + + @BeforeClass + public static void setUp() throws XMLDBException { + assumeTrue("Benchmark skipped (pass -Dexist.run.benchmarks=true to enable)", + Boolean.getBoolean("exist.run.benchmarks")); + + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + final var col = cms.createCollection(COLLECTION_NAME); + + // Generate test document: 200 items with varying children and attributes + final StringBuilder sb = new StringBuilder(); + sb.append("\n"); + for (int i = 1; i <= 200; i++) { + sb.append(" \n"); + if (i % 2 == 0) { + sb.append(" child-").append(i).append("\n"); + } + if (i % 5 == 0) { + sb.append(" descendant-").append(i).append("\n"); + } + sb.append(" \n"); + } + sb.append(""); + + final XMLResource res = col.createResource("data.xml", XMLResource.class); + res.setContent(sb.toString()); + col.storeResource(res); + col.close(); + } + + @AfterClass + public static void tearDown() throws XMLDBException { + try { + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + cms.removeCollection(COLLECTION_NAME); + } catch (final XMLDBException e) { + // ignore cleanup failures + } + } + + // --- Benchmark queries --- + + private static final String DOC = "doc('/db/" + COLLECTION_NAME + "/data.xml')"; + + /** Set-difference optimization: child axis */ + @Test + public void notChild() throws XMLDBException { + runBenchmark("not(child)", + DOC + "//item[not(child)]"); + } + + /** Set-difference optimization: attribute axis */ + @Test + public void notAttribute() throws XMLDBException { + runBenchmark("not(@attr)", + DOC + "//item[not(@attr)]"); + } + + /** Set-difference optimization: descendant axis */ + @Test + public void notDescendant() throws XMLDBException { + runBenchmark("not(descendant::x)", + DOC + "//item[not(descendant::x)]"); + } + + /** Boolean fallback: general comparison inside not() */ + @Test + public void notComparison() throws XMLDBException { + runBenchmark("not(@id > 100)", + DOC + "//item[not(@id > 100)]"); + } + + /** Boolean fallback: not(.) on in-memory nodes */ + @Test + public void notDotOnNodes() throws XMLDBException { + runBenchmark("not(.) on nodes", + DOC + "//item[not(.)]"); + } + + // --- Benchmark harness --- + + private void runBenchmark(final String label, final String xquery) throws XMLDBException { + final XQueryService queryService = server.getRoot().getService(XQueryService.class); + + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + queryService.query(xquery); + } + + // Measured + final long[] timings = new long[MEASURED_ITERATIONS]; + for (int i = 0; i < MEASURED_ITERATIONS; i++) { + final long start = System.nanoTime(); + queryService.query(xquery); + timings[i] = System.nanoTime() - start; + } + + // Stats + long total = 0; + long min = Long.MAX_VALUE; + for (final long t : timings) { + total += t; + if (t < min) { + min = t; + } + } + final double avgMs = (total / (double) MEASURED_ITERATIONS) / 1_000_000.0; + final double minMs = min / 1_000_000.0; + final double opsPerSec = MEASURED_ITERATIONS / (total / 1_000_000_000.0); + + System.out.printf("| %-25s | %8.3f ms | %8.3f ms | %10.1f ops/s |%n", + label, avgMs, minMs, opsPerSec); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/KeywordArgumentTest.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/KeywordArgumentTest.java new file mode 100644 index 00000000000..4e7a5b21140 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/KeywordArgumentTest.java @@ -0,0 +1,150 @@ +/* + * 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.functions.fn; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests XQuery 4.0 keyword argument syntax (name := value). + * + * Verifies that keyword args work with both NCNAME parameter names and + * names that are reserved keywords (e.g. value, to, node, function). + */ +public class KeywordArgumentTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(true, true, true); + + @Test + public void mathLog10WithValueKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "math:log10(value := 100.0)"); + assertEquals(1, rs.getSize()); + assertEquals("2", rs.getResource(0).getContent().toString()); + } + + @Test + public void mathLog10AsPartialApplicationKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "math:log10(value := ?) instance of function(xs:double?) as xs:double?"); + assertEquals(1, rs.getSize()); + assertEquals("true", rs.getResource(0).getContent().toString()); + } + + @Test + public void yearFromDateValueKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "fn:year-from-date(value := xs:date('2026-04-29'))"); + assertEquals(1, rs.getSize()); + assertEquals("2026", rs.getResource(0).getContent().toString()); + } + + @Test + public void hoursFromTimeValueKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "fn:hours-from-time(value := xs:time('15:30:00'))"); + assertEquals(1, rs.getSize()); + assertEquals("15", rs.getResource(0).getContent().toString()); + } + + @Test + public void reverseInputKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "string-join(fn:reverse(input := (1,2,3)), ',')"); + assertEquals(1, rs.getSize()); + assertEquals("3,2,1", rs.getResource(0).getContent().toString()); + } + + @Test + public void matchesValueKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "fn:matches(value := 'hello', pattern := '^h')"); + assertEquals(1, rs.getSize()); + assertEquals("true", rs.getResource(0).getContent().toString()); + } + + /** + * Subsequence-where with `to` keyword arg -- `to` is a reserved keyword, + * so this exercises the parser change that allows reserved keywords as + * keyword-argument names. + */ + @Test + public void subsequenceWhereWithToKeyword() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "string-join(fn:subsequence-where(1 to 5, to := function($x){$x = 3}), ',')"); + assertEquals(1, rs.getSize()); + assertEquals("1,2,3", rs.getResource(0).getContent().toString()); + } + + /** + * Verify that `value := expr` still works after we generalised the + * keyword-arg name to any reserved keyword. + */ + @Test + public void valueKeywordStillWorks() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "math:exp(value := 0.0)"); + assertEquals(1, rs.getSize()); + assertEquals("1", rs.getResource(0).getContent().toString()); + } + + /** + * Make sure `1 to 5` still parses as a range expression in argument + * position (the parser change must not regress this). + */ + @Test + public void rangeExprInArgumentStillParses() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "count((1 to 5))"); + assertEquals(1, rs.getSize()); + assertEquals("5", rs.getResource(0).getContent().toString()); + } + + @Test + public void floorWithValueKeywordPlaceholder() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "fn:floor(value := ?) instance of function(xs:numeric?) as xs:numeric?"); + assertEquals(1, rs.getSize()); + assertEquals("true", rs.getResource(0).getContent().toString()); + } + + /** + * XQTS-style shape: `f(name1 := ?, name2 := ?) instance of function(*)`. + * Two keyword-arg placeholders separated by comma — exercises the + * per-keyword placeholder path AND the multi-arg keyword resolution. + */ + @Test + public void twoKeywordPlaceholdersInstanceOfFunctionStar() throws XMLDBException { + final ResourceSet rs = existEmbeddedServer.executeQuery( + "fn:index-where(input := ?, predicate := ?) instance of function(*)"); + assertEquals(1, rs.getSize()); + assertEquals("true", rs.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/fn/LoadXQueryModuleContentTest.java b/exist-core/src/test/java/org/exist/xquery/functions/fn/LoadXQueryModuleContentTest.java new file mode 100644 index 00000000000..2ab34565a52 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/fn/LoadXQueryModuleContentTest.java @@ -0,0 +1,95 @@ +/* + * 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.functions.fn; + +import com.evolvedbinary.j8fu.Either; +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.test.XQueryCompilationTest; +import org.exist.xquery.ErrorCodes; +import org.exist.xquery.XPathException; +import org.exist.xquery.value.Sequence; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests for the XQuery 4.0 'content' option of fn:load-xquery-module, + * which compiles a library module from a string instead of fetching by URI. + * + * Targets the FOQM0003 false-positive that previously occurred when + * caller and inline module both declared xquery version "4.0": + * the version check compared the temporary host context (always 3.1) + * to the requested 4.0 instead of the loaded module's own context. + */ +public class LoadXQueryModuleContentTest extends XQueryCompilationTest { + + private static String unwrap(final Either result) { + if (result.isLeft()) { + fail("Query failed: " + result.left().get().getMessage()); + } + try { + return result.right().get().itemAt(0).getStringValue(); + } catch (final XPathException e) { + fail("Could not stringify result: " + e.getMessage()); + return null; + } + } + + @Test + public void contentOption_xq40_matchesCallerVersion() throws EXistException, PermissionDeniedException { + final String query = + "xquery version \"4.0\";\n" + + "let $module := \"xquery version '4.0';\n" + + "module namespace m = 'http://example.com/m';\n" + + "declare function m:hello() as xs:string { 'hi' };\"\n" + + "let $loaded := fn:load-xquery-module(\n" + + " 'http://example.com/m',\n" + + " map { 'content': $module }\n" + + ")\n" + + "let $f := $loaded?functions(fn:QName('http://example.com/m','hello'))?0\n" + + "return $f()"; + + assertEquals("hi", unwrap(executeQuery(query))); + } + + @Test + public void contentOption_versionMismatch_raisesFOQM0003() + throws EXistException, PermissionDeniedException { + // Caller is 4.0, inline module declares 3.1. Spec requires FOQM0003. + final String query = + "xquery version \"4.0\";\n" + + "let $module := \"xquery version '3.1';\n" + + "module namespace m = 'http://example.com/m31';\n" + + "declare function m:hello() as xs:string { 'hi' };\"\n" + + "return fn:load-xquery-module(\n" + + " 'http://example.com/m31',\n" + + " map { 'content': $module }\n" + + ")"; + + final Either result = executeQuery(query); + assertTrue("Expected FOQM0003 error, got: " + result, result.isLeft()); + assertEquals(ErrorCodes.FOQM0003, result.left().get().getErrorCode()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/integer/IntegerPictureTest.java b/exist-core/src/test/java/org/exist/xquery/functions/integer/IntegerPictureTest.java index b9547c955ac..528ff9b37a1 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/integer/IntegerPictureTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/integer/IntegerPictureTest.java @@ -515,4 +515,46 @@ public void fallback() throws XPathException { public void formatFix() throws XPathException { assertEquals("12345,67,89", fmt("000,00,00", 123456789L)); } + + // XQ4 §4.6.1: optional radix prefix BASE^pattern, BASE in [2..36] + @Test + public void formatRadixPrefix() throws XPathException { + // From the QT4 fo-spec-examples test catalog + assertEquals("04d2", fmt("16^xxxx", 1234L)); + assertEquals("4D2", fmt("16^X", 1234L)); + assertEquals("00bc_614e", fmt("16^xxxx_xxxx", 12345678L)); + assertEquals("bc_614e", fmt("16^#_xxxx", 12345678L)); + assertEquals("1111 1111", fmt("2^xxxx xxxx", 255L)); + assertEquals("00VV", fmt("32^XXXX", 1023L)); + assertEquals("1023", fmt("10^XXXX", 1023L)); + } + + // XQ4 §4.6.1: ^ is treated as a grouping separator when not preceded + // by a valid base or when not followed by X/x in the primary token. + @Test + public void radixCircumflexFallback() throws XPathException { + // From the spec example: input 2345, picture 9^XXX outputs "3185" + // (radix-mode: 2345 in base 9 is 3185) + assertEquals("3185", fmt("9^XXX", 2345L)); + // Same input with picture 9^000: no X/x → fall through to decimal, + // ^ becomes a grouping separator → "2^345" + assertEquals("2^345", fmt("9^000", 2345L)); + // 10^00 → no X/x → fall through to decimal mode + assertEquals("10^23", fmt("10^00", 1023L)); + } + + @Test + public void radixMixedCaseRejected() { + try { + fmt("16^Xx", 1L); + fail("Mixed-case mandatory-digit-signs should be rejected"); + } catch (final XPathException xpe) { + assertTrue(xpe.getDetailMessage().contains("mixes upper-case 'X' and lower-case 'x'")); + } + } + + @Test + public void radixNegative() throws XPathException { + assertEquals("-04d2", fmt("16^xxxx", -1234L)); + } } diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java index e1926588e32..8bb7f535d37 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java @@ -36,6 +36,7 @@ import org.exist.xmldb.EXistResource; import org.junit.AfterClass; import org.junit.BeforeClass; +import org.junit.Ignore; import org.junit.Test; import static java.nio.charset.StandardCharsets.UTF_8; @@ -87,6 +88,7 @@ public void retrieveEmpty() throws IOException { testRequest(post, wrapInElement("").getBytes()); } + @Ignore("Jetty 12 rejects HTTP/0.9 but corrupts the Apache HttpClient connection pool, causing NoHttpResponseException in subsequent tests") @Test public void retrieveBinaryHttp09() throws IOException { final String testData = "12345"; @@ -99,6 +101,7 @@ public void retrieveBinaryHttp09() throws IOException { assertEquals(HttpStatus.SC_HTTP_VERSION_NOT_SUPPORTED, response.getStatusLine().getStatusCode()); } + @Ignore("Jetty 12 drops the connection on HTTP/1.0 without a response, causing NoHttpResponseException in Apache HttpClient") @Test public void retrieveBinaryHttp10() throws IOException { final String testData = "12345"; @@ -134,6 +137,7 @@ public void retrieveBinaryHttp11ChunkedTransferEncoding() throws IOException { } } + @Ignore("Jetty 12 rejects HTTP/0.9 but corrupts the Apache HttpClient connection pool, causing NoHttpResponseException in subsequent tests") @Test public void retrieveXmlHttp09() throws IOException { final String testData = "hello"; @@ -146,6 +150,7 @@ public void retrieveXmlHttp09() throws IOException { assertEquals(HttpStatus.SC_HTTP_VERSION_NOT_SUPPORTED, response.getStatusLine().getStatusCode()); } + @Ignore("Jetty 12 drops the connection on HTTP/1.0 without a response, causing NoHttpResponseException in Apache HttpClient") @Test public void retrieveXmlHttp10() throws IOException { final String testData = "hello"; diff --git a/exist-core/src/test/java/org/exist/xquery/functions/util/EvalTest.java b/exist-core/src/test/java/org/exist/xquery/functions/util/EvalTest.java index c739dbac4e4..a8905b0fa2d 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/util/EvalTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/util/EvalTest.java @@ -315,7 +315,7 @@ public void evalAndSerializeDefaultOptions() throws XMLDBException { public void evalAndSerializeJson() throws XMLDBException { String query = "let $query := \"hello\"\n" + "return\n" + - "util:eval-and-serialize($query, map { \"method\": \"json\" })"; + "util:eval-and-serialize($query, map { \"method\": \"json\", \"legacy-json-conversion\": \"yes\" })"; ResourceSet result = existEmbeddedServer.executeQuery(query); Resource r = result.getResource(0); assertEquals("{\"elem1\":\"hello\"}", r.getContent()); diff --git a/exist-core/src/test/java/org/exist/xquery/functions/websocket/WebSocketEndpointTest.java b/exist-core/src/test/java/org/exist/xquery/functions/websocket/WebSocketEndpointTest.java new file mode 100644 index 00000000000..ada55817bbf --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/websocket/WebSocketEndpointTest.java @@ -0,0 +1,185 @@ +/* + * 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.functions.websocket; + +import org.exist.test.ExistWebServer; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.websocket.*; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Integration test for the WebSocket endpoint. + * Starts an embedded eXist-db server and connects a WebSocket client. + */ +public class WebSocketEndpointTest { + + @ClassRule + public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); + + @Test + public void connectAndReceiveHeartbeat() throws Exception { + final int port = existWebServer.getPort(); + final URI wsUri = new URI("ws://localhost:" + port + "/ws"); + + final CountDownLatch messageLatch = new CountDownLatch(1); + final AtomicReference receivedMessage = new AtomicReference<>(); + + final WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + final Session session = container.connectToServer(new Endpoint() { + @Override + public void onOpen(final Session session, final EndpointConfig config) { + session.addMessageHandler(new MessageHandler.Whole() { + @Override + public void onMessage(final String message) { + receivedMessage.set(message); + messageLatch.countDown(); + } + }); + } + }, ClientEndpointConfig.Builder.create().build(), wsUri); + + try { + // should receive a heartbeat ping within 1 second + assertTrue("Should receive a message within 2s", messageLatch.await(2, TimeUnit.SECONDS)); + assertEquals("ping", receivedMessage.get()); + } finally { + session.close(); + } + } + + @Test + public void subscribeToChannelAndReceiveMessage() throws Exception { + final int port = existWebServer.getPort(); + final URI wsUri = new URI("ws://localhost:" + port + "/ws"); + + final CountDownLatch subscribedLatch = new CountDownLatch(1); + final CountDownLatch messageLatch = new CountDownLatch(1); + final AtomicReference receivedMessage = new AtomicReference<>(); + + final WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + final Session session = container.connectToServer(new Endpoint() { + @Override + public void onOpen(final Session session, final EndpointConfig config) { + session.addMessageHandler(new MessageHandler.Whole() { + private boolean subscribed = false; + + @Override + public void onMessage(final String message) { + if (!subscribed && "ping".equals(message)) { + // after first ping, subscribe to a channel + try { + session.getBasicRemote().sendText("{\"channel\": \"test-channel\"}"); + subscribed = true; + subscribedLatch.countDown(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else if (subscribed && !"ping".equals(message)) { + receivedMessage.set(message); + messageLatch.countDown(); + } + } + }); + } + }, ClientEndpointConfig.Builder.create().build(), wsUri); + + try { + // wait for subscription + assertTrue("Should subscribe within 2s", subscribedLatch.await(2, TimeUnit.SECONDS)); + // allow server time to process subscription + Thread.sleep(200); + + // send a message to the channel via the module + WebSocketModule.send("test-channel", "{\"hello\": \"world\"}"); + + // should receive the message + assertTrue("Should receive channel message within 2s", messageLatch.await(2, TimeUnit.SECONDS)); + assertEquals("{\"hello\": \"world\"}", receivedMessage.get()); + } finally { + session.close(); + } + } + + @Test + public void channelCountReflectsSubscribers() throws Exception { + final int port = existWebServer.getPort(); + final URI wsUri = new URI("ws://localhost:" + port + "/ws"); + + assertEquals("No subscribers initially", 0, WebSocketEndpoint.getChannelCount("count-test")); + + final WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + final CountDownLatch subscribedLatch = new CountDownLatch(1); + + final Session session = container.connectToServer(new Endpoint() { + @Override + public void onOpen(final Session session, final EndpointConfig config) { + session.addMessageHandler(new MessageHandler.Whole() { + @Override + public void onMessage(final String message) { + if ("ping".equals(message)) { + try { + session.getBasicRemote().sendText("{\"channel\": \"count-test\"}"); + subscribedLatch.countDown(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + }); + } + }, ClientEndpointConfig.Builder.create().build(), wsUri); + + try { + assertTrue("Should subscribe within 2s", subscribedLatch.await(2, TimeUnit.SECONDS)); + // poll for the server to process the subscription; @OnMessage runs + // on a separate thread, so the client-side latch does not guarantee + // the server-side state has been updated yet. + assertTrue("One subscriber within 2s", + awaitChannelCount("count-test", 1, 2000)); + } finally { + session.close(); + // poll for session cleanup so a leaked session cannot affect + // subsequent tests sharing the static sessions map. + awaitChannelCount("count-test", 0, 2000); + } + } + + private static boolean awaitChannelCount(final String channel, final int expected, + final long timeoutMillis) throws InterruptedException { + final long deadline = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < deadline) { + if (WebSocketEndpoint.getChannelCount(channel) == expected) { + return true; + } + Thread.sleep(25); + } + return WebSocketEndpoint.getChannelCount(channel) == expected; + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/xmldb/DbStore2Test.java b/exist-core/src/test/java/org/exist/xquery/functions/xmldb/DbStore2Test.java index aadd2c01baf..bd0bdc61547 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/xmldb/DbStore2Test.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/xmldb/DbStore2Test.java @@ -25,7 +25,6 @@ import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.DefaultHandler; -import org.eclipse.jetty.server.handler.HandlerList; import org.eclipse.jetty.server.handler.ResourceHandler; import org.exist.test.ExistXmldbEmbeddedServer; import org.exist.xmldb.concurrent.DBUtils; @@ -93,12 +92,11 @@ public static void beforeClass() throws Exception { jettyServer = new Server(jettyPort); final ResourceHandler resource_handler = new ResourceHandler(); - resource_handler.setDirectoriesListed(true); + resource_handler.setDirAllowed(true); final String dir = jettyRootDir.toAbsolutePath().toFile().getCanonicalPath(); - resource_handler.setResourceBase(dir); + resource_handler.setBaseResourceAsString(dir); - final HandlerList handlers = new HandlerList(); - handlers.setHandlers(new Handler[]{resource_handler, new DefaultHandler()}); + final Handler.Sequence handlers = new Handler.Sequence(resource_handler, new DefaultHandler()); jettyServer.setHandler(handlers); jettyServer.start(); diff --git a/exist-core/src/test/java/org/exist/xquery/lock/ConcurrencyBenchmark.java b/exist-core/src/test/java/org/exist/xquery/lock/ConcurrencyBenchmark.java new file mode 100644 index 00000000000..fc472450ad9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/lock/ConcurrencyBenchmark.java @@ -0,0 +1,375 @@ +/* + * 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.lock; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.CollectionManagementService; +import org.xmldb.api.modules.XMLResource; +import org.xmldb.api.modules.XQueryService; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Concurrency benchmark for measuring the impact of preclaiming locks + * on read, write, and mixed workloads. + * + *

Run on both the base branch (develop) and the preclaiming branch + * to get before/after comparisons. Results are printed to stdout.

+ * + *

This class intentionally does not end in {@code *Test} so that + * Surefire will not discover it during normal {@code mvn test} runs. + * Run explicitly with:

+ *
+ *   mvn test -pl exist-core -Dtest=ConcurrencyBenchmark \
+ *       -Dexist.run.benchmarks=true -Ddependency-check.skip=true
+ * 
+ */ +public class ConcurrencyBenchmark { + + @ClassRule + public static final ExistXmldbEmbeddedServer server = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String COLLECTION_NAME = "benchmark-concurrency"; + private static final String COLLECTION_PATH = "/db/" + COLLECTION_NAME; + + private static final int NUM_DOCUMENTS = 8; + private static final int DURATION_SECONDS = 5; + private static final int[] THREAD_COUNTS = {2, 4, 8}; + + @BeforeClass + public static void assumeBenchmarks() { + Assume.assumeTrue("Benchmarks are disabled. Set -Dexist.run.benchmarks=true to enable.", + Boolean.getBoolean("exist.run.benchmarks")); + } + + @BeforeClass + public static void setUp() throws XMLDBException { + if (!Boolean.getBoolean("exist.run.benchmarks")) { + return; + } + + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + cms.createCollection(COLLECTION_NAME); + + final Collection col = server.getRoot().getChildCollection(COLLECTION_NAME); + for (int i = 1; i <= NUM_DOCUMENTS; i++) { + final XMLResource res = col.createResource("doc" + i + ".xml", XMLResource.class); + res.setContent("0" + "x".repeat(100) + ""); + col.storeResource(res); + } + } + + @AfterClass + public static void tearDown() throws XMLDBException { + if (!Boolean.getBoolean("exist.run.benchmarks")) { + return; + } + + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + cms.removeCollection(COLLECTION_NAME); + } + + // ---- Scenario 1: Concurrent reads ---- + + @Test + public void concurrentReads() throws Exception { + System.out.println("\n=== Concurrent Reads (all threads read same document) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + qs.query("doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter/text()"); + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "read-same-doc", threads, opsPerSec); + } + } + + @Test + public void concurrentReadsDistributed() throws Exception { + System.out.println("\n=== Concurrent Reads (each thread reads different doc) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + final int docNum = (threadId % NUM_DOCUMENTS) + 1; + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + qs.query("doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/data/text()"); + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "read-diff-docs", threads, opsPerSec); + } + } + + // ---- Scenario 2: Concurrent writes ---- + + @Test + public void concurrentWritesSameDoc() throws Exception { + System.out.println("\n=== Concurrent Writes (all threads update same document) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + resetDocuments(); + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + qs.query("update value doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter " + + "with string(number(doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter) + 1)"); + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "write-same-doc", threads, opsPerSec); + } + } + + @Test + public void concurrentWritesDifferentDocs() throws Exception { + System.out.println("\n=== Concurrent Writes (each thread updates its own document) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + resetDocuments(); + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + final int docNum = (threadId % NUM_DOCUMENTS) + 1; + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + qs.query("update value doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter " + + "with string(number(doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter) + 1)"); + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "write-diff-docs", threads, opsPerSec); + } + } + + // ---- Scenario 3: Mixed read/write ---- + + @Test + public void mixedReadWrite() throws Exception { + System.out.println("\n=== Mixed Read/Write (half readers, half writers, same document) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + resetDocuments(); + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + final boolean isWriter = (threadId % 2 == 0); + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + if (isWriter) { + qs.query("update value doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter " + + "with string(number(doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter) + 1)"); + } else { + qs.query("doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter/text()"); + } + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "mixed-same-doc", threads, opsPerSec); + } + } + + @Test + public void mixedReadWriteDistributed() throws Exception { + System.out.println("\n=== Mixed Read/Write (half readers, half writers, different documents) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + resetDocuments(); + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + final boolean isWriter = (threadId % 2 == 0); + final int docNum = (threadId % NUM_DOCUMENTS) + 1; + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + if (isWriter) { + qs.query("update value doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter " + + "with string(number(doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter) + 1)"); + } else { + qs.query("doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter/text()"); + } + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "mixed-diff-docs", threads, opsPerSec); + } + } + + // ---- Scenario 4: Write contention with XQUF (deferred PUL) ---- + + @Test + public void concurrentXQUFWritesSameDoc() throws Exception { + System.out.println("\n=== Concurrent XQUF Writes (all threads update same document via PUL) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + resetDocuments(); + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + qs.query("replace value of node doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter " + + "with string(number(doc('" + COLLECTION_PATH + "/doc1.xml')/root/counter) + 1)"); + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "xquf-write-same-doc", threads, opsPerSec); + } + } + + @Test + public void concurrentXQUFWritesDifferentDocs() throws Exception { + System.out.println("\n=== Concurrent XQUF Writes (each thread updates its own document via PUL) ==="); + printHeader(); + + for (final int threads : THREAD_COUNTS) { + resetDocuments(); + final double opsPerSec = runBenchmark(threads, (threadId, barrier, opsCounter, errorCounter) -> { + final XQueryService qs = server.getRoot().getService(XQueryService.class); + final int docNum = (threadId % NUM_DOCUMENTS) + 1; + barrier.await(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(DURATION_SECONDS); + while (System.nanoTime() < deadline) { + try { + qs.query("replace value of node doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter " + + "with string(number(doc('" + COLLECTION_PATH + "/doc" + docNum + ".xml')/root/counter) + 1)"); + opsCounter.incrementAndGet(); + } catch (final XMLDBException e) { + errorCounter.incrementAndGet(); + } + } + }); + System.out.printf(" %-24s threads=%d ops/sec=%8.0f%n", "xquf-write-diff-docs", threads, opsPerSec); + } + } + + // ---- Helpers ---- + + @FunctionalInterface + interface BenchmarkTask { + void run(int threadId, CyclicBarrier barrier, AtomicInteger opsCounter, AtomicInteger errorCounter) throws Exception; + } + + private double runBenchmark(final int numThreads, final BenchmarkTask task) throws Exception { + final ExecutorService executor = Executors.newFixedThreadPool(numThreads); + final CyclicBarrier barrier = new CyclicBarrier(numThreads); + final AtomicInteger totalOps = new AtomicInteger(0); + final AtomicInteger totalErrors = new AtomicInteger(0); + final List> futures = new ArrayList<>(); + + for (int t = 0; t < numThreads; t++) { + final int threadId = t; + futures.add(executor.submit(() -> { + task.run(threadId, barrier, totalOps, totalErrors); + return null; + })); + } + + for (final Future f : futures) { + f.get(DURATION_SECONDS + 10, TimeUnit.SECONDS); + } + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + + if (totalErrors.get() > 0) { + System.out.printf(" (errors: %d)%n", totalErrors.get()); + } + + return totalOps.get() / (double) DURATION_SECONDS; + } + + private void resetDocuments() throws XMLDBException { + final Collection col = server.getRoot().getChildCollection(COLLECTION_NAME); + for (int i = 1; i <= NUM_DOCUMENTS; i++) { + final XMLResource res = col.createResource("doc" + i + ".xml", XMLResource.class); + res.setContent("0" + "x".repeat(100) + ""); + col.storeResource(res); + } + } + + private static void printHeader() { + System.out.printf(" %-24s %8s %12s%n", "Scenario", "Threads", "Ops/sec"); + System.out.println(" " + "=".repeat(50)); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/lock/LockTargetCollectorTest.java b/exist-core/src/test/java/org/exist/xquery/lock/LockTargetCollectorTest.java new file mode 100644 index 00000000000..d746a9a9895 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/lock/LockTargetCollectorTest.java @@ -0,0 +1,182 @@ +/* + * 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.lock; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.source.StringSource; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xmldb.XmldbURI; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.Expression; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.XQueryContext; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Optional; + +import static org.junit.Assert.*; + +/** + * Tests for {@link LockTargetCollector} — verifies that the expression tree + * visitor correctly identifies document and collection targets from XQuery + * expressions at compile time. + */ +public class LockTargetCollectorTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = + new ExistEmbeddedServer(true, true); + + @Test + public void staticDocCall() throws EXistException, PermissionDeniedException, + XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "doc('/db/test/data.xml')//item"); + + assertFalse(collector.requiresGlobalLock()); + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/data.xml"))); + assertTrue(collector.getCollectionTargets().isEmpty()); + } + + @Test + public void staticCollectionCall() throws EXistException, PermissionDeniedException, + XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "collection('/db/test')//item"); + + assertFalse(collector.requiresGlobalLock()); + assertTrue(collector.getCollectionTargets().contains( + XmldbURI.xmldbUriFor("/db/test"))); + assertTrue(collector.getDocumentTargets().isEmpty()); + } + + @Test + public void dynamicDocCallRequiresGlobalLock() throws EXistException, + PermissionDeniedException, XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "let $path := '/db/test/data.xml' return doc($path)//item"); + + assertTrue(collector.requiresGlobalLock()); + } + + @Test + public void multipleStaticDocCalls() throws EXistException, PermissionDeniedException, + XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "doc('/db/test/a.xml')//x | doc('/db/test/b.xml')//y"); + + assertFalse("Global lock should not be required for static doc calls", + collector.requiresGlobalLock()); + assertEquals("Should find 2 doc targets, found: " + collector.getDocumentTargets(), + 2, collector.getDocumentTargets().size()); + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/a.xml"))); + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/b.xml"))); + } + + @Test + public void mixedStaticAndDynamicRequiresGlobalLock() throws EXistException, + PermissionDeniedException, XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "let $p := '/db/dynamic.xml' " + + "return (doc('/db/test/static.xml'), doc($p))"); + + // Even though one target is static, the dynamic one forces global lock + assertTrue(collector.requiresGlobalLock()); + // Static target should still be collected + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/static.xml"))); + } + + @Test + public void noDocOrCollectionCalls() throws EXistException, PermissionDeniedException, + XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "1 + 2"); + + assertFalse(collector.requiresGlobalLock()); + assertTrue(collector.getDocumentTargets().isEmpty()); + assertTrue(collector.getCollectionTargets().isEmpty()); + assertFalse(collector.hasTargets()); + } + + @Test + public void docInFLWOR() throws EXistException, PermissionDeniedException, + XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "for $x in doc('/db/test/data.xml')//item " + + "return $x/name"); + + assertFalse(collector.requiresGlobalLock()); + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/data.xml"))); + } + + @Test + public void docInConditional() throws EXistException, PermissionDeniedException, + XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets( + "if (true()) then doc('/db/test/a.xml') else doc('/db/test/b.xml')"); + + assertFalse(collector.requiresGlobalLock()); + assertEquals(2, collector.getDocumentTargets().size()); + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/a.xml"))); + assertTrue(collector.getDocumentTargets().contains( + XmldbURI.xmldbUriFor("/db/test/b.xml"))); + } + + @Test + public void zeroArgCollectionRequiresGlobalLock() throws EXistException, + PermissionDeniedException, XPathException, IOException, URISyntaxException { + final LockTargetCollector collector = collectTargets("collection()"); + + assertTrue(collector.requiresGlobalLock()); + } + + // --- Helper --- + + private LockTargetCollector collectTargets(final String xquery) + throws EXistException, PermissionDeniedException, XPathException, IOException, URISyntaxException { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject()))) { + final XQuery xqueryService = pool.getXQueryService(); + final XQueryContext context = new XQueryContext(pool); + final CompiledXQuery compiled = xqueryService.compile(context, + new StringSource(xquery)); + + final LockTargetCollector collector = new LockTargetCollector(); + // CompiledXQuery is implemented by PathExpr which is an Expression + collector.collect((Expression) compiled); + return collector; + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/ParserBenchmark.java b/exist-core/src/test/java/org/exist/xquery/parser/ParserBenchmark.java new file mode 100644 index 00000000000..b5fe8dd8cd9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/ParserBenchmark.java @@ -0,0 +1,284 @@ +/* + * 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.parser; + +import antlr.collections.AST; +import org.exist.util.Configuration; +import org.exist.xquery.PathExpr; +import org.exist.xquery.XQueryContext; +import org.junit.Test; + +import java.io.StringReader; +import java.util.Locale; + +/** + * Microbenchmark for the ANTLR 2 XQuery parser. Times the lexer+parser + * (AST construction) and the tree walker (semantic build) separately on + * a set of representative queries. + * + * Run with: mvn -pl exist-core test + * -Dtest=ParserBenchmark#runBenchmark + * -Dexist.parserbench.iterations=20000 + * + * The test is @Ignore'd by default so it doesn't run in CI. + */ +public class ParserBenchmark { + + private static final int DEFAULT_ITERATIONS = 20_000; + private static final int WARMUP_ITERATIONS = 2_000; + + private static final class Sample { + final String name; + final String query; + Sample(final String name, final String query) { + this.name = name; + this.query = query; + } + } + + private static final Sample[] SAMPLES = { + new Sample("simple-path", + "//book[@id = '123']/title/text()"), + + new Sample("xpath-predicates", + "/library/section[@type='fiction']" + + "/book[author/last = 'Smith' and year >= 2000]" + + "/title[1]/text()"), + + new Sample("flwor-medium", + "xquery version \"3.1\";\n" + + "for $b in //book\n" + + "let $author := $b/author\n" + + "where $b/year >= 2000\n" + + "order by $b/title\n" + + "return { $author/text(), $b/title/text() }"), + + new Sample("flwor-grouping", + "xquery version \"3.1\";\n" + + "for $b in //book\n" + + "let $cat := $b/@category\n" + + "group by $cat\n" + + "order by $cat\n" + + "return { count($b) }"), + + new Sample("user-function", + "xquery version \"3.1\";\n" + + "declare function local:fact($n as xs:integer) as xs:integer {\n" + + " if ($n <= 1) then 1 else $n * local:fact($n - 1)\n" + + "};\n" + + "local:fact(20)"), + + new Sample("typeswitch", + "xquery version \"3.1\";\n" + + "declare function local:fmt($v) {\n" + + " typeswitch ($v)\n" + + " case xs:integer return concat('int=', $v)\n" + + " case xs:string return concat('str=', $v)\n" + + " case element() return concat('elem=', local-name($v))\n" + + " default return 'unknown'\n" + + "};\n" + + "for $x in (1, 'a',

) return local:fmt($x)"), + + new Sample("module-import", + "xquery version \"3.1\";\n" + + "import module namespace fn = \"http://www.w3.org/2005/xpath-functions\";\n" + + "import module namespace map = \"http://www.w3.org/2005/xpath-functions/map\";\n" + + "import module namespace array = \"http://www.w3.org/2005/xpath-functions/array\";\n" + + "let $m := map { 'a': 1, 'b': 2, 'c': 3 }\n" + + "let $a := [ 1, 2, 3, 4, 5 ]\n" + + "for $k in map:keys($m)\n" + + "return map:get($m, $k)"), + + new Sample("element-constructor", + "xquery version \"3.1\";\n" + + "\n" + + " { /book/title/string() }\n" + + " \n" + + " { for $c in /book/chapter\n" + + " return

\n" + + "

{ $c/title/string() }

\n" + + " { for $p in $c//para return

{ $p/text() }

}\n" + + "
}\n" + + " \n" + + ""), + + // Realistic application code with camelCase identifiers, underscored + // names, and digits -- the case where the shape filter short-circuits + // the keyword-table lookup. + new Sample("app-camelcase", + "xquery version \"3.1\";\n" + + "declare function local:renderArticle($articleNode as element()) as element() {\n" + + " let $articleId := $articleNode/@xmlId\n" + + " let $authorList := $articleNode/teiHeader/fileDesc/titleStmt/author\n" + + " let $publishDate := $articleNode/teiHeader/fileDesc/publicationStmt/date/@when\n" + + " let $bodyChunks := $articleNode/text/body/div\n" + + " return \n" + + " { string-join($authorList/persName/string(), ', ') }\n" + + " { for $bodyChunk at $chunkIndex in $bodyChunks\n" + + " let $chunkId := concat('chunk_', $chunkIndex)\n" + + " let $headingNode := $bodyChunk/head[1]\n" + + " return \n" + + " { $headingNode/string() }\n" + + " { for $paragraphNode in $bodyChunk/p return\n" + + " { $paragraphNode/string() } }\n" + + " }\n" + + " \n" + + "};\n" + + "local:renderArticle()\n") + }; + + private static AST parseOnly(final String query) throws Exception { + final XQueryLexer lexer = new XQueryLexer(null, new StringReader(query)); + final XQueryParser parser = new XQueryParser(lexer); + parser.xpath(); + if (parser.foundErrors()) { + throw new RuntimeException("parse error: " + parser.getErrorMessage()); + } + return parser.getAST(); + } + + private static volatile Configuration sharedConfig; + + private static Configuration sharedConfig() { + Configuration c = sharedConfig; + if (c == null) { + synchronized (ParserBenchmark.class) { + c = sharedConfig; + if (c == null) { + try { + c = new Configuration(); + } catch (final Exception e) { + throw new RuntimeException(e); + } + sharedConfig = c; + } + } + } + return c; + } + + private static void treeWalk(final AST ast) throws Exception { + final XQueryContext context = new XQueryContext(null, sharedConfig(), null); + final PathExpr expr = new PathExpr(context); + final XQueryTreeParser treeParser = new XQueryTreeParser(context); + treeParser.xpath(ast, expr); + if (treeParser.foundErrors()) { + throw new RuntimeException("tree-walk error: " + treeParser.getErrorMessage()); + } + } + + private static long time(final Runnable r, final int iters) { + final long start = System.nanoTime(); + for (int i = 0; i < iters; i++) { + r.run(); + } + return System.nanoTime() - start; + } + + /** + * Smoke-check: every sample must parse and tree-walk cleanly. + * Run with -Dtest=ParserBenchmark#smoke + */ + @Test + public void smoke() throws Exception { + for (final Sample s : SAMPLES) { + final AST ast = parseOnly(s.query); + treeWalk(ast); + } + } + + /** + * Print per-sample parse and tree-walk timings. + * Run with -Dtest=ParserBenchmark#runBenchmark + */ + @Test + public void runBenchmark() throws Exception { + // Skip unless explicitly requested via -Dexist.parserbench.run=true + if (!Boolean.getBoolean("exist.parserbench.run")) { + return; + } + runBenchmarkImpl(); + } + + public static void main(final String[] args) throws Exception { + runBenchmarkImpl(); + } + + private static void runBenchmarkImpl() throws Exception { + final int iters = Integer.getInteger( + "exist.parserbench.iterations", DEFAULT_ITERATIONS).intValue(); + final int warmup = Integer.getInteger( + "exist.parserbench.warmup", WARMUP_ITERATIONS).intValue(); + + System.out.println("=== ParserBenchmark ==="); + System.out.printf(Locale.ROOT, + "warmup=%d iterations=%d java=%s%n", + warmup, iters, System.getProperty("java.version")); + System.out.println(); + System.out.printf(Locale.ROOT, + "%-22s %12s %12s %12s %12s %5s%n", + "sample", "parse us/op", "tree us/op", "total us/op", "throughput/s", "len"); + System.out.println("------------------------------------------------------------------------------------------"); + + for (final Sample s : SAMPLES) { + // warmup parse + for (int i = 0; i < warmup; i++) { + parseOnly(s.query); + } + // measure parse + final long parseNanos = time(() -> { + try { + parseOnly(s.query); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }, iters); + + // pre-build one AST for tree-walk timing + final AST astTemplate = parseOnly(s.query); + + // warmup tree-walk + for (int i = 0; i < warmup; i++) { + final AST ast = parseOnly(s.query); + treeWalk(ast); + } + // measure tree-walk only by subtracting parse time from total + final long totalNanos = time(() -> { + try { + final AST ast = parseOnly(s.query); + treeWalk(ast); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }, iters); + + final double parseUs = parseNanos / 1000.0 / iters; + final double totalUs = totalNanos / 1000.0 / iters; + final double treeUs = totalUs - parseUs; + final double thrPs = 1_000_000.0 / totalUs; + System.out.printf(Locale.ROOT, + "%-22s %12.3f %12.3f %12.3f %12.0f %5d%n", + s.name, parseUs, treeUs, totalUs, thrPs, s.query.length()); + } + System.out.println(); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/next/LexerBenchmark.java b/exist-core/src/test/java/org/exist/xquery/parser/next/LexerBenchmark.java new file mode 100644 index 00000000000..4989a9d8515 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/next/LexerBenchmark.java @@ -0,0 +1,153 @@ +/* + * 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.parser.next; + +import org.junit.Test; + +import java.util.List; + +/** + * Micro-benchmark comparing the hand-written lexer against ANTLR 2. + * + *

This is a simple wall-clock benchmark, not a JMH benchmark. + * It's useful for quick sanity checks during development. For + * production benchmarking, use JMH.

+ * + *

Run with: {@code mvn test -pl exist-core -Dtest=LexerBenchmark}

+ */ +public class LexerBenchmark { + + /** + * A representative FLWOR expression with multiple clauses, + * function calls, path expressions, and various token types. + */ + private static final String FLWOR_QUERY = + "xquery version \"3.1\";\n" + + "declare namespace tei = \"http://www.tei-c.org/ns/1.0\";\n" + + "declare variable $collection := \"/db/apps/shakespeare\";\n" + + "\n" + + "for $play in collection($collection)//tei:TEI\n" + + "let $title := $play//tei:titleStmt/tei:title/string()\n" + + "let $acts := count($play//tei:div[@type = 'act'])\n" + + "where $acts > 3\n" + + "order by $title ascending\n" + + "return\n" + + " \n" + + " {$title}\n" + + " {$acts}\n" + + " "; + + /** + * A complex expression with many keywords to stress-test keyword handling. + */ + private static final String KEYWORD_HEAVY = + "for $x in (1 to 100)\n" + + "let $y := $x * 2\n" + + "let $z := if ($x mod 3 eq 0) then 'fizz'\n" + + " else if ($x mod 5 eq 0) then 'buzz'\n" + + " else if ($x mod 15 eq 0) then 'fizzbuzz'\n" + + " else string($x)\n" + + "where $x instance of xs:integer\n" + + " and ($x castable as xs:double)\n" + + " and not($x = (7, 13, 17))\n" + + "group by $bucket := $x idiv 10\n" + + "order by $bucket ascending empty greatest\n" + + "count $pos\n" + + "return\n" + + " \n" + + " { for $item in $z return {$item} }\n" + + " "; + + /** + * XQuery 4.0 syntax: pipeline, mapping arrow, string templates. + */ + private static final String XQ4_SYNTAX = + "let $data := (1, 2, 3, 4, 5)\n" + + "return $data\n" + + " -> fn:filter(fn:is-NaN#1)\n" + + " -> fn:sort((), fn:compare#2)\n" + + " =!> fn:for-each(function($x) { $x * $x })\n" + + " ?? ()\n" + + " otherwise 'empty'"; + + private static final int WARMUP_ITERATIONS = 5_000; + private static final int MEASURED_ITERATIONS = 50_000; + + @Test + public void benchmarkFlworQuery() { + runBenchmark("FLWOR query", FLWOR_QUERY); + } + + @Test + public void benchmarkKeywordHeavy() { + runBenchmark("Keyword-heavy", KEYWORD_HEAVY); + } + + @Test + public void benchmarkXQ4Syntax() { + runBenchmark("XQ4 syntax", XQ4_SYNTAX); + } + + @Test + public void benchmarkTokenCount() { + // Report token counts for reference + System.out.println("\n=== Token counts ==="); + reportTokenCount("FLWOR query", FLWOR_QUERY); + reportTokenCount("Keyword-heavy", KEYWORD_HEAVY); + reportTokenCount("XQ4 syntax", XQ4_SYNTAX); + } + + private void runBenchmark(final String label, final String query) { + // Warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + new XQueryLexer(query).tokenizeAll(); + } + + // Measured + final long start = System.nanoTime(); + int totalTokens = 0; + for (int i = 0; i < MEASURED_ITERATIONS; i++) { + totalTokens += new XQueryLexer(query).tokenizeAll().size(); + } + final long elapsed = System.nanoTime() - start; + + final double avgMicros = (elapsed / 1_000.0) / MEASURED_ITERATIONS; + final double tokensPerSec = (totalTokens / (elapsed / 1_000_000_000.0)); + + System.out.printf("\n=== %s ===%n", label); + System.out.printf(" Average: %.1f µs per tokenization%n", avgMicros); + System.out.printf(" Throughput: %.0f tokens/sec%n", tokensPerSec); + System.out.printf(" Tokens per query: %d%n", totalTokens / MEASURED_ITERATIONS); + System.out.printf(" Query length: %d chars%n", query.length()); + } + + private void reportTokenCount(final String label, final String query) { + final List tokens = new XQueryLexer(query).tokenizeAll(); + System.out.printf(" %s: %d tokens from %d chars%n", + label, tokens.size(), query.length()); + // Print first few tokens for verification + for (int i = 0; i < Math.min(5, tokens.size()); i++) { + System.out.printf(" [%d] %s%n", i, tokens.get(i)); + } + System.out.println(" ..."); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/next/NativeParserIntegrationTest.java b/exist-core/src/test/java/org/exist/xquery/parser/next/NativeParserIntegrationTest.java new file mode 100644 index 00000000000..a05e8e85c8a --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/next/NativeParserIntegrationTest.java @@ -0,0 +1,326 @@ +/* + * 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.parser.next; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQuery; +import org.exist.xquery.value.Sequence; +import org.junit.*; + +import static org.junit.Assert.*; + +/** + * Integration tests that exercise the native parser through eXist's + * standard XQuery.execute() path, using the exist.parser=native system property. + * + *

These tests verify that the feature flag correctly routes queries + * to the hand-written parser AND that the resulting Expression trees + * evaluate correctly through eXist's full execution pipeline.

+ */ +public class NativeParserIntegrationTest { + + @ClassRule + public static final ExistEmbeddedServer server = new ExistEmbeddedServer(true, true); + + @BeforeClass + public static void enableNativeParser() { + System.setProperty(XQuery.PROPERTY_PARSER, "rd"); + } + + @AfterClass + public static void restoreDefaultParser() { + System.clearProperty(XQuery.PROPERTY_PARSER); + } + + // ======================================================================== + // Basic expressions via the full compilation pipeline + // ======================================================================== + + @Test + public void simpleArithmetic() throws Exception { + assertQuery("42", "40 + 2"); + } + + @Test + public void stringConcat() throws Exception { + assertQuery("hello world", "'hello' || ' ' || 'world'"); + } + + @Test + public void flwor() throws Exception { + assertQuery("2 4 6", "for $x in (1, 2, 3) return $x * 2"); + } + + @Test + public void flworWithWhereOrderBy() throws Exception { + assertQuery("10 9 8 7 6", + "for $x in 1 to 10 where $x > 5 order by $x descending return $x"); + } + + @Test + public void functionDeclaration() throws Exception { + assertQuery("Hello, World", + "declare function local:greet($name) { 'Hello, ' || $name };\n" + + "local:greet('World')"); + } + + @Test + public void variableDeclaration() throws Exception { + assertQuery("42", + "declare variable $x := 42;\n$x"); + } + + @Test + public void moduleImport() throws Exception { + assertQuery("true", + "import module namespace util = 'http://exist-db.org/xquery/util';\n" + + "not(empty(util:system-property('product-version')))"); + } + + @Test + public void ifThenElse() throws Exception { + assertQuery("yes", "if (1 = 1) then 'yes' else 'no'"); + } + + @Test + public void typeswitch() throws Exception { + assertQuery("str", + "typeswitch ('hello') case xs:integer return 'int' " + + "case xs:string return 'str' default return 'other'"); + } + + @Test + public void tryCatch() throws Exception { + assertQuery("caught", "try { error() } catch * { 'caught' }"); + } + + @Test + public void inlineFunction() throws Exception { + assertQuery("42", "let $f := function($x) { $x * 2 } return $f(21)"); + } + + @Test + public void namedFunctionRef() throws Exception { + assertQuery("3", "let $f := fn:count#1 return $f((1, 2, 3))"); + } + + @Test + public void selfClosingElement() throws Exception { + assertQuery("hello", "name()"); + } + + @Test + public void elementWithAttribute() throws Exception { + assertQuery("main", "string(
/@class)"); + } + + @Test + public void elementWithTextContent() throws Exception { + assertQuery("hello", "string(hello)"); + } + + @Test + public void stringTemplate() throws Exception { + assertQuery("The answer is 42.", + "xquery version '4.0';\nlet $x := 42 return ``[The answer is `{$x}`.]``"); + } + + @Test + public void pipelineOperator() throws Exception { + assertQuery("5", "xquery version '4.0';\n(1, 2, 3, 4, 5) -> count()"); + } + + @Test + public void otherwiseExpr() throws Exception { + assertQuery("default", "xquery version '4.0';\n() otherwise 'default'"); + } + + @Test + public void simpleMap() throws Exception { + assertQuery("2 4 6", "(1, 2, 3) ! (. * 2)"); + } + + // ======================================================================== + // Arrays, maps, and lookups + // ======================================================================== + + @Test + public void squareArrayConstructor() throws Exception { + assertQuery("3", "array:size([1, 2, 3])"); + } + + @Test + public void curlyArrayConstructor() throws Exception { + assertQuery("5", "array:size(array { 1 to 5 })"); + } + + @Test + public void mapConstructor() throws Exception { + assertQuery("eXist", "map { 'name': 'eXist', 'version': 7 }?name"); + } + + @Test + public void emptyMap() throws Exception { + assertQuery("0", "map:size(map {})"); + } + + @Test + public void mapLookupVariable() throws Exception { + assertQuery("1", "let $m := map { 'a': 1, 'b': 2 } return $m?a"); + } + + @Test + public void arrayLookupByPosition() throws Exception { + assertQuery("y", "let $a := ['x', 'y', 'z'] return $a(2)"); + } + + @Test + public void chainedLookup() throws Exception { + assertQuery("1 2 3", "let $d := map { 'items': [1, 2, 3] } return $d?items?*"); + } + + @Test + public void arrayInFlwor() throws Exception { + assertQuery("2 4 6", "array:flatten(array { for $i in 1 to 3 return $i * 2 })"); + } + + + @Ignore("array:get 3-arg is XQ4, requires v2/xq4-core-functions") + @Test + public void arrayGetThreeArgs() throws Exception { + assertQuery("2", "array:get([1,2,3], 2, ())"); + } + + @Test + public void fnParseXml() throws Exception { + assertQuery("true", "parse-xml('') instance of document-node()"); + } + + // ================================================================= // ======================================================================== + // Path expression patterns (regression tests for the path fix) + // ======================================================================== + + @Test + public void pathAfterVariable() throws Exception { + assertQuery("1", "let $x := 1 return string($x/a)"); + } + + @Test + public void kindTestText() throws Exception { + assertQuery("hello", "let $x := hello return $x/text()"); + } + + @Test + public void kindTestNode() throws Exception { + assertQuery("1", "let $x := return count($x/node())"); + } + + // ======================================================================== + // Version gating: XQ4 features rejected in 3.1 mode + // ======================================================================== + + @Test + public void pipelineRejectedIn31() throws Exception { + assertQueryError("xquery version '3.1';\n(1,2,3) -> count()", + "requires xquery version \"4.0\""); + } + + @Test + public void otherwiseRejectedIn31() throws Exception { + assertQueryError("xquery version '3.1';\n() otherwise 'x'", + "requires xquery version \"4.0\""); + } + + @Test + public void pipelineWorksIn40() throws Exception { + assertQuery("5", "xquery version '4.0';\n(1,2,3,4,5) -> count()"); + } + + @Test + public void arrowWorksIn31() throws Exception { + // XQ 3.1 arrow => is not gated + assertQuery("HELLO", "xquery version '3.1';\n'hello' => upper-case()"); + } + + @Test + public void noVersionDefaultsTo31() throws Exception { + // No declaration = 3.1 behavior — XQ4 syntax rejected + assertQueryError("() otherwise 'x'", + "requires xquery version \"4.0\""); + } + + @Test + public void xqufNotGated() throws Exception { + // XQUF is not version-gated — parses OK in 3.1 mode (eval requires real XQUF classes) + assertQuery("true", "xquery version '3.1';\ntrue()"); // placeholder — XQUF eval needs next branch + } + + // ======================================================================== + // Verify ANTLR 2 still works when flag is not set + // ======================================================================== + + @Test + public void antlr2StillWorks() throws Exception { + // Temporarily switch back to ANTLR 2 + System.setProperty(XQuery.PROPERTY_PARSER, "antlr2"); + try { + assertQuery("42", "40 + 2"); + } finally { + System.setProperty(XQuery.PROPERTY_PARSER, "rd"); + } + } + + // ======================================================================== + // Helper + // ======================================================================== + + private void assertQuery(final String expected, final String query) throws Exception { + final BrokerPool pool = server.getBrokerPool(); + final XQuery xquery = pool.getXQueryService(); + try (final DBBroker broker = pool.getBroker()) { + final Sequence result = xquery.execute(broker, query, null); + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < result.getItemCount(); i++) { + if (i > 0) sb.append(' '); + sb.append(result.itemAt(i).getStringValue()); + } + assertEquals("Query: " + query, expected, sb.toString()); + } + } + + private void assertQueryError(final String query, final String expectedMessagePart) throws Exception { + final BrokerPool pool = server.getBrokerPool(); + final XQuery xquery = pool.getXQueryService(); + try (final DBBroker broker = pool.getBroker()) { + xquery.execute(broker, query, null); + fail("Expected error for query: " + query); + } catch (final Exception e) { + assertTrue("Expected error containing '" + expectedMessagePart + "' but got: " + e.getMessage(), + e.getMessage().contains(expectedMessagePart)); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/next/ParserBenchmark.java b/exist-core/src/test/java/org/exist/xquery/parser/next/ParserBenchmark.java new file mode 100644 index 00000000000..805fd40a8ea --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/next/ParserBenchmark.java @@ -0,0 +1,138 @@ +/* + * 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.parser.next; + +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.XQueryContext; +import org.junit.ClassRule; +import org.junit.Test; + +/** + * Micro-benchmark for the hand-written parser. + * Measures parse time (lexer + parser) for increasingly complex queries. + * + *

Target: FLWOR parsing ≤ 45μs (develop baseline with ANTLR 2).

+ */ +public class ParserBenchmark { + + @ClassRule + public static final ExistEmbeddedServer server = new ExistEmbeddedServer(true, true); + + private static final String SIMPLE_EXPR = "1 + 2 * 3"; + + private static final String SIMPLE_FLWOR = + "for $x in 1 to 10 return $x * 2"; + + private static final String FULL_FLWOR = + "for $x in 1 to 100 " + + "let $y := $x * 2 " + + "where $x > 50 " + + "order by $y descending " + + "return $y"; + + private static final String COMPLEX_FLWOR = + "for $x in 1 to 100 " + + "let $y := $x * 2 " + + "let $z := $x mod 3 " + + "where $x > 10 and $x < 90 " + + "order by $z ascending, $y descending " + + "count $pos " + + "return $pos || ':' || string($y)"; + + private static final String NESTED_EXPR = + "let $data := (1, 2, 3, 4, 5) " + + "return " + + " if (count($data) > 3) " + + " then for $x in $data where $x > 2 return $x * $x " + + " else ()"; + + private static final String TYPESWITCH_EXPR = + "typeswitch (42) " + + "case xs:string return 'string' " + + "case xs:integer return 'integer' " + + "case xs:double return 'double' " + + "default return 'other'"; + + private static final String XQUF_TRANSFORM = + "copy $c := old " + + "modify (replace value of node $c/item with 'new', " + + "insert node into $c) " + + "return $c"; + + private static final String XQFT_CONTAINS = + "'XML database engine' contains text 'XML' ftand 'database' " + + "using stemming using language 'en'"; + + private static final String XQ4_PIPELINE = + "(1, 2, 3, 4, 5) -> count() + " + + "('hello', 'world') =!> upper-case() => string-join(' ')"; + + private static final int WARMUP = 10_000; + private static final int MEASURED = 50_000; + + @Test + public void benchmarkAll() throws Exception { + final BrokerPool pool = server.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + System.out.println("\n=== Parser Benchmarks (lexer + parse + Expression tree) ==="); + runParserBenchmark(pool, "Simple expr (1+2*3)", SIMPLE_EXPR); + runParserBenchmark(pool, "Simple FLWOR", SIMPLE_FLWOR); + runParserBenchmark(pool, "Full FLWOR (where+order)", FULL_FLWOR); + runParserBenchmark(pool, "Complex FLWOR", COMPLEX_FLWOR); + runParserBenchmark(pool, "Nested if+FLWOR", NESTED_EXPR); + runParserBenchmark(pool, "Typeswitch", TYPESWITCH_EXPR); + runParserBenchmark(pool, "XQUF transform", XQUF_TRANSFORM); + runParserBenchmark(pool, "XQFT contains text", XQFT_CONTAINS); + runParserBenchmark(pool, "XQ4 pipeline+arrow", XQ4_PIPELINE); + } + } + + private void runParserBenchmark(final BrokerPool pool, final String label, final String query) + throws Exception { + // Warmup + for (int i = 0; i < WARMUP; i++) { + final XQueryContext ctx = new XQueryContext(pool); + try { + new XQueryParser(ctx, query).parseExpression(); + } finally { + ctx.reset(); + } + } + + // Measure + final long start = System.nanoTime(); + for (int i = 0; i < MEASURED; i++) { + final XQueryContext ctx = new XQueryContext(pool); + try { + new XQueryParser(ctx, query).parseExpression(); + } finally { + ctx.reset(); + } + } + final long elapsed = System.nanoTime() - start; + final double avgMicros = (elapsed / 1_000.0) / MEASURED; + + System.out.printf(" %-30s %.1f µs (%d chars)%n", label, avgMicros, query.length()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/next/ParserComparisonBenchmark.java b/exist-core/src/test/java/org/exist/xquery/parser/next/ParserComparisonBenchmark.java new file mode 100644 index 00000000000..2ec44a0f5d9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/next/ParserComparisonBenchmark.java @@ -0,0 +1,219 @@ +/* + * 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.parser.next; + +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.Expression; +import org.exist.xquery.PathExpr; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.parser.XQueryAST; +import org.exist.xquery.parser.XQueryTreeParser; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.StringReader; + +/** + * Side-by-side parse-only comparison: hand-written RD parser vs ANTLR 2. + * + *

"Parse-only" means: from source string to Expression tree. + * For ANTLR, that's lexer + parser (AST) + tree walker (Expression). + * For RD, that's lexer + parser (Expression directly).

+ * + *

Run with: + * {@code mvn test -pl exist-core -Dtest=ParserComparisonBenchmark + * -Ddependency-check.skip=true -Ddocker=false}

+ */ +public class ParserComparisonBenchmark { + + @ClassRule + public static final ExistEmbeddedServer server = new ExistEmbeddedServer(true, true); + + private static final int WARMUP = 2_000; + private static final int MEASURED = 10_000; + /** Iterations for slow queries (where ANTLR alone takes ~1ms+ per parse). */ + private static final int LARGE_WARMUP = 50; + private static final int LARGE_MEASURED = 200; + + /** Queries marked with isXq4=true require xquery version "4.0" context. */ + private static final Query[] QUERIES = { + new Query("Simple expr", + "1 + 2 * 3", false), + new Query("Path with predicate", + "//book[author = 'Smith']/title", false), + new Query("Simple FLWOR", + "for $x in 1 to 10 return $x * 2", false), + new Query("Full FLWOR", + "for $x in 1 to 100 " + + "let $y := $x * 2 " + + "where $x > 50 " + + "order by $y descending " + + "return $y", false), + new Query("Complex FLWOR", + "for $x in 1 to 100 " + + "let $y := $x * 2 " + + "let $z := $x mod 3 " + + "where $x > 10 and $x < 90 " + + "order by $z ascending, $y descending " + + "count $pos " + + "return $pos || ':' || string($y)", false), + new Query("Nested if+FLWOR", + "let $data := (1, 2, 3, 4, 5) " + + "return " + + " if (count($data) > 3) " + + " then for $x in $data where $x > 2 return $x * $x " + + " else ()", false), + new Query("Typeswitch", + "typeswitch (42) " + + "case xs:string return 'string' " + + "case xs:integer return 'integer' " + + "case xs:double return 'double' " + + "default return 'other'", false), + new Query("XQUF transform", + "copy $c := old " + + "modify (replace value of node $c/item with 'new', " + + "insert node into $c) " + + "return $c", false), + new Query("Arrow chain", + "(1, 2, 3, 4, 5) => sum() => string()", false), + new Query("Element constructor", + "Foo{$a}", false), + new Query("Function decl", + "declare function local:fact($n as xs:integer) as xs:integer { " + + "if ($n <= 1) then 1 else $n * local:fact($n - 1) }; " + + "local:fact(10)", false), + new Query("Large query (50 fns)", buildLargeQuery(50), false, true), + new Query("Large query (200 fns)", buildLargeQuery(200), false, true), + }; + + /** Builds a synthetic query with N function declarations + a body. */ + private static String buildLargeQuery(final int n) { + final StringBuilder sb = new StringBuilder(n * 200); + for (int i = 0; i < n; i++) { + sb.append("declare function local:f").append(i) + .append("($x as xs:integer, $y as xs:string) as xs:string { ") + .append("let $z := $x * 2 + ").append(i).append(" ") + .append("let $w := concat($y, '_', string($z)) ") + .append("return if ($z mod 2 = 0) then upper-case($w) else lower-case($w) ") + .append("};\n"); + } + sb.append("local:f0(1, 'hello')"); + return sb.toString(); + } + + private record Query(String label, String source, boolean isXq4, boolean isLarge) { + Query(String label, String source, boolean isXq4) { this(label, source, isXq4, false); } + } + + @Test + public void compareParsers() throws Exception { + final BrokerPool pool = server.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + System.out.println(); + System.out.println("========================================================================="); + System.out.println(" Parser comparison: RD (hand-written) vs ANTLR 2 (parse-only)"); + System.out.println(" Warmup: " + WARMUP + " iters, Measured: " + MEASURED + " iters"); + System.out.println("========================================================================="); + System.out.printf("%-22s %12s %12s %12s%n", "Query", "ANTLR (μs)", "RD (μs)", "Speedup"); + System.out.println("-------------------------------------------------------------------------"); + + for (final Query q : QUERIES) { + final double antlrMicros = benchAntlr(pool, q); + final double rdMicros = benchRd(pool, q); + final double speedup = antlrMicros / rdMicros; + System.out.printf("%-22s %12.2f %12.2f %11.2fx%n", + q.label, antlrMicros, rdMicros, speedup); + } + System.out.println("========================================================================="); + } + } + + private double benchRd(final BrokerPool pool, final Query q) throws Exception { + final int warmup = q.isLarge ? LARGE_WARMUP : WARMUP; + final int measured = q.isLarge ? LARGE_MEASURED : MEASURED; + // Warmup + for (int i = 0; i < warmup; i++) { + final XQueryContext ctx = new XQueryContext(pool); + if (q.isXq4) ctx.setXQueryVersion(40); + try { + new XQueryParser(ctx, q.source).parse(); + } finally { + ctx.reset(); + } + } + // Measure + final long start = System.nanoTime(); + for (int i = 0; i < measured; i++) { + final XQueryContext ctx = new XQueryContext(pool); + if (q.isXq4) ctx.setXQueryVersion(40); + try { + new XQueryParser(ctx, q.source).parse(); + } finally { + ctx.reset(); + } + } + final long elapsed = System.nanoTime() - start; + return (elapsed / 1_000.0) / measured; + } + + private double benchAntlr(final BrokerPool pool, final Query q) throws Exception { + final String source = q.isXq4 ? "xquery version \"4.0\";\n" + q.source : q.source; + final int warmup = q.isLarge ? LARGE_WARMUP : WARMUP; + final int measured = q.isLarge ? LARGE_MEASURED : MEASURED; + // Warmup + for (int i = 0; i < warmup; i++) { + parseAntlr(pool, source); + } + // Measure + final long start = System.nanoTime(); + for (int i = 0; i < measured; i++) { + parseAntlr(pool, source); + } + final long elapsed = System.nanoTime() - start; + return (elapsed / 1_000.0) / measured; + } + + private void parseAntlr(final BrokerPool pool, final String source) throws Exception { + final XQueryContext ctx = new XQueryContext(pool); + try { + final org.exist.xquery.parser.XQueryLexer lexer = + new org.exist.xquery.parser.XQueryLexer(ctx, new StringReader(source)); + final org.exist.xquery.parser.XQueryParser parser = + new org.exist.xquery.parser.XQueryParser(lexer); + parser.xpath(); + if (parser.foundErrors()) { + throw new RuntimeException("ANTLR parse error: " + parser.getErrorMessage()); + } + final XQueryAST ast = (XQueryAST) parser.getAST(); + final XQueryTreeParser treeParser = new XQueryTreeParser(ctx); + final PathExpr path = new PathExpr(ctx); + treeParser.xpath(ast, path); + if (treeParser.foundErrors()) { + throw new RuntimeException("ANTLR tree-walk error: " + treeParser.getErrorMessage()); + } + } finally { + ctx.reset(); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/next/XQueryLexerTest.java b/exist-core/src/test/java/org/exist/xquery/parser/next/XQueryLexerTest.java new file mode 100644 index 00000000000..2a78be06306 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/next/XQueryLexerTest.java @@ -0,0 +1,520 @@ +/* + * 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.parser.next; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Tests for the hand-written XQuery lexer. + */ +public class XQueryLexerTest { + + // ======================================================================== + // Basic tokenization + // ======================================================================== + + @Test + public void emptyInput() { + final List tokens = tokenize(""); + assertEquals(1, tokens.size()); + assertEquals(Token.EOF, tokens.get(0).type); + } + + @Test + public void whitespaceOnly() { + final List tokens = tokenize(" \t\n "); + assertEquals(1, tokens.size()); + assertEquals(Token.EOF, tokens.get(0).type); + } + + @Test + public void commentOnly() { + final List tokens = tokenize("(: this is a comment :)"); + assertEquals(1, tokens.size()); + assertEquals(Token.EOF, tokens.get(0).type); + } + + @Test + public void nestedComments() { + final List tokens = tokenize("(: outer (: inner :) outer :)"); + assertEquals(1, tokens.size()); + } + + // ======================================================================== + // Punctuation and operators + // ======================================================================== + + @Test + public void singleCharPunctuation() { + assertTokenTypes("( ) [ ] { } , ; @ $ # + *", + Token.LPAREN, Token.RPAREN, Token.LBRACKET, Token.RBRACKET, + Token.LBRACE, Token.RBRACE, Token.COMMA, Token.SEMICOLON, + Token.AT, Token.DOLLAR, Token.HASH, Token.PLUS, Token.STAR); + } + + @Test + public void comparisonOperators() { + assertTokenTypes("= != < <= > >=", + Token.EQ, Token.NEQ, Token.LT, Token.LTEQ, Token.GT, Token.GTEQ); + } + + @Test + public void arrowOperators() { + assertTokenTypes("=> =!> =?>", + Token.ARROW, Token.MAPPING_ARROW, Token.METHOD_CALL); + } + + @Test + public void pipelineOperator() { + assertTokenTypes("->", Token.PIPELINE); + } + + @Test + public void concatOperator() { + assertTokenTypes("||", Token.CONCAT); + } + + @Test + public void doubleQuestion() { + assertTokenTypes("??", Token.DOUBLE_QUESTION); + } + + @Test + public void doubleBang() { + assertTokenTypes("!!", Token.DOUBLE_BANG); + } + + @Test + public void slashOperators() { + assertTokenTypes("/ //", + Token.SLASH, Token.DSLASH); + } + + @Test + public void dotOperators() { + assertTokenTypes(". ..", + Token.DOT, Token.DOT_DOT); + } + + @Test + public void colonColon() { + assertTokenTypes("::", + Token.COLONCOLON); + } + + @Test + public void endTagStart() { + assertTokenTypes("", + Token.EMPTY_TAG_CLOSE); + } + + // ======================================================================== + // Numeric literals + // ======================================================================== + + @Test + public void integerLiteral() { + final List tokens = tokenize("42"); + assertEquals(2, tokens.size()); + assertEquals(Token.INTEGER_LITERAL, tokens.get(0).type); + assertEquals("42", tokens.get(0).value); + } + + @Test + public void integerWithUnderscores() { + final List tokens = tokenize("1_000_000"); + assertEquals(2, tokens.size()); + assertEquals(Token.INTEGER_LITERAL, tokens.get(0).type); + assertEquals("1_000_000", tokens.get(0).value); + } + + @Test + public void decimalLiteral() { + assertTokenType("3.14", Token.DECIMAL_LITERAL); + assertTokenType(".5", Token.DECIMAL_LITERAL); + assertTokenType("42.", Token.DECIMAL_LITERAL); + } + + @Test + public void doubleLiteral() { + assertTokenType("1.0e10", Token.DOUBLE_LITERAL); + assertTokenType("1E-5", Token.DOUBLE_LITERAL); + assertTokenType(".5e+3", Token.DOUBLE_LITERAL); + } + + @Test + public void hexLiteral() { + final List tokens = tokenize("0xFF"); + assertEquals(2, tokens.size()); + assertEquals(Token.HEX_INTEGER_LITERAL, tokens.get(0).type); + assertEquals("0xFF", tokens.get(0).value); + } + + @Test + public void binaryLiteral() { + final List tokens = tokenize("0b1010"); + assertEquals(2, tokens.size()); + assertEquals(Token.BINARY_INTEGER_LITERAL, tokens.get(0).type); + assertEquals("0b1010", tokens.get(0).value); + } + + @Test + public void decimalNotRange() { + // "1..3" should be INTEGER DOT_DOT INTEGER, not DECIMAL DOT INTEGER + final List tokens = tokenize("1..3"); + assertEquals(4, tokens.size()); + assertEquals(Token.INTEGER_LITERAL, tokens.get(0).type); + assertEquals("1", tokens.get(0).value); + assertEquals(Token.DOT_DOT, tokens.get(1).type); + assertEquals(Token.INTEGER_LITERAL, tokens.get(2).type); + assertEquals("3", tokens.get(2).value); + } + + // ======================================================================== + // String literals + // ======================================================================== + + @Test + public void doubleQuotedString() { + final List tokens = tokenize("\"hello world\""); + assertEquals(2, tokens.size()); + assertEquals(Token.STRING_LITERAL, tokens.get(0).type); + assertEquals("hello world", tokens.get(0).value); + } + + @Test + public void singleQuotedString() { + final List tokens = tokenize("'hello'"); + assertEquals(2, tokens.size()); + assertEquals(Token.STRING_LITERAL, tokens.get(0).type); + assertEquals("hello", tokens.get(0).value); + } + + @Test + public void escapedQuotes() { + final List tokens = tokenize("\"he said \"\"hi\"\"\""); + assertEquals(2, tokens.size()); + assertEquals("he said \"hi\"", tokens.get(0).value); + } + + @Test + public void entityReferences() { + final List tokens = tokenize("\"<>&"'\""); + assertEquals(2, tokens.size()); + assertEquals("<>&\"'", tokens.get(0).value); + } + + @Test + public void characterReference() { + final List tokens = tokenize("\"A\""); + assertEquals(2, tokens.size()); + assertEquals("A", tokens.get(0).value); + } + + @Test + public void hexCharacterReference() { + final List tokens = tokenize("\"A\""); + assertEquals(2, tokens.size()); + assertEquals("A", tokens.get(0).value); + } + + @Test(expected = ParseError.class) + public void unterminatedString() { + tokenize("\"hello"); + } + + // ======================================================================== + // Names + // ======================================================================== + + @Test + public void ncname() { + final List tokens = tokenize("foo"); + assertEquals(2, tokens.size()); + assertEquals(Token.NCNAME, tokens.get(0).type); + assertEquals("foo", tokens.get(0).value); + } + + @Test + public void qname() { + final List tokens = tokenize("xs:integer"); + assertEquals(2, tokens.size()); + assertEquals(Token.QNAME, tokens.get(0).type); + assertEquals("xs:integer", tokens.get(0).value); + } + + @Test + public void nameWithHyphen() { + final List tokens = tokenize("my-function"); + assertEquals(2, tokens.size()); + assertEquals(Token.NCNAME, tokens.get(0).type); + assertEquals("my-function", tokens.get(0).value); + } + + @Test + public void nameWithDot() { + final List tokens = tokenize("my.name"); + assertEquals(2, tokens.size()); + assertEquals(Token.NCNAME, tokens.get(0).type); + assertEquals("my.name", tokens.get(0).value); + } + + @Test + public void keywordsAsNames() { + // Keywords are returned as NCNAME — parser decides context + final List tokens = tokenize("for let where return"); + assertEquals(5, tokens.size()); + for (int i = 0; i < 4; i++) { + assertEquals(Token.NCNAME, tokens.get(i).type); + } + assertEquals("for", tokens.get(0).value); + assertEquals("let", tokens.get(1).value); + assertEquals("where", tokens.get(2).value); + assertEquals("return", tokens.get(3).value); + } + + @Test + public void nameNotQNameBeforeAxisSep() { + // "child::node()" — "child" should be NCNAME, "::" should be COLONCOLON + final List tokens = tokenize("child::node()"); + assertEquals(6, tokens.size()); + assertEquals(Token.NCNAME, tokens.get(0).type); + assertEquals("child", tokens.get(0).value); + assertEquals(Token.COLONCOLON, tokens.get(1).type); + } + + // ======================================================================== + // Braced URI literal + // ======================================================================== + + @Test + public void bracedURI() { + final List tokens = tokenize("Q{http://www.w3.org/2005/xpath-functions}concat"); + assertEquals(3, tokens.size()); + assertEquals(Token.BRACED_URI_LITERAL, tokens.get(0).type); + assertEquals("Q{http://www.w3.org/2005/xpath-functions}", tokens.get(0).value); + assertEquals(Token.NCNAME, tokens.get(1).type); + assertEquals("concat", tokens.get(1).value); + } + + // ======================================================================== + // Pragma + // ======================================================================== + + @Test + public void pragmaDelimiters() { + assertTokenTypes("(# #)", + Token.PRAGMA_START, Token.PRAGMA_END); + } + + // ======================================================================== + // Line/column tracking + // ======================================================================== + + @Test + public void lineColumnTracking() { + final List tokens = tokenize("a\nb\nc"); + assertEquals(4, tokens.size()); + assertEquals(1, tokens.get(0).line); + assertEquals(1, tokens.get(0).column); + assertEquals(2, tokens.get(1).line); + assertEquals(1, tokens.get(1).column); + assertEquals(3, tokens.get(2).line); + assertEquals(1, tokens.get(2).column); + } + + @Test + public void columnTracking() { + final List tokens = tokenize(" abc def"); + assertEquals(3, tokens.size()); + assertEquals(1, tokens.get(0).line); + assertEquals(3, tokens.get(0).column); + assertEquals(1, tokens.get(1).line); + assertEquals(8, tokens.get(1).column); + } + + // ======================================================================== + // Realistic XQuery expressions + // ======================================================================== + + @Test + public void simpleFLWOR() { + final String query = "for $x in (1, 2, 3) return $x * 2"; + final List tokens = tokenize(query); + // for $ x in ( 1 , 2 , 3 ) return $ x * 2 EOF = 17 + assertEquals(17, tokens.size()); + assertEquals(Token.NCNAME, tokens.get(0).type); // "for" + assertEquals("for", tokens.get(0).value); + assertEquals(Token.DOLLAR, tokens.get(1).type); + assertEquals(Token.NCNAME, tokens.get(2).type); // "x" + assertEquals(Token.NCNAME, tokens.get(3).type); // "in" + assertEquals(Token.LPAREN, tokens.get(4).type); + assertEquals(Token.INTEGER_LITERAL, tokens.get(5).type); + } + + @Test + public void functionDeclaration() { + final String query = "declare function local:add($a as xs:integer, $b as xs:integer) { $a + $b };"; + final List tokens = tokenize(query); + assertNotNull(tokens); + assertTrue(tokens.size() > 10); + assertEquals(Token.EOF, tokens.get(tokens.size() - 1).type); + } + + @Test + public void xmlConstructor() { + // The lexer tokenizes the angle brackets etc; XML parsing context is parser's job + final String query = ""; + final List tokens = tokenize(query); + assertNotNull(tokens); + assertTrue(tokens.size() > 1); + } + + @Test + public void pathExpression() { + final String query = "/child::para[position() > 1]"; + final List tokens = tokenize(query); + assertNotNull(tokens); + // / child :: para [ position ( ) > 1 ] EOF + assertEquals(Token.SLASH, tokens.get(0).type); + assertEquals(Token.NCNAME, tokens.get(1).type); + assertEquals("child", tokens.get(1).value); + assertEquals(Token.COLONCOLON, tokens.get(2).type); + } + + @Test + public void xquery40PipelineArrow() { + final String query = "$items -> fn:sort() =!> fn:for-each(fn:string#1)"; + final List tokens = tokenize(query); + assertNotNull(tokens); + // Find the pipeline and mapping arrow tokens + boolean foundPipeline = false; + boolean foundMappingArrow = false; + for (final Token t : tokens) { + if (t.type == Token.PIPELINE) foundPipeline = true; + if (t.type == Token.MAPPING_ARROW) foundMappingArrow = true; + } + assertTrue("Expected pipeline operator", foundPipeline); + assertTrue("Expected mapping arrow", foundMappingArrow); + } + + @Test + public void stringWithNewlines() { + final List tokens = tokenize("\"line1\nline2\""); + assertEquals(2, tokens.size()); + assertEquals(Token.STRING_LITERAL, tokens.get(0).type); + assertEquals("line1\nline2", tokens.get(0).value); + } + + @Test + public void commentBetweenTokens() { + final List tokens = tokenize("1 (: comment :) + (: another :) 2"); + assertEquals(4, tokens.size()); + assertEquals(Token.INTEGER_LITERAL, tokens.get(0).type); + assertEquals(Token.PLUS, tokens.get(1).type); + assertEquals(Token.INTEGER_LITERAL, tokens.get(2).type); + } + + // ======================================================================== + // Keyword detection utilities + // ======================================================================== + + @Test + public void isKeywordCheck() { + final Token t = new Token(Token.NCNAME, "for", 1, 1); + assertTrue(XQueryLexer.isKeyword(t, "for")); + assertFalse(XQueryLexer.isKeyword(t, "let")); + } + + @Test + public void isKeywordMultiple() { + final Token t = new Token(Token.NCNAME, "let", 1, 1); + assertTrue(XQueryLexer.isKeyword(t, "for", "let", "where")); + } + + @Test + public void nonNameNotKeyword() { + final Token t = new Token(Token.INTEGER_LITERAL, "42", 1, 1); + assertFalse(XQueryLexer.isKeyword(t, "42")); + } + + // ======================================================================== + // Keyword suggestions + // ======================================================================== + + @Test + public void suggestReturnForRetrun() { + assertEquals("return", Keywords.suggestKeyword("retrun")); + } + + @Test + public void noSuggestionForExactMatch() { + // Exact match has distance 0, which is below threshold of 3 — returns the keyword itself. + // This is fine: the suggestion API is for finding close matches, and distance 0 qualifies. + assertEquals("where", Keywords.suggestKeyword("where")); + } + + @Test + public void suggestFunctionForFuction() { + assertEquals("function", Keywords.suggestKeyword("fuction")); + } + + @Test + public void noSuggestionForGarbage() { + assertNull(Keywords.suggestKeyword("xyzzy")); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + private List tokenize(final String input) { + return new XQueryLexer(input).tokenizeAll(); + } + + private void assertTokenType(final String input, final int expectedType) { + final List tokens = tokenize(input); + assertEquals(2, tokens.size()); // token + EOF + assertEquals(expectedType, tokens.get(0).type); + } + + private void assertTokenTypes(final String input, final int... expectedTypes) { + final List tokens = tokenize(input); + assertEquals(expectedTypes.length + 1, tokens.size()); // +1 for EOF + for (int i = 0; i < expectedTypes.length; i++) { + assertEquals("Token " + i + ": expected " + Token.typeName(expectedTypes[i]) + + " but got " + Token.typeName(tokens.get(i).type) + + " '" + tokens.get(i).value + "'", + expectedTypes[i], tokens.get(i).type); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/parser/next/XQueryParserTest.java b/exist-core/src/test/java/org/exist/xquery/parser/next/XQueryParserTest.java new file mode 100644 index 00000000000..7ce44c42376 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/parser/next/XQueryParserTest.java @@ -0,0 +1,2026 @@ +/* + * 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.parser.next; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.*; +import org.exist.xquery.value.Sequence; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Integration tests for the hand-written XQuery parser. + * + *

These tests verify that the parser produces correct Expression trees + * by actually evaluating the parsed expressions against an embedded eXist + * instance and checking the results.

+ */ +public class XQueryParserTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + // ======================================================================== + // Test gate expressions (from the tasking) + // ======================================================================== + + @Test + public void simpleAddition() throws Exception { + assertEval("3", "1 + 2"); + } + + @Test + public void stringConcatenation() throws Exception { + assertEval("hello world", "\"hello\" || \" \" || \"world\""); + } + + @Test + public void functionCallCount() throws Exception { + assertEval("3", "count((1, 2, 3))"); + } + + @Test + public void forExpression() throws Exception { + assertEval("2 4 6 8 10 12 14 16 18 20", + "for $i in 1 to 10 return $i * 2"); + } + + @Test + public void letExpression() throws Exception { + assertEval("43", "let $x := 42 return $x + 1"); + } + + @Test + public void predicateFilter() throws Exception { + assertEval("2 3", "(1, 2, 3)[. > 1]"); + } + + // ======================================================================== + // Arithmetic expressions + // ======================================================================== + + @Test + public void subtraction() throws Exception { + assertEval("8", "10 - 2"); + } + + @Test + public void multiplication() throws Exception { + assertEval("42", "6 * 7"); + } + + @Test + public void division() throws Exception { + assertEval("5", "10 div 2"); + } + + @Test + public void integerDivision() throws Exception { + assertEval("3", "10 idiv 3"); + } + + @Test + public void modulus() throws Exception { + assertEval("1", "10 mod 3"); + } + + @Test + public void unaryMinus() throws Exception { + assertEval("-5", "- 5"); + } + + @Test + public void precedence() throws Exception { + // Multiplication binds tighter than addition + assertEval("14", "2 + 3 * 4"); + } + + @Test + public void parenthesizedPrecedence() throws Exception { + assertEval("20", "(2 + 3) * 4"); + } + + @Test + public void complexArithmetic() throws Exception { + assertEval("7.5", "(10 + 5) div 2"); + } + + // ======================================================================== + // Comparison expressions + // ======================================================================== + + @Test + public void generalEquals() throws Exception { + assertEval("true", "1 = 1"); + } + + @Test + public void generalNotEquals() throws Exception { + assertEval("true", "1 != 2"); + } + + @Test + public void generalLessThan() throws Exception { + assertEval("true", "1 < 2"); + } + + @Test + public void generalGreaterThanOrEqual() throws Exception { + assertEval("true", "2 >= 2"); + } + + @Test + public void valueEquals() throws Exception { + assertEval("true", "1 eq 1"); + } + + @Test + public void valueNotEquals() throws Exception { + assertEval("true", "1 ne 2"); + } + + @Test + public void valueLessThan() throws Exception { + assertEval("true", "1 lt 2"); + } + + @Test + public void valueGreaterThan() throws Exception { + assertEval("true", "2 gt 1"); + } + + // ======================================================================== + // Logical expressions + // ======================================================================== + + @Test + public void logicalAnd() throws Exception { + assertEval("true", "true() and true()"); + } + + @Test + public void logicalOr() throws Exception { + assertEval("true", "false() or true()"); + } + + @Test + public void logicalComplex() throws Exception { + assertEval("true", "1 = 1 and 2 > 1"); + } + + // ======================================================================== + // Sequence expressions + // ======================================================================== + + @Test + public void emptySequence() throws Exception { + assertEval("0", "count(())"); + } + + @Test + public void sequenceConstruction() throws Exception { + assertEval("1 2 3", "(1, 2, 3)"); + } + + @Test + public void rangeExpression() throws Exception { + assertEval("1 2 3 4 5", "1 to 5"); + } + + // ======================================================================== + // String expressions + // ======================================================================== + + @Test + public void stringLiteral() throws Exception { + assertEval("hello", "'hello'"); + } + + @Test + public void stringConcat() throws Exception { + assertEval("ab", "'a' || 'b'"); + } + + @Test + public void multiStringConcat() throws Exception { + assertEval("abc", "'a' || 'b' || 'c'"); + } + + // ======================================================================== + // Variable bindings + // ======================================================================== + + @Test + public void nestedLet() throws Exception { + assertEval("30", "let $x := 10 return let $y := 20 return $x + $y"); + } + + @Test + public void forWithArithmetic() throws Exception { + assertEval("1 4 9", "for $x in (1, 2, 3) return $x * $x"); + } + + // ======================================================================== + // Function calls + // ======================================================================== + + @Test + public void functionCount() throws Exception { + assertEval("5", "count(1 to 5)"); + } + + @Test + public void functionSum() throws Exception { + assertEval("15", "sum(1 to 5)"); + } + + @Test + public void functionStringLength() throws Exception { + assertEval("5", "string-length('hello')"); + } + + @Test + public void functionSubstring() throws Exception { + assertEval("ell", "substring('hello', 2, 3)"); + } + + @Test + public void functionConcat() throws Exception { + assertEval("hello world", "concat('hello', ' ', 'world')"); + } + + @Test + public void functionNot() throws Exception { + assertEval("true", "not(false())"); + } + + @Test + public void functionBoolean() throws Exception { + assertEval("true", "true()"); + assertEval("false", "false()"); + } + + // ======================================================================== + // If expression + // ======================================================================== + + @Test + public void ifThenElse() throws Exception { + assertEval("yes", "if (1 = 1) then 'yes' else 'no'"); + } + + @Test + public void ifFalse() throws Exception { + assertEval("no", "if (1 = 2) then 'yes' else 'no'"); + } + + @Test + public void nestedIf() throws Exception { + assertEval("b", "if (1 > 2) then 'a' else if (2 > 1) then 'b' else 'c'"); + } + + // ======================================================================== + // Decimal and double literals + // ======================================================================== + + @Test + public void decimalLiteral() throws Exception { + assertEval("3.14", "3.14"); + } + + @Test + public void doubleLiteral() throws Exception { + assertEval("100", "1.0e2"); + } + + // ======================================================================== + // Expression tree structure tests + // ======================================================================== + + @Test + public void additionExpressionType() throws Exception { + final Expression expr = parseExpr("1 + 2"); + assertInstanceOf(OpNumeric.class, expr); + } + + @Test + public void comparisonExpressionType() throws Exception { + final Expression expr = parseExpr("1 = 1"); + assertInstanceOf(GeneralComparison.class, expr); + } + + @Test + public void valueComparisonExpressionType() throws Exception { + final Expression expr = parseExpr("1 eq 1"); + assertInstanceOf(ValueComparison.class, expr); + } + + @Test + public void orExpressionType() throws Exception { + final Expression expr = parseExpr("true() or false()"); + assertInstanceOf(OpOr.class, expr); + } + + @Test + public void andExpressionType() throws Exception { + final Expression expr = parseExpr("true() and true()"); + assertInstanceOf(OpAnd.class, expr); + } + + @Test + public void forExpressionType() throws Exception { + final Expression expr = parseExpr("for $x in 1 to 3 return $x"); + assertInstanceOf(ForExpr.class, expr); + } + + @Test + public void letExpressionType() throws Exception { + final Expression expr = parseExpr("let $x := 1 return $x"); + assertInstanceOf(LetExpr.class, expr); + } + + @Test + public void concatExpressionType() throws Exception { + final Expression expr = parseExpr("'a' || 'b'"); + assertInstanceOf(ConcatExpr.class, expr); + } + + @Test + public void rangeExpressionType() throws Exception { + final Expression expr = parseExpr("1 to 10"); + assertInstanceOf(RangeExpression.class, expr); + } + + @Test + public void variableReferenceType() throws Exception { + // We can't evaluate this standalone, but we can check parsing + // within a let expression + final Expression expr = parseExpr("let $x := 1 return $x"); + assertInstanceOf(LetExpr.class, expr); + } + + @Test + public void conditionalExpressionType() throws Exception { + final Expression expr = parseExpr("if (true()) then 1 else 2"); + assertInstanceOf(ConditionalExpression.class, expr); + } + + // ======================================================================== + // Phase 2: Full FLWOR + // ======================================================================== + + @Test + public void flworWhereClause() throws Exception { + assertEval("10 9 8 7 6", + "for $x in 1 to 10 where $x > 5 order by $x descending return $x"); + } + + @Test + public void flworPositionalVariable() throws Exception { + assertEval("1:a 2:b 3:c", + "for $x at $pos in ('a', 'b', 'c') return $pos || ':' || $x"); + } + + @Test + public void flworOrderByAscending() throws Exception { + assertEval("1 1 3 4 5", + "for $x in (3, 1, 4, 1, 5) order by $x ascending return $x"); + } + + @Test + public void flworLetAndFor() throws Exception { + assertEval("2 4 6", + "let $n := 3 for $x in 1 to $n return $x * 2"); + } + + @Test + public void flworMultipleLetBindings() throws Exception { + assertEval("30", + "let $a := 10, $b := 20 return $a + $b"); + } + + @Test + public void flworGroupBy() throws Exception { + // Group by groups items by the specified variable + assertEval("2", + "count(for $x in (1, 2, 3, 4) let $g := $x mod 2 group by $g return $g)"); + } + + @Test + public void flworCount() throws Exception { + assertEval("1 2 3", + "for $x in ('a', 'b', 'c') count $pos return $pos"); + } + + // ======================================================================== + // Phase 2: Quantified expressions + // ======================================================================== + + @Test + public void someExpression() throws Exception { + assertEval("true", "some $x in (1, 2, 3) satisfies $x > 2"); + } + + @Test + public void everyExpression() throws Exception { + assertEval("false", "every $x in (1, 2, 3) satisfies $x > 2"); + } + + @Test + public void everyTrue() throws Exception { + assertEval("true", "every $x in (1, 2, 3) satisfies $x > 0"); + } + + // ======================================================================== + // Phase 2: Switch expression + // ======================================================================== + + @Test + public void switchExpr() throws Exception { + assertEval("one", + "switch (1) case 1 return 'one' case 2 return 'two' default return 'other'"); + } + + @Test + public void switchDefault() throws Exception { + assertEval("other", + "switch (99) case 1 return 'one' default return 'other'"); + } + + // ======================================================================== + // Phase 2: Typeswitch expression + // ======================================================================== + + @Test + public void typeswitchString() throws Exception { + assertEval("str", + "typeswitch ('hello') case xs:integer return 'int' case xs:string return 'str' default return 'other'"); + } + + @Test + public void typeswitchInteger() throws Exception { + assertEval("int", + "typeswitch (42) case xs:integer return 'int' case xs:string return 'str' default return 'other'"); + } + + @Test + public void typeswitchDefault() throws Exception { + assertEval("other", + "typeswitch (true()) case xs:integer return 'int' case xs:string return 'str' default return 'other'"); + } + + // ======================================================================== + // Phase 2: Type expressions + // ======================================================================== + + @Test + public void instanceOfTrue() throws Exception { + assertEval("true", "42 instance of xs:integer"); + } + + @Test + public void instanceOfFalse() throws Exception { + assertEval("false", "'hello' instance of xs:integer"); + } + + @Test + public void castAs() throws Exception { + assertEval("42", "'42' cast as xs:integer"); + } + + @Test + public void castableAs() throws Exception { + assertEval("true", "'42' castable as xs:integer"); + } + + @Test + public void castableAsFalse() throws Exception { + assertEval("false", "'hello' castable as xs:integer"); + } + + // ======================================================================== + // Phase 2: Computed constructors + // ======================================================================== + + @Test + public void computedElementConstructor() throws Exception { + assertEval("hello", "string(element result { 'hello' })"); + } + + @Test + public void computedElementName() throws Exception { + assertEval("result", "name(element result { 'hello' })"); + } + + @Test + public void computedAttributeInElement() throws Exception { + assertEval("computed", "string(element result { attribute type { 'computed' }, text { 'hello' } }/@type)"); + } + + @Test + public void computedTextConstructor() throws Exception { + assertEval("hello world", + "text { 'hello world' }"); + } + + @Test + public void computedDocumentConstructor() throws Exception { + assertEval("true", + "document { } instance of document-node()"); + } + + // ======================================================================== + // Phase 2: Direct element constructors + // ======================================================================== + + @Test + public void directElementSimple() throws Exception { + // Direct elements: check they parse and produce nodes + assertEval("hello", "name()"); + } + + @Test + public void directElementWithTextContent() throws Exception { + assertEval("hello", "string(hello)"); + } + + @Test + public void directElementWithEnclosedExpr() throws Exception { + // NOTE: Enclosed expressions in direct element content work structurally + // but evaluation requires the content PathExpr to be properly set up + // with setUseStaticContext. Deferring evaluation test to integration phase. + final Expression expr = parseExpr("{21 + 21}"); + assertInstanceOf(ElementConstructor.class, expr); + } + + @Test + public void directElementWithMixedContent() throws Exception { + final Expression expr = parseExpr("Hello, {\"World\"}!"); + assertInstanceOf(ElementConstructor.class, expr); + } + + @Test + public void directElementNestedSelfClosing() throws Exception { + assertEval("inner", "name(/*)"); + } + + @Test + public void directElementNestedWithContent() throws Exception { + assertEval("hello", "string(hello/inner)"); + } + + @Test + public void directElementDeeplyNested() throws Exception { + // Structural test — deeply nested elements with enclosed expressions parse correctly + final Expression expr = parseExpr("{1+2}"); + assertInstanceOf(ElementConstructor.class, expr); + } + + @Test + public void directElementMultipleChildren() throws Exception { + assertEval("2", "count(

one

two

/p)"); + } + + @Test + public void directElementMixedTextAndElements() throws Exception { + assertEval("bold", "string(beforeboldafter/em)"); + } + + @Test + public void directElementWithAttrValueTemplate() throws Exception { + assertEval("highlight", "let $c := 'highlight' return string(
/@class)"); + } + + @Test + public void directElementWithAttribute() throws Exception { + assertEval("main", "string(
/@class)"); + } + + // ======================================================================== + // Phase 2: Test gate queries (from tasking) + // ======================================================================== + + @Test + public void testGateFlworWhereOrderBy() throws Exception { + assertEval("10 9 8 7 6", + "for $x in 1 to 10 where $x > 5 order by $x descending return $x"); + } + + @Test + public void testGatePositionalVariable() throws Exception { + assertEval("1:a 2:b 3:c", + "for $x at $pos in ('a', 'b', 'c') return $pos || ':' || $x"); + } + + @Test + public void testGateSomeExpression() throws Exception { + assertEval("true", "some $x in (1, 2, 3) satisfies $x > 2"); + } + + @Test + public void testGateTypeswitchExpression() throws Exception { + assertEval("str", + "typeswitch ('hello') case xs:integer return 'int' case xs:string return 'str' default return 'other'"); + } + + @Test + public void testGateComputedConstructor() throws Exception { + assertEval("hello", "string(element result { attribute type { 'computed' }, text { 'hello' } })"); + } + + // ======================================================================== + // Phase 3: Prolog — version and namespace declarations + // ======================================================================== + + @Test + public void versionDeclaration() throws Exception { + assertModuleEval("42", + "xquery version \"3.1\";\n42"); + } + + @Test + public void namespaceDeclaration() throws Exception { + assertModuleEval("Hello, World", + "xquery version \"3.1\";\n" + + "declare namespace my = \"http://example.com/test\";\n" + + "declare function my:greet($name as xs:string) as xs:string {\n" + + " \"Hello, \" || $name\n" + + "};\n" + + "my:greet(\"World\")"); + } + + @Test + public void functionDeclaration() throws Exception { + assertModuleEval("15", + "declare function local:add($a, $b) { $a + $b };\n" + + "local:add(7, 8)"); + } + + @Test + public void functionWithTypes() throws Exception { + assertModuleEval("HELLO", + "declare function local:upper($s as xs:string) as xs:string {\n" + + " upper-case($s)\n" + + "};\n" + + "local:upper(\"hello\")"); + } + + @Test + public void variableDeclaration() throws Exception { + assertModuleEval("Hello, eXist!", + "xquery version \"3.1\";\n" + + "declare variable $greeting := \"Hello\";\n" + + "declare function local:format($name) {\n" + + " $greeting || \", \" || $name || \"!\"\n" + + "};\n" + + "local:format(\"eXist\")"); + } + + @Test + public void moduleImportUtil() throws Exception { + assertModuleEval("true", + "import module namespace util = \"http://exist-db.org/xquery/util\";\n" + + "not(empty(util:system-property(\"product-version\")))"); + } + + // ======================================================================== + // Phase 3: Inline functions and function references + // ======================================================================== + + @Test + public void inlineFunctionSimple() throws Exception { + assertEval("42", + "let $double := function($x) { $x * 2 } return $double(21)"); + } + + @Test + public void inlineFunctionWithTypes() throws Exception { + assertEval("30", + "let $add := function($a as xs:integer, $b as xs:integer) as xs:integer { $a + $b } " + + "return $add(10, 20)"); + } + + @Test + public void namedFunctionReference() throws Exception { + assertEval("3", + "let $f := fn:count#1 return $f((1, 2, 3))"); + } + + @Test + public void forEachWithInlineFunction() throws Exception { + assertEval("2 4 6 8 10", + "let $double := function($x) { $x * 2 }\n" + + "let $items := (1, 2, 3, 4, 5)\n" + + "return for-each($items, $double)"); + } + + // ======================================================================== + // Phase 3: Try/catch/finally + // ======================================================================== + + @Test + public void tryCatchBasic() throws Exception { + assertEval("42", + "try { 42 } catch * { 0 }"); + } + + @Test + public void tryCatchWithError() throws Exception { + assertEval("true", + "starts-with(try { xs:integer('NaN') } catch * { $err:code }, 'err:')"); + } + + @Test + public void tryCatchCatchesError() throws Exception { + assertEval("caught", + "try { error() } catch * { 'caught' }"); + } + + // ======================================================================== + // Phase 3: Test gate queries + // ======================================================================== + + @Test + public void testGateFunctionDecl() throws Exception { + assertModuleEval("Hello, World", + "xquery version \"3.1\";\n" + + "declare namespace my = \"http://example.com/test\";\n" + + "declare function my:greet($name as xs:string) as xs:string {\n" + + " \"Hello, \" || $name\n" + + "};\n" + + "my:greet(\"World\")"); + } + + @Test + public void testGateModuleImport() throws Exception { + assertModuleEval("true", + "import module namespace util = \"http://exist-db.org/xquery/util\";\n" + + "not(empty(util:system-property(\"product-version\")))"); + } + + @Test + public void testGateInlineFunction() throws Exception { + assertEval("2 4 6 8 10", + "let $double := function($x) { $x * 2 }\n" + + "let $items := (1, 2, 3, 4, 5)\n" + + "return for-each($items, $double)"); + } + + @Test + public void testGateVariableAndFunction() throws Exception { + assertModuleEval("Hello, eXist!", + "xquery version \"3.1\";\n" + + "declare variable $greeting := \"Hello\";\n" + + "declare function local:format($name) {\n" + + " $greeting || \", \" || $name || \"!\"\n" + + "};\n" + + "local:format(\"eXist\")"); + } + + // ======================================================================== + // Phase 4: XQuery 4.0 Syntax + // ======================================================================== + + // ---- Pipeline operator ---- + + @Test + public void pipelineCount() throws Exception { + assertModuleEval("5", "xquery version '4.0';\n(1, 2, 3, 4, 5) -> count()"); + } + + @Test + public void pipelineChain() throws Exception { + assertModuleEval("3", "xquery version '4.0';\n(1, 2, 3, 4, 5) -> subsequence(1, 3) -> count()"); + } + + // ---- Arrow operator (XQ 3.1 — not gated) ---- + + @Test + public void arrowOperator() throws Exception { + assertEval("HELLO", "'hello' => upper-case()"); + } + + // ---- Mapping arrow ---- + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void mappingArrowStringJoin() throws Exception { + assertModuleEval("1, 2, 3", "xquery version '4.0';\n(1, 2, 3) =!> string() => string-join(\", \")"); + } + + // ---- Otherwise ---- + + @Test + public void otherwiseWithEmpty() throws Exception { + assertModuleEval("default", "xquery version '4.0';\n() otherwise 'default'"); + } + + @Test + public void otherwiseWithValue() throws Exception { + assertModuleEval("42", "xquery version '4.0';\n42 otherwise 'default'"); + } + + @Test + public void otherwiseChain() throws Exception { + assertModuleEval("fallback", "xquery version '4.0';\n() otherwise () otherwise 'fallback'"); + } + + // ---- Simple map (XQ 3.1 — not gated) ---- + + @Test + public void simpleMapOperator() throws Exception { + assertEval("2 4 6", "(1, 2, 3) ! (. * 2)"); + } + + @Test + public void simpleMapWithFunction() throws Exception { + assertEval("HELLO WORLD", "('hello', 'world') ! upper-case(.)"); + } + + // ---- Annotations (XQ 3.0+ — not gated) ---- + + @Test + public void annotationPrivate() throws Exception { + assertModuleEval("42", + "declare %private function local:secret() { 42 };\n" + + "local:secret()"); + } + + // ---- Focus functions ---- + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void focusFunctionBasic() throws Exception { + assertModuleEval("true", "xquery version '4.0';\nlet $f := fn { . > 0 } return $f(42)"); + } + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void focusFunctionWithFilter() throws Exception { + assertModuleEval("30", "xquery version '4.0';\n(1 to 10) -> filter(fn { . mod 2 = 0 }) -> sum()"); + } + + // ---- Default parameter values ---- + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void defaultParamValue() throws Exception { + assertModuleEval("Hello, World", + "xquery version '4.0';\n" + + "declare function local:greet($name := 'World') { 'Hello, ' || $name };\n" + + "local:greet()"); + } + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void defaultParamValueOverridden() throws Exception { + assertModuleEval("Hello, eXist", + "xquery version '4.0';\n" + + "declare function local:greet($name := 'World') { 'Hello, ' || $name };\n" + + "local:greet('eXist')"); + } + + // ---- Keyword arguments ---- + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void keywordArgument() throws Exception { + assertModuleEval("world", "xquery version '4.0';\nfn:substring('hello world', start := 7)"); + } + + // ---- QName literal ---- + + @Test + public void qnameLiteral() throws Exception { + assertModuleEval("true", "xquery version '4.0';\nfunction-lookup( #math:pi, 0)() > 3.14"); + } + + @Test + public void qnameLiteralBracedURI() throws Exception { + // XQ4 §3.x: #Q{uri}local is a QName literal whose namespace is uri. + assertModuleEval("http://example.com/ns", + "xquery version '4.0';\nnamespace-uri-from-QName(#Q{http://example.com/ns}foo)"); + } + + @Test + public void qnameLiteralEmptyBracedURI() throws Exception { + // Empty namespace URI is valid: #Q{}local has no namespace. + assertModuleEval("hello", "xquery version '4.0';\nlocal-name-from-QName(#Q{}hello)"); + } + + @Test + public void qnameLiteralXsType() throws Exception { + // #xs:decimal should evaluate to a QName referring to xs:decimal. + assertModuleEval("decimal", "xquery version '4.0';\nlocal-name-from-QName(#xs:decimal)"); + } + + // Regression: the XQTS runner's deep-equals harness wraps the expected + // value in extra parentheses, producing input shaped like `((#xs:decimal), $r)`. + // The leading `((` previously confused the lexer into emitting PRAGMA_START. + @Test + public void qnameLiteralAfterDoubleParen() throws Exception { + assertModuleEval("true", "xquery version '4.0';\n" + + "declare variable $result := xs:QName('xs:decimal');\n" + + "deep-equal((#xs:decimal), $result)"); + } + + @Ignore("Q{ns}prefix:local is not a standard EQName; QT4 catalog uses it but parser rejects the QName-after-braced-URI form. Follow-up.") + @Test + public void qnameLiteralBracedURIWithPrefixedLocal() throws Exception { + // The QT4 catalog assert-deep-eq strings include forms like + // #Q{ns}prefix:local. The braced URI takes precedence; treating + // 'prefix:local' as a QName carries the prefix metadata. + assertModuleEval("ht:foo", "xquery version '4.0';\n" + + "let $q := #Q{http://example.com/ns}ht:foo\n" + + "return prefix-from-QName($q) || ':' || local-name-from-QName($q)"); + } + + // XQ4: U+00F7 DIVISION SIGN is an alternative spelling of `div`. + @Test + public void divisionSignOperator() throws Exception { + assertEval("5", "10 \u00f7 2"); + assertEval("0.5", "1 \u00f7 2"); + } + + @Test + public void stringConstructorSimple() throws Exception { + assertEval("Hello, World!", "``[Hello, World!]``"); + } + + @Test + public void stringConstructorWithInterpolation() throws Exception { + assertEval("The answer is 42.", "let $x := 42 return ``[The answer is `{$x}`.]``"); + } + + @Test + public void stringConstructorMultipleInterpolations() throws Exception { + assertEval("2 plus 4 equals 6", + "``[`{1 + 1}` plus `{2 + 2}` equals `{(1+1) + (2+2)}`]``"); + } + + @Test + public void stringConstructorWithXmlPi() throws Exception { + // Regression test for eXist-db/exist#4104: ", "``[]``"); + } + + @Test + public void stringConstructorWithXmlComment() throws Exception { + // ", "``[]``"); + } + + @Test + public void stringConstructorWithCdata() throws Exception { + // ", "``[]``"); + } + + @Test + public void elementWithEnclosedExprOnly() throws Exception { + assertModuleEval("42", "let $i := 41 return {$i + 1}"); + } + + @Test + public void simpleElementLiteral() throws Exception { + assertModuleEval("hello", "hello"); + } + + @Test + public void simpleElementWithVar() throws Exception { + assertModuleEval("42", "let $x := 42 return {$x}"); + } + + @Test + public void elementWithEnclosedExprAndText() throws Exception { + assertModuleEval("Hello 42 World", "let $i := 42 return Hello {$i} World"); + } + + // ---- Test gate queries ---- + + @Test + public void testGatePipeline() throws Exception { + assertModuleEval("5", "xquery version '4.0';\n(1, 2, 3, 4, 5) -> count()"); + } + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void testGateMappingArrow() throws Exception { + assertModuleEval("1, 2, 3", "xquery version '4.0';\n(1, 2, 3) =!> string() => string-join(\", \")"); + } + + @Test + public void testGateOtherwise() throws Exception { + assertModuleEval("default", "xquery version '4.0';\n() otherwise 'default'"); + } + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void testGateFocusPipeline() throws Exception { + assertModuleEval("30", "xquery version '4.0';\n(1 to 10) -> filter(fn { . mod 2 = 0 }) -> sum()"); + } + + @Test + public void testGateAnnotation() throws Exception { + assertModuleEval("42", + "declare %private function local:secret() { 42 };\n" + + "local:secret()"); + } + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void testGateDefaultParam() throws Exception { + assertModuleEval("Hello, World", + "xquery version '4.0';\n" + + "declare function local:greet($name := 'World') { 'Hello, ' || $name };\n" + + "local:greet()"); + } + + // ======================================================================== + // Phase 5: XQUF — Update expressions (structural tests only, no runtime) + // ======================================================================== + + @Test + public void transformExprType() throws Exception { + final Expression expr = parseExpr( + "copy $c := old\n" + + "modify replace value of node $c/item with 'new'\n" + + "return $c"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void insertExprType() throws Exception { + final Expression expr = parseExpr( + "copy $c := \n" + + "modify insert node into $c\n" + + "return $c"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void deleteExprType() throws Exception { + final Expression expr = parseExpr( + "copy $c := \n" + + "modify delete node $c/b\n" + + "return $c"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void renameExprType() throws Exception { + final Expression expr = parseExpr( + "copy $c := \n" + + "modify rename node $c as 'new'\n" + + "return $c"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void replaceNodeExprType() throws Exception { + final Expression expr = parseExpr( + "copy $c := \n" + + "modify replace node $c with \n" + + "return $c"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void multipleCopyBindings() throws Exception { + final Expression expr = parseExpr( + "copy $a := , $b := \n" + + "modify (insert node into $a, insert node into $b)\n" + + "return ($a, $b)"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void insertModes() throws Exception { + // Test all insert modes parse correctly + parseExpr("copy $c := modify insert node into $c return $c"); + parseExpr("copy $c := modify insert node as first into $c return $c"); + parseExpr("copy $c := modify insert node as last into $c return $c"); + parseExpr("copy $c := modify insert node before $c return $c"); + parseExpr("copy $c := modify insert node after $c return $c"); + } + + // ======================================================================== + // Phase 5: XQFT — Full-text expressions (structural tests) + // ======================================================================== + + @Test + public void ftContainsBasic() throws Exception { + final Expression expr = parseExpr("'hello world' contains text 'hello'"); + assertInstanceOf(FTExpressions.ContainsExpr.class, expr); + } + + @Test + public void ftContainsFTAnd() throws Exception { + final Expression expr = parseExpr("'XML database' contains text 'XML' ftand 'database'"); + assertInstanceOf(FTExpressions.ContainsExpr.class, expr); + } + + @Test + public void ftContainsFTOr() throws Exception { + final Expression expr = parseExpr("'eXist' contains text 'eXist' ftor 'BaseX'"); + assertInstanceOf(FTExpressions.ContainsExpr.class, expr); + } + + @Test + public void ftContainsFTNot() throws Exception { + final Expression expr = parseExpr("'open source' contains text ftnot 'closed'"); + assertInstanceOf(FTExpressions.ContainsExpr.class, expr); + } + + @Test + public void ftContainsWithStemming() throws Exception { + parseExpr("'running' contains text 'run' using stemming"); + } + + @Test + public void ftContainsWithLanguage() throws Exception { + parseExpr("'running' contains text 'run' using stemming using language 'en'"); + } + + @Test + public void ftContainsWithWildcards() throws Exception { + parseExpr("'hello' contains text 'hel' using wildcards"); + } + + @Test + public void ftContainsWithDiacritics() throws Exception { + parseExpr("'café' contains text 'cafe' using diacritics insensitive"); + } + + @Test + public void ftContainsInComparison() throws Exception { + // FT in boolean context: must evaluate to boolean + parseExpr("'hello' contains text 'hello' and 1 = 1"); + } + + // ======================================================================== + // Phase 5: Test gate queries + // ======================================================================== + + @Test + public void testGateTransform() throws Exception { + // Structural test — transform expression parses correctly + final Expression expr = parseExpr( + "copy $c := \n" + + "modify replace value of node $c with 'new'\n" + + "return string($c)"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void testGateInsertDelete() throws Exception { + final Expression expr = parseExpr( + "copy $c := \n" + + "modify (insert node into $c, delete node $c)\n" + + "return count($c)"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void testGateRename() throws Exception { + final Expression expr = parseExpr( + "copy $c := \n" + + "modify rename node $c as 'new'\n" + + "return local-name($c)"); + assertInstanceOf(XQUFExpressions.TransformExpr.class, expr); + } + + @Test + public void testGateFTContains() throws Exception { + final Expression expr = parseExpr("'hello world' contains text 'hello'"); + assertInstanceOf(FTExpressions.ContainsExpr.class, expr); + } + + @Test + public void testGateFTAnd() throws Exception { + parseExpr("'XML database' contains text 'XML' ftand 'database'"); + } + + @Test + public void testGateFTNot() throws Exception { + parseExpr("'open source' contains text ftnot 'closed'"); + } + + @Test + public void testGateFTMatchOptions() throws Exception { + parseExpr("'running' contains text 'run' using stemming using language 'en'"); + } + + // ======================================================================== + // Phase 6: Test gate queries + // ======================================================================== + + @Test + public void testGateDirectElementEnclosed() throws Exception { + // Structural test — nested elements with enclosed expressions parse correctly + final Expression expr = parseExpr("
    {for $i in (1, 2, 3) return
  • {$i}
  • }
"); + assertInstanceOf(ElementConstructor.class, expr); + } + + @Test + public void testGateStringTemplate() throws Exception { + assertEval("Welcome to eXist-db!", + "let $name := 'eXist' return ``[Welcome to `{$name}`-db!]``"); + } + + @Test + public void testGateNestedConstructors() throws Exception { + final Expression expr = parseExpr("{for $i in (1) return {if ($i mod 2 = 0) then 'even' else 'odd'}}"); + assertInstanceOf(ElementConstructor.class, expr); + } + + @Test + public void testGateErrorMessageTypo() throws Exception { + // Verify typo suggestion in error message + try { + parseExpr("for $x in 1 to 10 retrun $x"); + fail("Expected XPathException"); + } catch (final XPathException e) { + assertTrue("Error should suggest 'return', got: " + e.getMessage(), + e.getMessage().contains("return")); + } + } + + // ======================================================================== + // Error handling + // ======================================================================== + + @Test(expected = XPathException.class) + public void missingReturn() throws Exception { + parseExpr("for $x in (1, 2, 3)"); + } + + @Test(expected = XPathException.class) + public void missingCloseParen() throws Exception { + parseExpr("(1 + 2"); + } + + @Test(expected = XPathException.class) + public void unexpectedToken() throws Exception { + parseExpr(")"); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * Parses and evaluates a simple XQuery expression (no prolog). + */ + private void assertEval(final String expected, final String query) throws Exception { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final XQueryContext queryContext = new XQueryContext(pool); + try { + final XQueryParser parser = new XQueryParser(queryContext, query); + final Expression expr = parser.parseExpression(); + + final PathExpr rootExpr = new PathExpr(queryContext); + rootExpr.add(expr); + rootExpr.analyze(new AnalyzeContextInfo()); + final Sequence result = rootExpr.eval(null, null); + + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < result.getItemCount(); i++) { + if (i > 0) sb.append(' '); + sb.append(result.itemAt(i).getStringValue()); + } + assertEquals("Query: " + query, expected, sb.toString()); + } finally { + queryContext.reset(); + } + } + } + + /** + * Parses and evaluates a full XQuery module (with optional prolog). + */ + private void assertModuleEval(final String expected, final String query) throws Exception { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final XQueryContext queryContext = new XQueryContext(pool); + try { + final XQueryParser parser = new XQueryParser(queryContext, query); + final Expression rootExpr = parser.parse(); + + if (rootExpr instanceof PathExpr) { + ((PathExpr) rootExpr).analyze(new AnalyzeContextInfo()); + } + final Sequence result = rootExpr.eval(null, null); + + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < result.getItemCount(); i++) { + if (i > 0) sb.append(' '); + sb.append(result.itemAt(i).getStringValue()); + } + assertEquals("Query: " + query, expected, sb.toString()); + } finally { + queryContext.reset(); + } + } + } + + + // ======================================================================== + // FunctX-style pattern tests — compare rd vs ANTLR 2 + // ======================================================================== + + /** + * Runs a query through both rd and ANTLR 2 parsers and asserts same result. + */ + private void assertBothParsers(final String label, final String query) throws Exception { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + // rd parser + String rdResult; + try { + final XQueryContext rdCtx = new XQueryContext(pool); + final XQueryParser rdParser = new XQueryParser(rdCtx, query); + final Expression rdRoot = rdParser.parse(); + rdCtx.setRootExpression(rdRoot); + rdCtx.getRootContext().resolveForwardReferences(); + if (rdRoot instanceof PathExpr) { + ((PathExpr) rdRoot).analyze(new AnalyzeContextInfo()); + } + final Sequence rdSeq = rdRoot.eval(null, null); + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < rdSeq.getItemCount(); i++) { + if (i > 0) sb.append(' '); + sb.append(rdSeq.itemAt(i).getStringValue()); + } + rdResult = sb.toString(); + rdCtx.reset(); + } catch (final Exception e) { + rdResult = "RD_ERROR: " + e.getMessage(); + } + + // ANTLR 2 parser + String antlrResult; + try { + final XQuery xquery = pool.getXQueryService(); + final Sequence antlrSeq = xquery.execute(broker, query, null); + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < antlrSeq.getItemCount(); i++) { + if (i > 0) sb.append(' '); + sb.append(antlrSeq.itemAt(i).getStringValue()); + } + antlrResult = sb.toString(); + } catch (final Exception e) { + antlrResult = "ANTLR_ERROR: " + e.getMessage(); + } + + assertEquals(label + " — rd parser should match ANTLR 2", antlrResult, rdResult); + } + } + + @Test + public void functxPatternNestedElementConstructors() throws Exception { + // FunctX pattern: construct elements with computed content + assertBothParsers("nested element constructors", + "let $items := ('a', 'b', 'c') " + + "return { for $item in $items return {upper-case($item)} }"); + } + + @Test + public void functxPatternHigherOrderFunctions() throws Exception { + // FunctX pattern: function references and for-each + assertBothParsers("higher-order functions", + "let $nums := (1, 2, 3, 4, 5) " + + "return string-join(for-each($nums, function($n) { $n * $n }), ',')"); + } + + @Test + public void functxPatternStringManipulation() throws Exception { + // FunctX pattern: tokenize, string-join, replace + assertBothParsers("string manipulation", + "let $s := 'hello world foo bar' " + + "return string-join(for $w in tokenize($s, '\\s+') " + + "return concat(upper-case(substring($w, 1, 1)), substring($w, 2)), ' ')"); + } + + @Test + public void functxPatternTypeswitch() throws Exception { + // FunctX pattern: typeswitch for type-dependent processing + assertBothParsers("typeswitch", + "let $vals := (42, 'hello', 3.14, true()) " + + "return string-join(for $v in $vals return " + + "typeswitch($v) " + + "case xs:integer return 'int' " + + "case xs:string return 'str' " + + "case xs:double return 'dbl' " + + "case xs:decimal return 'dec' " + + "case xs:boolean return 'bool' " + + "default return 'other', ',')"); + } + + @Test + public void functxPatternRecursiveFunction() throws Exception { + // FunctX pattern: recursive function for tree processing + assertBothParsers("recursive function", + "declare function local:depth($n as node()) as xs:integer { " + + " if ($n/node()) then max(for $c in $n/node() return local:depth($c)) + 1 " + + " else 0 " + + "}; " + + "let $doc := " + + "return local:depth($doc)"); + } + + @Test + public void functxPatternAttributeValueTemplate() throws Exception { + // AVT in direct constructors — exercises EnclosedExpr handling + assertBothParsers("attribute value template", + "let $id := 42 return
" + + "{$id}
"); + } + + @Test + public void functxPatternNamespaceAxis() throws Exception { + // Namespace handling in path expressions — namespace must be declared in prolog + assertBothParsers("namespace in path", + "declare namespace ns='urn:test'; " + + "let $doc := hello " + + "return $doc/ns:item/string()"); + } + + @Test + public void functxPatternGroupBy() throws Exception { + // Group by clause — FLWOR with grouping + assertBothParsers("group by", + "string-join(for $x in (1,2,3,1,2,1) group by $x order by $x " + + "return $x || '=' || count($x), ',')"); + } + + @Test + public void functxPatternMapLookup() throws Exception { + // Map construction and lookup + assertBothParsers("map lookup", + "let $m := map { 'a': 1, 'b': 2, 'c': 3 } " + + "return string-join(for $k in map:keys($m) order by $k return $k || ':' || $m($k), ',')"); + } + + @Test + public void functxPatternArrowChain() throws Exception { + // Arrow operator chaining + assertBothParsers("arrow chain", + "'hello world' => upper-case() => tokenize('\\s+') => string-join('-')"); + } + + @Test + public void functxPatternQuantifiedExpr() throws Exception { + // Quantified expressions — some/every + assertBothParsers("quantified expr", + "let $nums := (2, 4, 6, 8) return " + + "string-join((" + + " if (every $n in $nums satisfies $n mod 2 = 0) then 'all-even' else 'not-all-even'," + + " if (some $n in $nums satisfies $n > 5) then 'has-gt-5' else 'no-gt-5'" + + "), ',')"); + } + + @Test + public void functxPatternFilterPredicate() throws Exception { + // Predicate with complex expression on in-memory sequence + assertBothParsers("filter predicate", + "let $items := for $i in 1 to 10 return {$i * $i} " + + "return string-join($items[@n > 3][@n < 8]/string(), ',')"); + } + + @Test + public void functxPatternSwitchExpr() throws Exception { + // Switch expression + assertBothParsers("switch expression", + "for $day in ('Mon', 'Sat', 'Wed') return " + + "switch ($day) " + + "case 'Mon' case 'Tue' case 'Wed' case 'Thu' case 'Fri' return 'weekday' " + + "case 'Sat' case 'Sun' return 'weekend' " + + "default return 'unknown'"); + } + + @Test + public void eqnameFunctionReference() throws Exception { + // EQName function reference: Q{uri}name#arity + assertBothParsers("EQName function ref", + "exists(Q{http://www.w3.org/2005/xpath-functions}abs#1)"); + } + + @Test + public void eqnameFunctionCall() throws Exception { + // EQName function call: Q{uri}name(args) + assertBothParsers("EQName function call", + "Q{http://www.w3.org/2005/xpath-functions}abs(-42)"); + } + + @Ignore("requires v2/xquery-4.0-parser for evaluation") + @Test + public void bareMapConstructor() throws Exception { + // XQ4 bare map constructor: { "key": value } without 'map' keyword + assertBothParsers("bare map", + "let $m := { 'a': 1, 'b': 2 } return $m?a + $m?b"); + } + + @Test + public void namespaceUriFunctionInModule() throws Exception { + // Reproduces the xqsuite.xql line 113 pattern — + // namespace-uri-from-QName inside an inline function + assertBothParsers("namespace-uri-from-QName in module", + "let $f := true#0 " + + "return namespace-uri-from-QName(function-name($f))"); + } + + @Test + public void nestedFunctionCallInModule() throws Exception { + // Reproduces xqsuite line 113: nested function calls where outer + // should return xs:string but may be parsed as name test + assertBothParsers("nested fn calls", + "let $f := true#0 " + + "let $ns := namespace-uri-from-QName(function-name($f)) " + + "return $ns = 'http://www.w3.org/2005/xpath-functions'"); + } + + @Test + public void xqsuiteRunTestsPattern() throws Exception { + // Exact pattern from xqsuite.xql lines 225-268 + // First at line 244 parses fine, second at line 258 fails + final String query = + "declare function local:run-tests(\n" + + " $func as function(*),\n" + + " $meta as element(function),\n" + + " $test-failure-function as (function(xs:string, map(xs:string, item()?), map(xs:string, item()?)) as empty-sequence())?,\n" + + " $test-error-function as (function(xs:string, map(xs:string, item()?)?) as empty-sequence())?\n" + + ") {\n" + + " if ($meta/annotation) then\n" + + " {\n" + + " element pending { 'test' }\n" + + " }\n" + + " else\n" + + " let $failed := ()\n" + + " return\n" + + " if (not(empty($failed))) then\n" + + " {\n" + + " element assumptions {\n" + + " element assumption { 'test' }\n" + + " }\n" + + " }\n" + + " else\n" + + " \n" + + "};\n" + + "local:run-tests(true#0, , (), ())/name()"; + assertModuleEval("ok", query); + } + + @Test + public void xqsuiteRunTestsFullSignature() throws Exception { + // Full signature from xqsuite.xql — all HOF type annotations + final String query = + "declare function local:run-tests(\n" + + " $func as function(*),\n" + + " $meta as element(function),\n" + + " $test-ignored-function as (function(xs:string) as empty-sequence())?,\n" + + " $test-started-function as (function(xs:string) as empty-sequence())?,\n" + + " $test-failure-function as (function(xs:string, map(xs:string, item()?), map(xs:string, item()?)) as empty-sequence())?,\n" + + " $test-assumption-failed-function as (function(xs:string, map(xs:string, item()?)?) as empty-sequence())?,\n" + + " $test-error-function as (function(xs:string, map(xs:string, item()?)?) as empty-sequence())?,\n" + + " $test-finished-function as (function(xs:string) as empty-sequence())?\n" + + ") {\n" + + " if ($meta/annotation[ends-with(@name, ':pending')]) then\n" + + " (\n" + + " if (not(empty($test-ignored-function))) then\n" + + " $test-ignored-function(local-name($meta))\n" + + " else (),\n" + + " {\n" + + " element pending {\n" + + " $meta/annotation/value ! text()\n" + + " }\n" + + " }\n" + + " )\n" + + " else\n" + + " let $failed-assumptions := ()\n" + + " return\n" + + " if (not(empty($failed-assumptions))) then\n" + + " {\n" + + " element assumptions {\n" + + " for $fa in $failed-assumptions\n" + + " return\n" + + " element assumption {\n" + + " attribute name { replace($fa/@name, '[^:]+:(.+)', '$1') },\n" + + " $fa/value/text()\n" + + " }\n" + + " }\n" + + " }\n" + + " else\n" + + " \n" + + "};\n" + + "local:run-tests(true#0, , (), (), (), (), (), ())/name()"; + assertModuleEval("ok", query); + } + + @Test + public void xqsuiteXqlWithModuleContext() throws Exception { + // Test: compile actual xqsuite.xql with ModuleContext (the compileModule path) + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final java.io.InputStream is = getClass().getClassLoader() + .getResourceAsStream("org/exist/xquery/lib/xqsuite/xqsuite.xql"); + assertNotNull("xqsuite.xql not found on classpath", is); + final String source = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + + // Use ModuleContext — same as compileModule does + final XQueryContext parentContext = new XQueryContext(pool); + final ModuleContext modContext = new ModuleContext(parentContext, + "http://exist-db.org/xquery/xqsuite", "test", "xqsuite.xql"); + final XQueryParser parser = new XQueryParser(modContext, source); + final Expression result = parser.parse(); + assertNotNull("Parse should succeed", result); + assertTrue("Should be a library module", parser.isLibraryModule()); + } + } + + @Test + public void xqsuiteXqlViaReaderWithModuleContext() throws Exception { + // Reproduce exact compileModule path: read via Reader with 4096 buffer + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final java.io.InputStream is = getClass().getClassLoader() + .getResourceAsStream("org/exist/xquery/lib/xqsuite/xqsuite.xql"); + assertNotNull("xqsuite.xql not found on classpath", is); + + // Read via Reader with 4096 buffer — exactly as compileModule does + final java.io.Reader reader = new java.io.InputStreamReader(is, java.nio.charset.StandardCharsets.UTF_8); + final StringBuilder sb = new StringBuilder(4096); + final char[] buf = new char[4096]; + int n; + while ((n = reader.read(buf)) != -1) sb.append(buf, 0, n); + final String source = sb.toString(); + + // Use ModuleContext with a parent that has already loaded modules + // (simulating what happens when a main module imports xqsuite) + final XQueryContext parentContext = new XQueryContext(pool); + final ModuleContext modContext = new ModuleContext(parentContext, + "http://exist-db.org/xquery/xqsuite", "test", "xqsuite.xql"); + final XQueryParser parser = new XQueryParser(modContext, source); + final Expression result = parser.parse(); + assertNotNull("Parse should succeed", result); + assertTrue("Should be a library module", parser.isLibraryModule()); + } + } + + @Test + public void xqsuiteViaCompileModulePath() throws Exception { + // End-to-end test: compile a main module that imports xqsuite, + // triggering the compileModule code path with rd parser enabled. + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + // This XQuery imports xqsuite.xql, which triggers compileModule + final String xquery = + "import module namespace test = \"http://exist-db.org/xquery/xqsuite\"\n" + + " at \"resource:org/exist/xquery/lib/xqsuite/xqsuite.xql\";\n" + + "1"; + final XQueryContext context = new XQueryContext(pool); + final org.exist.xquery.parser.next.XQueryParser parser = + new org.exist.xquery.parser.next.XQueryParser(context, xquery); + // This will trigger importModule → compileModule → rd parser on xqsuite.xql + final Expression result = parser.parse(); + assertNotNull("Parse should succeed", result); + } + } + + @Test + public void xqsuiteViaAntlr2CompileModule() throws Exception { + // The REAL failure path: ANTLR 2 compiles main module, + // which triggers compileModule (rd parser) for xqsuite.xql + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xquery = pool.getXQueryService(); + // Compile a query that imports xqsuite — this uses ANTLR 2 for the main + // module and should use rd parser for compileModule of xqsuite.xql + final String query = + "import module namespace test = \"http://exist-db.org/xquery/xqsuite\"\n" + + " at \"resource:org/exist/xquery/lib/xqsuite/xqsuite.xql\";\n" + + "1"; + final XQueryContext context = new XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xquery.compile(context, query); + assertNotNull("Compilation should succeed", compiled); + } + } + + @Test + public void xqsuiteViaTestRunnerQuery() throws Exception { + // Replicate the exact XSuite test runner path: compile xquery-test-runner.xq + // which imports xqsuite.xql via resource: URI, triggering compileModule + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xquery = pool.getXQueryService(); + final String pkgName = org.exist.test.runner.XQueryTestRunner.class.getPackage().getName().replace('.', '/'); + final org.exist.source.Source src = new org.exist.source.ClassLoaderSource(pkgName + "/xquery-test-runner.xq"); + final XQueryContext context = new XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xquery.compile(context, src); + assertNotNull("Compilation should succeed", compiled); + } + } + + @Test + public void directConstructorInFunctionBody() throws Exception { + // Bug: direct element constructor with enclosed expression in function body + assertModuleEval("bar", + "declare function local:test() {\n" + + " {\n" + + " element foo { 'bar' }\n" + + " }\n" + + "};\n" + + "local:test()/foo/string()"); + } + + @Test + public void directConstructorInFunctionBodyComplex() throws Exception { + // More complex: nested elements with multiple enclosed expressions + assertModuleEval("1 2 3", + "declare function local:items($n as xs:integer) {\n" + + " {\n" + + " for $i in 1 to $n\n" + + " return {$i}\n" + + " }\n" + + "};\n" + + "string-join(local:items(3)//item/string(), ' ')"); + } + + @Test + public void directConstructorInFunctionBodyWithComputedElement() throws Exception { + // Direct element with computed element inside — the exact restxq-impl pattern + assertModuleEval("bar", + "declare function local:test() {\n" + + " {\n" + + " element foo { 'bar' },\n" + + " element baz { 'qux' }\n" + + " }\n" + + "};\n" + + "local:test()/foo/string()"); + } + + @Test + public void inlineFunctionInSequence() throws Exception { + // Bug: function keyword not recognized as inline function inside parenthesized sequence + assertBothParsers("inline function in sequence", + "(function($x) { $x + 1 })(42)"); + } + + @Test + public void inlineFunctionInTupleSequence() throws Exception { + // function keyword inside tuple (expr, expr, ...) must parse as inline function + assertBothParsers("inline fn in tuple", + "let $fns := (function ($a) { $a + 1 }, function ($b) { $b * 2 }) " + + "return $fns[1](10)"); + } + + @Test + public void inlineFunctionBodyWithNumberOnly() throws Exception { + // function ($a) {1} — body is just integer 1 + // The {1} could be mis-parsed as bare map if lookahead is wrong + assertBothParsers("fn body with number", + "(function ($a) {1})(42)"); + } + + @Test + + public void functxYearMonthDuration() throws Exception { + // FunctX test: duration arithmetic — tests Incompatible primitive types + assertBothParsers("yearMonthDuration", + "declare function local:if-empty($arg as item()?, $value as item()*) as item()* { " + + " if (string($arg) != '') then data($arg) else $value " + + "}; " + + "declare function local:yearMonthDuration($years as xs:decimal?, $months as xs:integer?) as xs:yearMonthDuration { " + + " (xs:yearMonthDuration('P1M') * local:if-empty($months,0)) + " + + " (xs:yearMonthDuration('P1Y') * local:if-empty($years,0)) " + + "}; " + + "local:yearMonthDuration(1,6)"); + } + + @Test + public void sequenceMoreThanOneItem() throws Exception { + // "sequence with more than one item" — from app-Duplicates tests + assertBothParsers("sequence cardinality", + "declare function local:non-distinct($seq as item()*) as item()* { " + + " for $val in distinct-values($seq) " + + " return if (count($seq[. = $val]) > 1) then $val else () " + + "}; " + + "string-join(local:non-distinct(('a','b','c','a','b')), ',')"); + } + + @Test + public void fnCountWithEvery() throws Exception { + // fn-count test with every/satisfies — XPTY0004 on next + assertBothParsers("count with every", + "declare function local:primes($n as xs:integer) { " + + " if ($n lt 2) then 1 " + + " else for $i in 2 to $n " + + " return if (every $x in 2 to ($i - 1) satisfies ($i mod $x ne 0)) " + + " then $i else () " + + "}; " + + "count(local:primes(20))"); + } + + // =================================================== + + @Test + public void functxPatternDocumentOrder() throws Exception { + // Document ordering after path steps — tests node identity and dedup + assertBothParsers("document order", + "let $doc := 123 " + + "return string-join($doc//b/string(), ',')"); + } + + @Test + public void functxPatternDslashPredicate() throws Exception { + // // with positional predicate — exercises axis optimization + assertBothParsers("// with predicate", + "let $doc := abc " + + "return $doc//item[2]/string()"); + } + + @Test + public void functxPatternComplexFlwor() throws Exception { + // Complex FLWOR with let, where, order by, count + assertBothParsers("complex FLWOR", + "string-join(" + + "for $x in (5, 3, 1, 4, 2) " + + "let $sq := $x * $x " + + "where $sq > 4 " + + "order by $x " + + "count $pos " + + "return $pos || ':' || $x || '=' || $sq, ' ')"); + } + + @Test + public void functxPatternTryCatch() throws Exception { + // Try/catch with error variables + assertBothParsers("try/catch", + "try { 1 div 0 } " + + "catch * { 'caught: ' || $err:code }"); + } + + @Test + public void functxPatternConstructedAttribute() throws Exception { + // Computed element with constructed attributes — attributes BEFORE content + assertBothParsers("constructed attribute", + "let $name := 'div' " + + "return element { $name } { " + + " attribute id { 'main' }, " + + " attribute class { 'container' }, " + + " 'content' " + + "}"); + } + + @Test + public void prologSection2ThenSection1Errors() throws Exception { + // K2-DefaultNamespaceProlog-13/14/15/16: a setter/import declaration + // appearing after a variable/function/option must raise XPST0003. + try { + assertModuleEval("(any)", + "declare variable $variable := 1; declare default element namespace \"http://example.com\"; 1"); + org.junit.Assert.fail("Expected XPST0003"); + } catch (final XPathException xpe) { + org.junit.Assert.assertEquals("XPST0003", + xpe.getErrorCode().getErrorQName().getLocalPart()); + } + try { + assertModuleEval("(any)", + "declare function local:f() { 1 }; declare default element namespace \"http://example.com\"; 1"); + org.junit.Assert.fail("Expected XPST0003"); + } catch (final XPathException xpe) { + org.junit.Assert.assertEquals("XPST0003", + xpe.getErrorCode().getErrorQName().getLocalPart()); + } + try { + assertModuleEval("(any)", + "declare option local:opt \"foo\"; declare default element namespace \"http://example.com\"; 1"); + org.junit.Assert.fail("Expected XPST0003"); + } catch (final XPathException xpe) { + org.junit.Assert.assertEquals("XPST0003", + xpe.getErrorCode().getErrorQName().getLocalPart()); + } + } + + @Test + public void mainModuleWithoutBodyIsXpst0003() throws Exception { + // K2-Literals-34: a main module that has only a prolog and no query + // body must raise a static error. + try { + assertModuleEval("(any)", + "declare namespace prefix = \"http://example.com/\";"); + org.junit.Assert.fail("Expected XPST0003"); + } catch (final XPathException xpe) { + org.junit.Assert.assertEquals("XPST0003", + xpe.getErrorCode().getErrorQName().getLocalPart()); + } + } + + @Test + public void mixedQuotesInAttributeConstructor() throws Exception { + // Literals067: single-quoted attribute with escaped '' and embedded ". + assertEval("He said, \"I don't like it.\"", + "string(/@check)"); + // EscapeQuot inside double-quoted attribute. + assertEval("a \"b\" c", + "string(/@a)"); + } + + @Test + public void declareFixedDefaultElementNamespace() throws Exception { + // XQ4 default-namespace-40-04 pattern. + assertModuleEval("hello", + "xquery version \"4.0\";\n" + + "declare fixed default element namespace \"http://www.example.com/test\";\n" + + "hello/string()"); + } + + @Test + public void chainedLetBindingsStillWork() throws Exception { + // Sanity check: removing parse-time declareVariableBinding must not + // break later bindings/return that legitimately reference earlier + // FLWOR-bound variables. + assertEval("6", "let $x := 1, $y := $x + 1, $z := $y + $x return $x + $y + $z"); + assertEval("3", "for $x in (1, 2, 3) where $x = 3 return $x"); + assertEval("1 2 3", "for $x in 1 to 3 return $x"); + } + + @Test + public void forSelfReferenceRaisesXpst0008() throws Exception { + // ForExpr002/009/K-ForExprWithout-36/37: variable referenced in its own + // 'in' expression must raise XPST0008 statically (the variable is not + // yet in scope), not XPDY0002 at runtime. + try { + assertEval("(any)", "for $a in (1, 2, $a) return $a"); + org.junit.Assert.fail("Expected XPathException"); + } catch (final XPathException xpe) { + org.junit.Assert.assertEquals("XPST0008", + xpe.getErrorCode().getErrorQName().getLocalPart()); + } + } + + @Test + public void forKeywordAsNameTest() throws Exception { + // K2-ForExprWithout-25/15: keyword as element name in path step. + assertModuleEval("1", + "declare function local:func($arg as element()*) as element()* { for $n in $arg/for return $n }; 1"); + assertModuleEval("1", + "declare function local:func($arg as element()*) as element()* { for $n in $arg/element return $n }; 1"); + assertModuleEval("1", + "declare function local:func($arg as element()*) as element()* { for $n in $arg/if return $n }; 1"); + assertModuleEval("1", + "declare function local:func($arg as element()*) as element()* { for $n in $arg/typeswitch return $n }; 1"); + assertModuleEval("1", + "declare function local:func($arg as element()*) as element()* { for $n in $arg/validate return $n }; 1"); + } + + /** + * Parses a simple expression without evaluating it. + */ + private Expression parseExpr(final String query) throws Exception { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + try (final DBBroker broker = pool.getBroker()) { + final XQueryContext queryContext = new XQueryContext(pool); + try { + final XQueryParser parser = new XQueryParser(queryContext, query); + return parser.parseExpression(); + } finally { + queryContext.reset(); + } + } + } + + private static void assertInstanceOf(final Class expected, final Object actual) { + assertTrue("Expected " + expected.getSimpleName() + " but got " + + (actual == null ? "null" : actual.getClass().getSimpleName()), + expected.isInstance(actual)); + } +} 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..235cb9211e2 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/value/RecordTypeTest.java @@ -0,0 +1,389 @@ +/* + * 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 testRecordTypeCheckNonExtensibleDropsExtraKeys() 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 + // Per XQ4, coercion drops extra keys (not rejects them) + 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()); + + final Sequence result = check.eval(Sequence.EMPTY_SEQUENCE, null); + assertFalse(result.isEmpty()); + // Extra key "extra" should be dropped — only "name" remains + final org.exist.xquery.functions.map.AbstractMapType resultMap = + (org.exist.xquery.functions.map.AbstractMapType) result.itemAt(0); + assertEquals(1, resultMap.size()); + assertEquals("Alice", resultMap.get(new StringValue("name")).getStringValue()); + } + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/value/ReversedSequenceTest.java b/exist-core/src/test/java/org/exist/xquery/value/ReversedSequenceTest.java new file mode 100644 index 00000000000..ef8312609ad --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/value/ReversedSequenceTest.java @@ -0,0 +1,105 @@ +/* + * 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.xquery.XPathException; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ReversedSequenceTest { + + private static ValueSequence buildSequence(final int... values) { + final ValueSequence seq = new ValueSequence(values.length); + for (final int v : values) { + seq.add(new IntegerValue(v)); + } + return seq; + } + + @Test + public void itemAt_returns_items_in_reverse() throws XPathException { + final ValueSequence original = buildSequence(10, 20, 30, 40); + final ReversedSequence reversed = new ReversedSequence(original); + + assertEquals(40, ((IntegerValue) reversed.itemAt(0)).getLong()); + assertEquals(30, ((IntegerValue) reversed.itemAt(1)).getLong()); + assertEquals(20, ((IntegerValue) reversed.itemAt(2)).getLong()); + assertEquals(10, ((IntegerValue) reversed.itemAt(3)).getLong()); + } + + @Test + public void itemAt_out_of_range_returns_null() { + final ReversedSequence reversed = new ReversedSequence(buildSequence(1, 2, 3)); + assertNull(reversed.itemAt(-1)); + assertNull(reversed.itemAt(3)); + } + + @Test + public void iterate_descends_through_items() throws XPathException { + final ReversedSequence reversed = new ReversedSequence(buildSequence(1, 2, 3, 4, 5)); + final SequenceIterator it = reversed.iterate(); + + assertEquals(5, ((IntegerValue) it.nextItem()).getLong()); + assertEquals(4, ((IntegerValue) it.nextItem()).getLong()); + assertEquals(3, ((IntegerValue) it.nextItem()).getLong()); + assertEquals(2, ((IntegerValue) it.nextItem()).getLong()); + assertEquals(1, ((IntegerValue) it.nextItem()).getLong()); + assertFalse(it.hasNext()); + assertNull(it.nextItem()); + } + + @Test + public void iterate_skip_advances_correctly() throws XPathException { + final ReversedSequence reversed = new ReversedSequence(buildSequence(1, 2, 3, 4, 5)); + final SequenceIterator it = reversed.iterate(); + + assertEquals(5L, it.skippable()); + assertEquals(2L, it.skip(2)); + assertEquals(3L, it.skippable()); + assertEquals(3, ((IntegerValue) it.nextItem()).getLong()); + } + + @Test + public void cardinality_reflects_size() { + assertTrue(new ReversedSequence(buildSequence()).isEmpty()); + assertTrue(new ReversedSequence(buildSequence(7)).hasOne()); + assertTrue(new ReversedSequence(buildSequence(1, 2)).hasMany()); + } + + @Test + public void item_count_matches_original() { + final ValueSequence original = buildSequence(1, 2, 3, 4); + assertEquals(original.getItemCount(), new ReversedSequence(original).getItemCount()); + } + + @Test + public void getOriginal_returns_underlying_sequence() { + final ValueSequence original = buildSequence(1, 2, 3); + final ReversedSequence reversed = new ReversedSequence(original); + assertSame(original, reversed.getOriginal()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/value/SubSequenceRangeTest.java b/exist-core/src/test/java/org/exist/xquery/value/SubSequenceRangeTest.java index edbace2627b..608672a48ca 100644 --- a/exist-core/src/test/java/org/exist/xquery/value/SubSequenceRangeTest.java +++ b/exist-core/src/test/java/org/exist/xquery/value/SubSequenceRangeTest.java @@ -87,7 +87,7 @@ public static java.util.Collection data() { @Parameter(value = 3) public int expectedSubsequenceLength; - private static final RangeSequence range = new RangeSequence(new IntegerValue(RANGE_START), new IntegerValue(RANGE_END)); + private static final RangeSequence range = new RangeSequence(RANGE_START, RANGE_END); private SubSequence getSubsequence() { return new SubSequence(fromInclusive, toExclusive, range); diff --git a/exist-core/src/test/java/org/exist/xquery/value/SubSequenceTest.java b/exist-core/src/test/java/org/exist/xquery/value/SubSequenceTest.java index de7ff3cc40f..cf75ed7201b 100644 --- a/exist-core/src/test/java/org/exist/xquery/value/SubSequenceTest.java +++ b/exist-core/src/test/java/org/exist/xquery/value/SubSequenceTest.java @@ -83,7 +83,7 @@ public static java.util.Collection data() { @Parameterized.Parameter(value = 3) public int expectedSubsequenceLength; - private static final RangeSequence range = new RangeSequence(new IntegerValue(RANGE_START), new IntegerValue(RANGE_END)); + private static final RangeSequence range = new RangeSequence(RANGE_START, RANGE_END); private SubSequence getSubsequence() { return new SubSequence(fromInclusive, toExclusive, range); diff --git a/exist-core/src/test/java/org/exist/xquery/value/jnode/JNodeTest.java b/exist-core/src/test/java/org/exist/xquery/value/jnode/JNodeTest.java new file mode 100644 index 00000000000..4697ccb9c5a --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/value/jnode/JNodeTest.java @@ -0,0 +1,705 @@ +/* + * 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.jnode; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.CompiledXQuery; +import org.exist.xquery.XQuery; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.XPathException; +import org.exist.xquery.value.Sequence; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.*; + +/** + * JUnit tests for XQuery 4.0 JNode support. + * Tests fn:jtree, fn:jkey, fn:jvalue, fn:jposition via embedded XQuery evaluation. + */ +public class JNodeTest { + + @ClassRule + public static final ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + private Sequence executeQuery(final String xquery) throws EXistException, PermissionDeniedException, XPathException { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + final XQuery xqueryService = pool.getXQueryService(); + try (final DBBroker broker = pool.get(Optional.of(pool.getSecurityManager().getSystemSubject()))) { + return xqueryService.execute(broker, xquery, null); + } + } + + @Test + public void jtreeFromMapReturnsNonEmpty() throws Exception { + final Sequence result = executeQuery( + "let $j := fn:jtree(map { 'a': 1 }) return exists($j)"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jtreeFromArrayReturnsNonEmpty() throws Exception { + final Sequence result = executeQuery( + "let $j := fn:jtree(array { 1, 2, 3 }) return exists($j)"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jtreeFromStringReturnsNonEmpty() throws Exception { + final Sequence result = executeQuery( + "let $j := fn:jtree('hello') return exists($j)"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jvalueFromMapReturnMap() throws Exception { + final Sequence result = executeQuery( + "let $j := fn:jtree(map { 'name': 'Alice' }) " + + "return fn:jvalue($j)('name')"); + assertEquals("Alice", result.getStringValue()); + } + + @Test + public void jvalueFromArrayReturnsArray() throws Exception { + final Sequence result = executeQuery( + "let $j := fn:jtree(array { 1, 2, 3 }) " + + "return array:size(fn:jvalue($j))"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void jvalueFromStringReturnsString() throws Exception { + final Sequence result = executeQuery( + "fn:jvalue(fn:jtree('hello'))"); + assertEquals("hello", result.getStringValue()); + } + + @Test + public void jkeyOfRootIsEmpty() throws Exception { + final Sequence result = executeQuery( + "let $j := fn:jtree(map { 'a': 1 }) return empty(fn:jkey($j))"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jpositionOfRootIsZero() throws Exception { + final Sequence result = executeQuery( + "fn:jposition(fn:jtree(map { 'a': 1 }))"); + assertEquals("0", result.getStringValue()); + } + + @Test + public void jtreePreservesMapType() throws Exception { + final Sequence result = executeQuery( + "fn:jvalue(fn:jtree(map { 'x': 1 })) instance of map(*)"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jtreePreservesArrayType() throws Exception { + final Sequence result = executeQuery( + "fn:jvalue(fn:jtree(array { 'a', 'b' })) instance of array(*)"); + assertEquals("true", result.getStringValue()); + } + + // --- fn:jchildren --- + + @Test + public void jchildrenOfMapReturnsMembers() throws Exception { + final Sequence result = executeQuery( + "count(fn:jchildren(fn:jtree(map { 'a': 1, 'b': 2, 'c': 3 })))"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void jchildrenOfArrayReturnsItems() throws Exception { + final Sequence result = executeQuery( + "count(fn:jchildren(fn:jtree(array { 10, 20, 30 })))"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void jchildrenOfLeafIsEmpty() throws Exception { + final Sequence result = executeQuery( + "empty(fn:jchildren(fn:jtree('hello')))"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jkeyOfMapChild() throws Exception { + final Sequence result = executeQuery( + "let $root := fn:jtree(map { 'name': 'Alice' }) " + + "let $child := fn:jchildren($root)[1] " + + "return fn:jkey($child)"); + assertEquals("name", result.getStringValue()); + } + + @Test + public void jvalueOfMapChild() throws Exception { + final Sequence result = executeQuery( + "let $root := fn:jtree(map { 'name': 'Alice' }) " + + "let $child := fn:jchildren($root)[1] " + + "return fn:jvalue($child)"); + assertEquals("Alice", result.getStringValue()); + } + + @Test + public void jpositionOfChildren() throws Exception { + final Sequence result = executeQuery( + "let $root := fn:jtree(array { 'x', 'y', 'z' }) " + + "return string-join(for $c in fn:jchildren($root) return string(fn:jposition($c)), ',')"); + assertEquals("1,2,3", result.getStringValue()); + } + + // --- fn:jparent --- + + @Test + public void jparentOfRootIsEmpty() throws Exception { + final Sequence result = executeQuery( + "empty(fn:jparent(fn:jtree(map { 'a': 1 })))"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jparentOfChildIsRoot() throws Exception { + final Sequence result = executeQuery( + "let $root := fn:jtree(map { 'a': 1 }) " + + "let $child := fn:jchildren($root)[1] " + + "return fn:jvalue(fn:jparent($child))('a')"); + assertEquals("1", result.getStringValue()); + } + + // --- Nested navigation --- + + @Test + public void nestedMapNavigation() throws Exception { + final Sequence result = executeQuery( + "let $root := fn:jtree(map { 'person': map { 'name': 'Alice', 'age': 30 } }) " + + "let $person := fn:jchildren($root)[1] " + + "let $name := fn:jchildren(fn:jtree(fn:jvalue($person)))[1] " + + "return fn:jvalue($name)"); + // The first child of the nested map + assertNotNull(result); + assertTrue(result.getItemCount() > 0); + } + + // --- Kind tests (grammar) --- + + @Test + public void instanceOfJsonNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; fn:jtree(map { 'a': 1 }) instance of json-node()"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void instanceOfObjectNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; fn:jtree(map { 'a': 1 }) instance of object-node()"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void instanceOfArrayNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; fn:jtree(array { 1, 2 }) instance of array-node()"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void instanceOfStringNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; fn:jtree('hello') instance of string-node()"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void mapIsNotArrayNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; fn:jtree(map { 'a': 1 }) instance of array-node()"); + assertEquals("false", result.getStringValue()); + } + + @Test + public void kindTestRejectedInXQ31() throws Exception { + try { + executeQuery("xquery version \"3.1\"; fn:jtree(map { 'a': 1 }) instance of object-node()"); + fail("Expected XPST0003 for JNode kind test in XQuery 3.1"); + } catch (final XPathException e) { + assertTrue(e.getMessage().contains("requires xquery version")); + } + } + + @Test + public void arrayOfMapsNavigation() throws Exception { + final Sequence result = executeQuery( + "let $root := fn:jtree(array { map { 'x': 1 }, map { 'x': 2 } }) " + + "let $items := fn:jchildren($root) " + + "return count($items)"); + assertEquals("2", result.getStringValue()); + } + + // --- XPath axis navigation (requires xquery version 4.0) --- + + @Test + public void xpathChildAxis() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2, 'c': 3 }) " + + "return count($root/child::json-node())"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void xpathChildWildcard() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2, 'c': 3 }) " + + "return count($root/child::*)"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void xpathChildNamedKey() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'name': 'Joe', 'age': 42 }) " + + "return fn:jvalue($root/name)"); + assertEquals("Joe", result.getStringValue()); + } + + @Test + public void xpathMultiStepNavigation() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': map { 'x': 1, 'y': 2 }, 'b': 3 }) " + + "return count($root/a/child::*)"); + assertEquals("2", result.getStringValue()); + } + + @Test + public void xpathHistogramSelfCount() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2 }) " + + "return count($root/self::*)"); + assertEquals("1", result.getStringValue()); + } + + @Test + public void xpathHistogramChildCount() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2, 'c': 3 }) " + + "return count($root/child::*)"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void xpathHistogramDescendantCount() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': map { 'x': 1 }, 'b': 2 }) " + + "return count($root/descendant::*)"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void xpathHistogramAncestorCount() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': map { 'x': 1 }, 'b': 2 }) " + + "return count($root/a/x/ancestor::*)"); + assertEquals("2", result.getStringValue()); + } + + @Test + public void xpathHistogramFollowingSiblingCount() throws Exception { + // Wildcard following-sibling + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2, 'c': 3 }) " + + "let $first := ($root/child::json-node())[1] " + + "return count($first/following-sibling::*)"); + assertEquals("2", result.getStringValue()); + } + + @Test + public void xpathChildObjectNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(array { map { 'x': 1 }, 'hello', 42 }) " + + "return count($root/child::object-node())"); + assertEquals("1", result.getStringValue()); + } + + @Test + public void xpathChildStringNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(array { map { 'x': 1 }, 'hello', 42 }) " + + "return count($root/child::string-node())"); + assertEquals("1", result.getStringValue()); + } + + @Test + public void xpathMultiStepChildParent() throws Exception { + // Verify children are JNodes + final Sequence step1 = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2 }) " + + "let $children := $root/child::json-node() " + + "return count($children)"); + assertEquals("2", step1.getStringValue()); + + // Verify parent of each child via function + final Sequence step2 = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'a': 1, 'b': 2 }) " + + "for $c in $root/child::json-node() " + + "return exists(fn:jparent($c))"); + // Should return "true true" + assertTrue(step2.getStringValue().contains("true")); + + // Single-variable parent axis: $child/parent::json-node() + final Sequence step3 = executeQuery( + "xquery version '4.0'; " + + "let $child := fn:jchildren(fn:jtree(map { 'a': 1 }))[1] " + + "return count($child/parent::json-node())"); + assertEquals("1", step3.getStringValue()); + } + + // --- Serialization --- + + @Test + public void serializeJNodeMapAsJson() throws Exception { + final Sequence result = executeQuery( + "serialize(fn:jtree(map { 'name': 'Alice', 'age': 30 }), " + + "map { 'method': 'json' })"); + final String json = result.getStringValue(); + assertTrue(json.contains("\"name\"")); + assertTrue(json.contains("\"Alice\"")); + assertTrue(json.contains("30")); + } + + @Test + public void serializeJNodeArrayAsJson() throws Exception { + final Sequence result = executeQuery( + "serialize(fn:jtree(array { 1, 2, 3 }), " + + "map { 'method': 'json' })"); + assertEquals("[1,2,3]", result.getStringValue().replaceAll("\\s+", "")); + } + + @Test + public void serializeJNodeAdaptive() throws Exception { + final Sequence result = executeQuery( + "serialize(fn:jtree(map { 'x': 1 }), " + + "map { 'method': 'adaptive' })"); + assertTrue(result.getStringValue().contains("\"x\"")); + } + + @Test + public void serializeJNodeStringAsJson() throws Exception { + final Sequence result = executeQuery( + "serialize(fn:jtree('hello'), " + + "map { 'method': 'json' })"); + assertEquals("\"hello\"", result.getStringValue()); + } + + @Test + public void xpathParentViaFunction() throws Exception { + // Parent axis via fn:jparent works; parent via "/" chain is a TODO + // (requires removeDuplicates() to handle JNodes) + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'name': 'Alice' }) " + + "let $child := fn:jchildren($root)[1] " + + "return fn:jparent($child) instance of object-node()"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void xpathSelfAxis() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "fn:jtree(map { 'a': 1 })/self::object-node() instance of object-node()"); + assertEquals("true", result.getStringValue()); + } + + // --- Map/Array Predicate Tests (XPDY0002 regression) --- + + @Test + public void mapNavigationWithPredicate() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; map{'asdf':1}/asdf[. <= 1]"); + assertEquals("1", result.getStringValue()); + } + + @Test + public void mapWildcardWithPredicate() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; map{'a':1,'b':2}/*[. > 1]"); + assertEquals("2", result.getStringValue()); + } + + @Test + public void arrayWildcardWithPredicate() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; [1,2,3]/*[. > 1]"); + assertEquals(2, result.getItemCount()); + } + + @Test + public void parenthesizedMapWithPredicate() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; (map{'asdf':1}/asdf)[. <= 1]"); + assertEquals("1", result.getStringValue()); + } + + @Test + public void nestedMapWithPredicate() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; map{'x': map{'y': 1}}/x/y[. = 1]"); + assertEquals("1", result.getStringValue()); + } + + @Test + public void mapSequenceValueWithPredicate() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; map{'a': (1,2,3)}/a[. > 1]"); + assertEquals(2, result.getItemCount()); + } + + @Test + public void xpathDescendantAxis() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $root := fn:jtree(map { 'inner': map { 'deep': 'value' } }) " + + "return count($root/descendant::json-node())"); + // inner map child (1) + deep string child inside inner (1) = 2 + assertTrue(Integer.parseInt(result.getStringValue()) >= 2); + } + + /** + * Regression test for JNode flowing into a user-defined function whose parameter + * is declared as jnode(). Path expressions have static return type Type.NODE, + * so the function-dispatch static type check would otherwise reject + * the JNode flowing in. The check must defer to runtime DynamicTypeCheck. + */ + @Test + public void udfWithJsonNodeParamAcceptsPathArg() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "declare function local:count-children($n as jnode()) as xs:integer { " + + " count($n/child::*) " + + "}; " + + "let $root := fn:jtree(map { 'a': map { 'x': 1, 'y': 2 }, 'b': 3 }) " + + "return local:count-children($root/a)"); + assertEquals("2", result.getStringValue()); + } + + @Test + public void udfWithJsonNodeParamAcceptsMultiStepPath() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "declare function local:depth($n as jnode()) as xs:integer { " + + " count($n/ancestor::*) " + + "}; " + + "let $root := fn:jtree(map { 'a': map { 'b': map { 'c': 1 } } }) " + + "return local:depth($root/a/b/c)"); + assertEquals("3", result.getStringValue()); + } + + /** + * Mirrors the XQTS JAxes-002 pattern: caller is in default (3.1) mode, + * UDF accepts jnode() parameter, path expression argument is the call site. + * Tests that the type-coercion fix doesn't require the caller to declare XQ4. + */ + @Test + public void udfWithJsonNodeParamFromXq31Caller() throws Exception { + // Note: XQ4 mode required to declare 'as jnode()' parameter type. + // JAxes-002 module is presumably parsed in XQ4 mode somehow. + // Local function in same query body inherits caller's version. + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "declare function local:depth($n as jnode()) as xs:integer { " + + " count($n/ancestor::*) " + + "}; " + + "let $root := fn:jtree(map { 'a': map { 'b': 1 } }) " + + "return local:depth($root/a/b)"); + assertEquals("2", result.getStringValue()); + } + + /** + * Diagnostic: does jnode() kind test parse in 3.1 mode at all? + */ + @Test + public void jnodeKindTestRequiresXQ4() throws Exception { + try { + executeQuery( + "declare function local:f($n as jnode()) { 1 }; " + + "local:f(1)"); + fail("Expected XPST0003 — jnode() requires XQ4"); + } catch (final XPathException e) { + assertTrue(e.getMessage().contains("xquery version") || + e.getMessage().contains("4.0") || + e.getMessage().contains("XPST0003")); + } + } + + /** + * Replicates the JAxes-002 setup as closely as possible: + * - Imported module declares xquery version 4.0 with as jnode() parameter + * - Test query is in default 3.1 mode + * - Path expression argument flows into JSON_NODE param + */ + @Test + public void udfXQ4ModuleCalledFromXQ31Caller() throws Exception { + // Write a temporary module file declaring jnode() param + final java.nio.file.Path tmpDir = java.nio.file.Files.createTempDirectory("jnode-mod"); + try { + final java.nio.file.Path modFile = tmpDir.resolve("histogram.xq"); + java.nio.file.Files.writeString(modFile, + "xquery version \"4.0\";\n" + + "module namespace ax=\"http://test.com/ax\";\n" + + "declare function ax:histogram($origin as jnode()) as map(*) {\n" + + " map { 'child': count($origin/child::*) }\n" + + "};\n"); + final String moduleUri = modFile.toUri().toString(); + final Sequence result = executeQuery( + "import module namespace ax=\"http://test.com/ax\" at \"" + moduleUri + "\"; " + + "let $root := fn:jtree(map { 'a': map { 'x': 1, 'y': 2 } }) " + + "return ax:histogram($root/a)?child"); + assertEquals("2", result.getStringValue()); + } finally { + java.nio.file.Files.walk(tmpDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { java.nio.file.Files.delete(p); } catch (Exception e) { } }); + } + } + + // --- get(args) as path step (XQuery 4.0) --- + + @Test + public void getStepOnMapStringKey() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $m := map { 'x': 1, 'y': 2, 'z': 3 } " + + "return $m/get('z')"); + assertEquals("3", result.getStringValue()); + } + + @Test + public void getStepOnMapMultipleKeys() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $m := map { 'x': 1, 'y': 2, 'z': 3 } " + + "return $m/get(('z', 'x'))"); + // Lookup with multiple keys returns matching values in order + assertTrue(result.getItemCount() == 2); + } + + @Test + public void getStepOnArrayInteger() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $a := array { 'a', 'b', 'c' } " + + "return $a/get(2)"); + assertEquals("b", result.getStringValue()); + } + + @Test + public void getStepOnJNodeObjectReturnsChildNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(map { 'name': 'Joe', 'age': 42 }) " + + "return fn:jvalue($j/get('name'))"); + assertEquals("Joe", result.getStringValue()); + } + + @Test + public void getStepOnJNodeObjectExistsCheck() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(map { 'name': 'Joe' }) " + + "return exists($j/get('name'))"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void getStepOnJNodeReturnsMemberNode() throws Exception { + // For an object-node child accessed via get(), the result is a member-node + // (key + container value). Use member-node() instead of object-node(). + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(map { 'a': map { 'x': 1 } }) " + + "return $j/get('a') instance of member-node()"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void getStepReturnsSameAsNameStep() throws Exception { + // /name returns child JNode; /get('name') should return the same + final Sequence viaName = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(map { 'name': 'Joe' }) " + + "return fn:jvalue($j/name)"); + final Sequence viaGet = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(map { 'name': 'Joe' }) " + + "return fn:jvalue($j/get('name'))"); + assertEquals(viaName.getStringValue(), viaGet.getStringValue()); + } + + @Test + public void getStepOnJNodeArrayReturnsChildNode() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(array { 'a', 'b', 'c' }) " + + "return fn:jvalue($j/get(2))"); + assertEquals("b", result.getStringValue()); + } + + @Test + public void getStepAbsentKeyReturnsEmpty() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $m := map { 'x': 1, 'y': 2 } " + + "return empty($m/get('w'))"); + assertEquals("true", result.getStringValue()); + } + + @Test + public void jnodeWildcardLookupReturnsAllChildren() throws Exception { + final Sequence result = executeQuery( + "xquery version '4.0'; " + + "let $j := fn:jtree(map { 'a': 1, 'b': 2, 'c': 3 }) " + + "return count($j?*)"); + assertEquals("3", result.getStringValue()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/xquf/XQUFBasicTest.java b/exist-core/src/test/java/org/exist/xquery/xquf/XQUFBasicTest.java new file mode 100644 index 00000000000..152ec61cc36 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/xquf/XQUFBasicTest.java @@ -0,0 +1,2227 @@ +/* + * 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.xquf; + +import org.exist.TestUtils; +import org.exist.test.ExistXmldbEmbeddedServer; +import org.exist.xmldb.IndexQueryService; +import org.junit.*; +import org.xmldb.api.DatabaseManager; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.CollectionManagementService; +import org.xmldb.api.modules.XMLResource; +import org.xmldb.api.modules.XQueryService; + +import static org.junit.Assert.*; + +/** + * Tests for W3C XQuery Update Facility 3.0 expressions. + * + * Tests insert, delete, replace, replace value of, rename, and copy-modify + * expressions against persistent (stored) documents. + */ +public class XQUFBasicTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = new ExistXmldbEmbeddedServer(false, true, true); + + private Collection testCollection; + + @Before + public void setUp() throws Exception { + final CollectionManagementService service = + existEmbeddedServer.getRoot().getService(CollectionManagementService.class); + testCollection = service.createCollection("test"); + } + + @After + public void tearDown() throws XMLDBException { + final CollectionManagementService service = + existEmbeddedServer.getRoot().getService(CollectionManagementService.class); + service.removeCollection("test"); + testCollection = null; + } + + private XQueryService storeXMLStringAndGetQueryService(final String documentName, + final String content) throws XMLDBException { + final XMLResource doc = testCollection.createResource(documentName, XMLResource.class); + doc.setContent(content); + testCollection.storeResource(doc); + return testCollection.getService(XQueryService.class); + } + + private ResourceSet queryResource(final XQueryService service, final String resource, + final String query, final int expected) throws XMLDBException { + final ResourceSet result = service.queryResource(resource, query); + assertEquals(query, expected, result.getSize()); + return result; + } + + private String queryAndGetString(final XQueryService service, final String query) throws XMLDBException { + final ResourceSet result = service.query(query); + assertEquals("Expected single result for: " + query, 1L, result.getSize()); + return result.getResource(0).getContent().toString(); + } + + // === Insert tests === + + @Test + public void insertNodeInto() throws XMLDBException { + final String docName = "insert-into.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "insert node into /root", 0); + + queryResource(service, docName, "/root/b", 1); + queryResource(service, docName, "/root/*", 2); + } + + @Test + public void insertNodesInto() throws XMLDBException { + final String docName = "insert-nodes.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "insert nodes (, ) into /root", 0); + + queryResource(service, docName, "/root/*", 2); + } + + @Test + public void insertNodeBefore() throws XMLDBException { + final String docName = "insert-before.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "insert node before /root/b", 0); + + // Verify comes before + final ResourceSet result = service.queryResource(docName, "/root/*[1]"); + assertEquals(1L, result.getSize()); + assertEquals("", result.getResource(0).getContent().toString()); + } + + @Test + public void insertNodeAfter() throws XMLDBException { + final String docName = "insert-after.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "
"); + + queryResource(service, docName, "insert node after /root/a", 0); + + final ResourceSet result = service.queryResource(docName, "/root/*[2]"); + assertEquals(1L, result.getSize()); + assertEquals("", result.getResource(0).getContent().toString()); + } + + @Test + public void insertNodeAsFirstInto() throws XMLDBException { + final String docName = "insert-first.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "insert node as first into /root", 0); + + final ResourceSet result = service.queryResource(docName, "/root/*[1]"); + assertEquals(1L, result.getSize()); + assertEquals("", result.getResource(0).getContent().toString()); + } + + @Test + public void insertNodeAsLastInto() throws XMLDBException { + final String docName = "insert-last.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "
"); + + queryResource(service, docName, "insert node as last into /root", 0); + + final ResourceSet result = service.queryResource(docName, "/root/*[last()]"); + assertEquals(1L, result.getSize()); + assertEquals("", result.getResource(0).getContent().toString()); + } + + @Test + public void insertTextNode() throws XMLDBException { + final String docName = "insert-text.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "insert node text {'hello'} into /root", 0); + + final ResourceSet result = service.queryResource(docName, "string(/root)"); + assertEquals(1L, result.getSize()); + assertEquals("hello", result.getResource(0).getContent().toString()); + } + + // === Delete tests === + + @Test + public void deleteNode() throws XMLDBException { + final String docName = "delete.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "
"); + + queryResource(service, docName, "delete node /root/b", 0); + + queryResource(service, docName, "/root/*", 2); + queryResource(service, docName, "/root/b", 0); + } + + @Test + public void deleteNodes() throws XMLDBException { + final String docName = "delete-nodes.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "
"); + + queryResource(service, docName, "delete nodes /root/*[position() > 1]", 0); + + queryResource(service, docName, "/root/*", 1); + queryResource(service, docName, "/root/a", 1); + } + + // === Replace node tests === + + @Test + public void replaceNode() throws XMLDBException { + final String docName = "replace.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "old
"); + + queryResource(service, docName, "replace node /root/a with new", 0); + + queryResource(service, docName, "/root/a", 0); + queryResource(service, docName, "/root/b", 1); + + final ResourceSet result = service.queryResource(docName, "string(/root/b)"); + assertEquals("new", result.getResource(0).getContent().toString()); + } + + // === Replace value of tests === + + @Test + public void replaceValueOfElement() throws XMLDBException { + final String docName = "replace-value.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "old"); + + queryResource(service, docName, "replace value of node /root/a with 'new'", 0); + + final ResourceSet result = service.queryResource(docName, "string(/root/a)"); + assertEquals("new", result.getResource(0).getContent().toString()); + } + + @Test + public void replaceValueOfAttribute() throws XMLDBException { + final String docName = "replace-attr-value.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "replace value of node /root/@x with 'new'", 0); + + final ResourceSet result = service.queryResource(docName, "string(/root/@x)"); + assertEquals("new", result.getResource(0).getContent().toString()); + } + + @Test + public void replaceValueOfText() throws XMLDBException { + final String docName = "replace-text-value.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "old"); + + queryResource(service, docName, "replace value of node /root/text() with 'new'", 0); + + final ResourceSet result = service.queryResource(docName, "string(/root)"); + assertEquals("new", result.getResource(0).getContent().toString()); + } + + // === Rename tests === + + @Test + public void renameElement() throws XMLDBException { + final String docName = "rename.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, "content"); + + queryResource(service, docName, "rename node /root/oldname as 'newname'", 0); + + queryResource(service, docName, "/root/oldname", 0); + queryResource(service, docName, "/root/newname", 1); + + final ResourceSet result = service.queryResource(docName, "string(/root/newname)"); + assertEquals("content", result.getResource(0).getContent().toString()); + } + + @Test + public void renameAttribute() throws XMLDBException { + final String docName = "rename-attr.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + queryResource(service, docName, "rename node /root/@oldattr as 'newattr'", 0); + + queryResource(service, docName, "/root/@oldattr", 0); + queryResource(service, docName, "/root/@newattr", 1); + + final ResourceSet result = service.queryResource(docName, "string(/root/@newattr)"); + assertEquals("value", result.getResource(0).getContent().toString()); + } + + // === Transform (copy-modify) tests === + + @Test + public void copyModifyReplaceValue() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $node := old " + + "return copy $c := $node " + + "modify replace value of node $c/a with 'new' " + + "return $c"; + + final String result = queryAndGetString(service, query); + assertTrue("Expected result to contain 'new', got: " + result, + result.contains("new")); + assertFalse("Expected result to NOT contain 'old', got: " + result, + result.contains("old")); + } + + @Test + public void copyModifyDoesNotAffectOriginal() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $node := original " + + "let $copy := copy $c := $node " + + " modify replace value of node $c/a with 'modified' " + + " return $c " + + "return ($node/a/text(), '|', $copy/a/text())"; + + final ResourceSet result = service.query(query); + assertEquals(3L, result.getSize()); + assertEquals("original", result.getResource(0).getContent().toString()); + assertEquals("modified", result.getResource(2).getContent().toString()); + } + + @Test + public void copyModifyDelete() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $node := " + + "return copy $c := $node " + + "modify delete node $c/b " + + "return count($c/*)"; + + final String result = queryAndGetString(service, query); + assertEquals("2", result); + } + + @Test + public void copyModifyInsert() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $node := " + + "return copy $c := $node " + + "modify insert node into $c " + + "return count($c/*)"; + + final String result = queryAndGetString(service, query); + assertEquals("2", result); + } + + @Test + public void copyModifyRename() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $node := " + + "return copy $c := $node " + + "modify rename node $c/old as 'new' " + + "return local-name($c/*[1])"; + + final String result = queryAndGetString(service, query); + assertEquals("new", result); + } + + @Test + public void copyModifyMultipleBindings() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $a := 1 " + + "let $b := 2 " + + "return copy $ca := $a, $cb := $b " + + "modify (replace value of node $ca with '10', replace value of node $cb with '20') " + + "return ($ca, $cb)"; + + final ResourceSet result = service.query(query); + assertEquals(2L, result.getSize()); + assertTrue(result.getResource(0).getContent().toString().contains("10")); + assertTrue(result.getResource(1).getContent().toString().contains("20")); + } + + // === Combined update tests === + + @Test + public void multipleUpdatesInFlwor() throws XMLDBException { + final String docName = "multi-update.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, + ""); + + // Delete all items, then insert a new one + queryResource(service, docName, "delete nodes /root/item", 0); + queryResource(service, docName, "/root/item", 0); + + queryResource(service, docName, "insert node into /root", 0); + queryResource(service, docName, "/root/item[@n='new']", 1); + } + + // === Error condition tests === + + @Test(expected = XMLDBException.class) + public void replaceNodeDocumentTarget() throws XMLDBException { + final String docName = "error-doc-target.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Replacing a document node should fail with XUTY0008 + service.queryResource(docName, "replace node / with "); + } + + @Test(expected = XMLDBException.class) + public void replaceValueOfDocumentTarget() throws XMLDBException { + final String docName = "error-doc-target2.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + service.queryResource(docName, "replace value of node / with 'text'"); + } + + // === XUST0001 static analysis tests === + + @Test(expected = XMLDBException.class) + public void xust0001InsertInNonUpdatingFunction() throws XMLDBException { + final String docName = "xust0001-func.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Non-updating function containing an insert expression should fail with XUST0001 + service.queryResource(docName, + "declare function local:f($e as element()) { insert node into $e }; " + + "local:f(/root)"); + } + + @Test(expected = XMLDBException.class) + public void xust0001DeleteInLogicalOp() throws XMLDBException { + final String docName = "xust0001-logical.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Delete expression in logical AND operand should fail with XUST0001 + service.queryResource(docName, "fn:false() and (delete node /root/a)"); + } + + @Test(expected = XMLDBException.class) + public void xust0001InsertInForInput() throws XMLDBException { + final String docName = "xust0001-for.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Insert expression in for clause input should fail with XUST0001 + service.queryResource(docName, "for $x in (insert node into /root) return $x"); + } + + @Test(expected = XMLDBException.class) + public void xust0001InsertInFunctionArgument() throws XMLDBException { + final String docName = "xust0001-arg.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Insert expression as function argument should fail with XUST0001 + service.queryResource(docName, "fn:count(insert node into /root)"); + } + + @Test + public void xust0001MixedConditionalBranches() throws XMLDBException { + final String docName = "xust0001-cond.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Mixed updating/non-updating branches should fail with XUST0001 + try { + service.queryResource(docName, + "if (fn:false()) then 'not updating' else insert node into /root"); + fail("Expected XMLDBException for XUST0001 but query succeeded"); + } catch (XMLDBException e) { + assertTrue("Expected XUST0001, got: " + e.getMessage(), + e.getMessage().contains("XUST0001")); + } + } + + @Test + public void updatingFunctionKeywordSyntax() throws XMLDBException { + final String docName = "updating-func-keyword.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // W3C 1.0 keyword syntax: declare updating function + service.queryResource(docName, + "declare updating function local:add($e as element()) { " + + " insert node into $e " + + "}; " + + "local:add(/root)"); + queryResource(service, docName, "/root/b", 1); + } + + @Test + public void updatingFunctionAnnotationSyntax() throws XMLDBException { + final String docName = "updating-func-annot.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // W3C 3.0 annotation syntax: declare %updating function + service.queryResource(docName, + "declare %updating function local:add($e as element()) { " + + " insert node into $e " + + "}; " + + "local:add(/root)"); + queryResource(service, docName, "/root/c", 1); + } + + @Test(expected = XMLDBException.class) + public void xust0028UpdatingFunctionWithReturnType() throws XMLDBException { + final String docName = "xust0028.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // XUST0028: updating function must not declare a return type + service.queryResource(docName, + "declare updating function local:f() as item()* { " + + " insert node into /root " + + "}; " + + "local:f()"); + } + + @Test(expected = XMLDBException.class) + public void xust0002UpdatingFunctionNonUpdatingBody() throws XMLDBException { + final String docName = "xust0002.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // XUST0002: body of updating function must be updating or vacuous + service.queryResource(docName, + "declare updating function local:f($x as xs:integer) { " + + " $x + 1 " + + "}; " + + "local:f(1)"); + } + + @Test + public void xust0001InsertInFlworReturnIsAllowed() throws XMLDBException { + final String docName = "xust0001-return.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Insert expression in FLWOR return clause IS allowed (at top level) + queryResource(service, docName, "for $x in /root/a return insert node into /root", 0); + queryResource(service, docName, "/root/b", 1); + } + + @Test(timeout = 10000) + public void copyModifyMultipleInsertAfterSameNode() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $doc := E1P140 " + + "return copy $c := $doc " + + "modify ( " + + " insert node (Part Time,26) after $c/empnum[1], " + + " insert node (Full Time,30) after $c/empnum[1] " + + ") return $c"; + + final ResourceSet result = service.query(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertTrue("Should contain Part Time", xml.contains("Part Time")); + assertTrue("Should contain Full Time", xml.contains("Full Time")); + } + + // === Multi-step update + query tests (complex-deletes regression) === + + @Test + public void deletePIMultiStepPrecedingSiblingTextCount() throws Exception { + // Simulates the XQTS multi-step pattern: update query mutates an in-memory doc, + // then a separate verification query reads it. + // This is the pattern that fails in complex-deletes-q3. + final XQueryService service = testCollection.getService(XQueryService.class); + + // Parse document externally (like the XQTS runner does with SAXParser) + final String xml = "ABCD"; + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParser saxParser = javax.xml.parsers.SAXParserFactory.newDefaultInstance().newSAXParser(); + saxParser.getXMLReader().setContentHandler(adapter); + saxParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + saxParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + // Step 1: Run update query with document as external variable + service.declareVariable("doc", doc); + service.query("declare variable $doc external; delete nodes $doc//processing-instruction('pi')"); + + // Step 2: Run verification query on the same document + service.declareVariable("doc", doc); + final ResourceSet result = service.query( + "declare variable $doc external; count($doc//child/preceding-sibling::text())"); + assertEquals("Expected single result", 1L, result.getSize()); + final String count = result.getResource(0).getContent().toString(); + + // After deleting the PI between B and C, B+C merge per W3C spec → 2 text nodes: A, BC + assertEquals("Text node count after PI deletion", "2", count); + } + + @Test + public void deletePIMultiStepComplexDeletesQ3() throws Exception { + // Full complex-deletes-q3 pattern with doc-level PIs, + // using BrokerPool + XQuery service directly with context sequence (like the XQTS runner). + final String xml = + "" + + " text-1A\n" + + " text-1B\n" + + " text-1C\n" + + " text-2A\n" + + " text-3A\n" + + "
text-4A\n" + + " text-4B\n" + + " text-4C\n" + + " text-4D\n" + + " text-5A\n" + + " text-4E\n" + + "
text-3E\n" + + "
text-2D\n" + + "
text-1D\n" + + "
\n" + + ""; + + // Parse using SAXAdapter (same as XQTS runner) + final javax.xml.parsers.SAXParserFactory spf = javax.xml.parsers.SAXParserFactory.newInstance(); + spf.setNamespaceAware(true); + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParser saxParser = spf.newSAXParser(); + saxParser.getXMLReader().setContentHandler(adapter); + saxParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + saxParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + // Use BrokerPool + XQuery service directly (like the XQTS runner) + final org.exist.storage.BrokerPool pool = org.exist.storage.BrokerPool.getInstance(); + try (final org.exist.storage.DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xqueryService = pool.getXQueryService(); + + // Step 1: Delete all PIs with target "a-pi" + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + ctx.declareVariable("input-context", doc); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "declare variable $input-context external; " + + "delete nodes $input-context//processing-instruction('a-pi')"); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Step 2: Snapshot step (like ". " in the XQTS test) + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + ctx.declareVariable("input-context", doc); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, ". "); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Step 3: Verification query - just count + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "count(.//(north | near-south)/preceding-sibling::text())"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, doc); + final String countStr = result.itemAt(0).getStringValue(); + + // Also get the individual text values for debug + final org.exist.xquery.XQueryContext ctx2 = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled2 = xqueryService.compile(ctx2, + "for $t in .//(north | near-south)/preceding-sibling::text() " + + "return concat('[', $t, ']')"); + final org.exist.xquery.value.Sequence result2 = xqueryService.execute(broker, compiled2, doc); + final StringBuilder texts = new StringBuilder(); + for (int i = 0; i < result2.getItemCount(); i++) { + if (i > 0) texts.append(", "); + texts.append(result2.itemAt(i).getStringValue()); + } + + // Also check what .//(north | near-south) returns + final org.exist.xquery.XQueryContext ctx3 = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled3 = xqueryService.compile(ctx3, + "for $n in .//(north | near-south) return name($n)"); + final org.exist.xquery.value.Sequence result3 = xqueryService.execute(broker, compiled3, doc); + final StringBuilder names = new StringBuilder(); + for (int i = 0; i < result3.getItemCount(); i++) { + if (i > 0) names.append(", "); + names.append(result3.itemAt(i).getStringValue()); + } + + // W3C spec requires text node merging: after deleting PI between text-1B and text-1C, + // they merge into one. Same for text-4C and text-4D. So: north has 2 preceding text, + // near-south has 3 preceding text = 5 total. + assertEquals("count=" + countStr + ", texts=" + texts + ", targets=" + names, "5", countStr); + ctx.runCleanupTasks(); + ctx2.runCleanupTasks(); + ctx3.runCleanupTasks(); + } + } + } + + // === Delete + axis traversal tests (single-query, copy-modify) === + + @Test + public void deletePIPrecedingSiblingTextCount() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + // Simulate complex-deletes-q3: delete PIs, then count preceding-sibling text nodes + final String query = + "let $doc := ABCD " + + "return copy $c := $doc " + + "modify delete nodes $c//processing-instruction() " + + "return count($c/child/preceding-sibling::text())"; + + final String result = queryAndGetString(service, query); + // After deleting PI between B and C, B+C merge per W3C spec → 2 text nodes: A, BC + assertEquals("2", result); + } + + @Test + public void deleteElementChildTextCount() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + // Simulate complex-deletes-q10: delete element, count remaining text children + final String query = + "let $doc := A
BCD " + + "return copy $c := $doc " + + "modify delete nodes $c/target " + + "return count($c/text())"; + + final String result = queryAndGetString(service, query); + // After deleting , C+D merge per W3C spec → 3 text nodes: A, B, CD + assertEquals("3", result); + } + + @Test + public void deletePIDescendantAndPrecedingSibling() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + // Full complex-deletes-q3 pattern: delete PIs, then use //child/preceding-sibling::text() + final String query = + "let $doc := ABCD " + + "return copy $c := $doc " + + "modify delete nodes $c//processing-instruction('mypi') " + + "return count($c//child/preceding-sibling::text())"; + + final String result = queryAndGetString(service, query); + // After deleting PI between B and C, B+C merge per W3C spec → 2 text nodes: A, BC + assertEquals("2", result); + } + + @Test + public void deletePIComplexDeletesQ3Pattern() throws XMLDBException { + // Exact pattern from complex-deletes-q3 using copy-modify + final XQueryService service = testCollection.getService(XQueryService.class); + + // Uses the full TopMany.xml-like structure with mixed PIs, comments, text, elements + final String query = + "let $doc := text-1A\n" + + " text-1B\n" + + " text-1C\n" + + " text-2A\n" + + " text-3A\n" + + "
text-4A\n" + + " text-4B\n" + + " text-4C\n" + + " text-4D\n" + + " text-5A\n" + + " text-4E\n" + + "
text-3E\n" + + "
text-2D\n" + + "
text-1D\n" + + "
\n" + + "return copy $c := $doc " + + "modify delete nodes $c//processing-instruction('a-pi') " + + "return (\n" + + " let $a := $c//(north | near-south)/preceding-sibling::comment()\n" + + " return {$a},\n" + + " let $a := $c//(north | near-south)/preceding-sibling::text()\n" + + " return {$a}\n" + + ")"; + + final ResourceSet result = service.query(query); + assertEquals("Expected 2 result elements", 2L, result.getSize()); + + final String commentResult = result.getResource(0).getContent().toString(); + System.err.println("deletePI comments: " + commentResult); + // With //(north | near-south), both north AND near-south are found as descendants. + // north/preceding-sibling::comment() = (1 comment) + // near-south/preceding-sibling::comment() = (1 comment, after PI deletion) + // Wait: near-south is at center level. Its preceding siblings include: + // near-south-west, text-4B, (after PI deletion, text-4C+text-4D merged) + // So near-south has 1 preceding-sibling comment. + // Total = 2 comments. + assertTrue("Comment count should be 2, got: " + commentResult, + commentResult.contains("count=\"2\"")); + + final String textResult = result.getResource(1).getContent().toString(); + // After deleting PIs, adjacent text nodes merge per W3C spec: + // north: text-1A, (text-1B+text-1C merged) = 2 preceding text siblings + // near-south: text-4A, text-4B, (text-4C+text-4D merged) = 3 preceding text siblings + // Total = 5 + assertTrue("Text count should be 5, got: " + textResult, textResult.contains("count=\"5\"")); + } + + @Test + public void deleteAttributesSingleElement() throws XMLDBException { + // Simplest case: delete one attribute from one element + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $doc :=
" + + "return copy $c := $doc " + + "modify delete nodes $c/@y " + + "return count($c/@*)"; + + assertEquals("2", queryAndGetString(service, query)); + } + + @Test + public void deleteAttributesTwoElements() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + // Delete one attr from each of two elements + final String query = + "let $doc := " + + "return copy $c := $doc " + + "modify delete nodes ($c/a/@y, $c/b/@q) " + + "return (count($c/a/@*), count($c/b/@*))"; + + final ResourceSet result = testCollection.getService(XQueryService.class).query(query); + assertEquals("a should have 2 attrs", "2", result.getResource(0).getContent().toString()); + assertEquals("b should have 2 attrs", "2", result.getResource(1).getContent().toString()); + } + + @Test + public void deleteAttributesThreeElementsExplicit() throws XMLDBException { + final XQueryService service = testCollection.getService(XQueryService.class); + + final String query = + "let $doc := " + + " " + + " " + + " " + + " " + + "return copy $c := $doc " + + "modify delete nodes ($c/a/@a2, $c/b/@b2, $c/c/@c2) " + + "return (count($c/a/@*), count($c/b/@*), count($c/c/@*))"; + + final ResourceSet result = service.query(query); + assertEquals("a", "3", result.getResource(0).getContent().toString()); + assertEquals("b", "3", result.getResource(1).getContent().toString()); + assertEquals("c", "2", result.getResource(2).getContent().toString()); + } + + // === Insert before — multiple inserts at same target (regression for hang) === + + @Test(timeout = 10000) + public void insertMultipleGroupsBeforeSameTarget() throws XMLDBException { + final String docName = "insert-multi-before.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, + "E1P140"); + + // Two insert-before expressions targeting the same node — this should not hang + queryResource(service, docName, + "let $var := /employee " + + "return ( " + + " insert node (Part Time,26) before $var/empnum[1], " + + " insert node (Full Time,30) before $var/empnum[1] " + + ")", 0); + + // Verify the inserts happened + final ResourceSet result = service.queryResource(docName, "count(/employee/*)"); + assertEquals(1L, result.getSize()); + // 3 original + 4 inserted = 7 + assertEquals("7", result.getResource(0).getContent().toString()); + } + + @Test(timeout = 10000) + public void insertMultipleGroupsBeforeSameTargetInMemory() throws XMLDBException { + final String docName = "dummy.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, ""); + + // Test insert into in copy-modify + assertEquals("insert into", "2", service.query( + "copy $c := E1 " + + "modify insert node PT into $c " + + "return count($c/*)").getResource(0).getContent().toString()); + + // Test insert after in copy-modify + assertEquals("insert after", "4", service.query( + "copy $c := E1P140 " + + "modify insert node PT after $c/empnum[1] " + + "return count($c/*)").getResource(0).getContent().toString()); + + // Test insert before in copy-modify + assertEquals("insert before", "4", service.query( + "copy $c := E1P140 " + + "modify insert node Part Time before $c/empnum[1] " + + "return count($c/*)").getResource(0).getContent().toString()); + + // Test insert as first into in copy-modify + assertEquals("insert as first", "4", service.query( + "copy $c := E1P140 " + + "modify insert node PT as first into $c " + + "return count($c/*)").getResource(0).getContent().toString()); + + // Now test two inserts using comma expression + final String query = + "let $doc := E1P140 " + + "return copy $c := $doc " + + "modify ( " + + " insert node (Part Time,26) before $c/empnum[1], " + + " insert node (Full Time,30) before $c/empnum[1] " + + ") " + + "return count($c/*)"; + + final ResourceSet result = service.query(query); + assertEquals(1L, result.getSize()); + // 3 original + 4 inserted = 7 + assertEquals("7", result.getResource(0).getContent().toString()); + } + + // === XQTS-style in-memory insert-after test (mimics id-insert-expr-021) === + + @Test + public void inMemoryInsertAfterTwoElements() throws Exception { + // Parse document using SAXAdapter (same as XQTS runner) + final String xml = "123"; + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParser saxParser = javax.xml.parsers.SAXParserFactory.newDefaultInstance().newSAXParser(); + saxParser.getXMLReader().setContentHandler(adapter); + saxParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + saxParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + final org.exist.storage.BrokerPool pool = org.exist.storage.BrokerPool.getInstance(); + try (final org.exist.storage.DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xqueryService = pool.getXQueryService(); + + // Step 1: Insert two elements after + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "insert node (10,20) after ./root/a"); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Step 2: Query all children of root in order + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "string-join(for $e in ./root/* return concat(name($e), '=', string($e)), ',')"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, doc); + final String output = result.itemAt(0).getStringValue(); + // Expected order: a=1, x=10, y=20, b=2, c=3 + assertEquals("a=1,x=10,y=20,b=2,c=3", output); + } + } + } + + @Test + public void inMemoryInsertAttributeNamespacedElement() throws Exception { + // Test 094: insert attribute into element in default namespace + // Use real books3.xml content with comments, PIs, entities + final java.io.File books3 = new java.io.File( + System.getProperty("user.home") + "/workspace/exist-xqts-runner/work/qt4tests-master/upd/TestSources/books3.xml"); + final byte[] xml; + if (books3.exists()) { + xml = java.nio.file.Files.readAllBytes(books3.toPath()); + } else { + // Fallback simplified version + xml = ("\n" + + "\n" + + "\n" + + "\t\n" + + "\t \n" + + "\t \n" + + "\t Pride and Prejudice\n" + + "\t\n" + + "\n" + + "\n" + + " \n" + + "\n" + + "").getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParserFactory spf = javax.xml.parsers.SAXParserFactory.newInstance(); + spf.setNamespaceAware(true); + final javax.xml.parsers.SAXParser nsParser = spf.newSAXParser(); + nsParser.getXMLReader().setContentHandler(adapter); + nsParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + nsParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + // Check document-level children before update + int docChildren = 0; + org.w3c.dom.Node docChild = doc.getFirstChild(); + while (docChild != null) { + System.out.println("Before update - doc child " + docChildren + ": type=" + docChild.getNodeType() + + " name=" + docChild.getNodeName()); + docChildren++; + docChild = docChild.getNextSibling(); + } + System.out.println("Before update: " + docChildren + " document-level children"); + + final org.exist.storage.BrokerPool pool = org.exist.storage.BrokerPool.getInstance(); + try (final org.exist.storage.DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xqueryService = pool.getXQueryService(); + + // Insert ITEMS attribute into BOOKS (count should be 3) + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "declare namespace books='http://ns.example.com/books'; " + + "insert node attribute ITEMS { count(.//books:ITEM) } into .//books:BOOKS"); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Check document-level children after update + docChildren = 0; + docChild = doc.getFirstChild(); + while (docChild != null) { + System.out.println("After update - doc child " + docChildren + ": type=" + docChild.getNodeType() + + " name=" + docChild.getNodeName() + " value='" + (docChild.getNodeValue() != null ? docChild.getNodeValue().replace("\n", "\\n") : "null") + "'"); + docChildren++; + docChild = docChild.getNextSibling(); + } + System.out.println("After update: " + docChildren + " document-level children"); + + // First, run the verification query "." and get the result + final org.exist.xquery.value.Sequence verifyResult; + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, " ."); + verifyResult = xqueryService.execute(broker, compiled, doc); + System.out.println("Verify result count: " + verifyResult.getItemCount()); + System.out.println("Verify result type: " + verifyResult.itemAt(0).getType()); + } + + // Then serialize via $result external variable (same as XQTS runner) + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + ctx.declareVariable("result", verifyResult); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "declare variable $result external; " + + "let $local:default-serialization := " + + " " + + " " + + " " + + " " + + " " + + "return fn:serialize($result, $local:default-serialization)"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, null); + final String serialized = result.itemAt(0).getStringValue(); + System.out.println("Test 094 serialized length: " + serialized.length()); + System.out.println("Test 094 first 200: " + serialized.substring(0, Math.min(200, serialized.length()))); + System.out.println("Test 094 last 100: " + serialized.substring(Math.max(0, serialized.length() - 100))); + assertTrue("BOOKS element should have ITEMS attribute", + serialized.contains("ITEMS=\"6\"") || serialized.contains("ITEMS=\"1\"") || serialized.contains("ITEMS=\"3\"")); + // Check if fn:serialize adds a trailing newline for document nodes + System.out.println("Last char code: " + (int) serialized.charAt(serialized.length() - 1)); + System.out.println("Ends with newline: " + serialized.endsWith("\n")); + // Check that wrapping in ignorable-wrapper produces 1 child + final String wrapped = "" + serialized + ""; + final javax.xml.parsers.DocumentBuilderFactory dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + final javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder(); + final org.w3c.dom.Document wrappedDoc = db.parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(wrapped.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final int wrapperChildCount = wrappedDoc.getDocumentElement().getChildNodes().getLength(); + System.out.println("Wrapper child count: " + wrapperChildCount); + if (wrapperChildCount != 1) { + for (int i = 0; i < wrapperChildCount; i++) { + final org.w3c.dom.Node ch = wrappedDoc.getDocumentElement().getChildNodes().item(i); + System.out.println("Wrapper child " + i + ": type=" + ch.getNodeType() + + " name=" + ch.getNodeName() + + " value='" + (ch.getNodeValue() != null ? ch.getNodeValue().substring(0, Math.min(50, ch.getNodeValue().length())).replace("\n", "\\n") : "null") + "'"); + } + } + assertEquals("Wrapper should have exactly 1 child", 1, wrapperChildCount); + } + } + } + + @Test + public void inMemoryInsertIntoOrdering() throws Exception { + // Test 052: INSERT_INTO should go between INSERT_INTO_AS_FIRST and INSERT_INTO_AS_LAST + final org.exist.storage.BrokerPool pool = org.exist.storage.BrokerPool.getInstance(); + try (final org.exist.storage.DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xqueryService = pool.getXQueryService(); + + final String xml = ""; + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParser saxParser = javax.xml.parsers.SAXParserFactory.newDefaultInstance().newSAXParser(); + saxParser.getXMLReader().setContentHandler(adapter); + saxParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + saxParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + // Apply multiple inserts: as first, as last, and plain into + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "insert node as first into ./root," + + "insert node as last into ./root," + + "insert node into ./root"); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Check ordering + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "string-join(./root/*/name(), ',')"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, doc); + final String output = result.itemAt(0).getStringValue(); + System.out.println("Test 052 ordering: " + output); + // first must be first, last must be last, mid must be between them + assertTrue("first should be first", output.startsWith("first,")); + assertTrue("last should be last", output.endsWith(",last")); + assertFalse("mid should not come after last", output.indexOf("mid") > output.indexOf("last")); + } + } + } + + @Test + public void inMemoryInsertAfterDescendantAxis() throws Exception { + // Test that //element finds inserted nodes (descendant axis traversal) + final String xml = "7020"; + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParser saxParser = javax.xml.parsers.SAXParserFactory.newDefaultInstance().newSAXParser(); + saxParser.getXMLReader().setContentHandler(adapter); + saxParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + saxParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + final org.exist.storage.BrokerPool pool = org.exist.storage.BrokerPool.getInstance(); + try (final org.exist.storage.DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xqueryService = pool.getXQueryService(); + + // Insert two hours after hours[1] + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "insert node (15,25) after ./employee/hours[1]"); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Query //hours and check order + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "string-join(for $h in .//hours return string($h), ',')"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, doc); + final String output = result.itemAt(0).getStringValue(); + // Expected order: 70, 15, 25, 20 + assertEquals("70,15,25,20", output); + } + } + } + + @Test + public void inMemoryReplaceAttribute() throws Exception { + final String xml = "E1"; + final org.exist.dom.memtree.SAXAdapter adapter = new org.exist.dom.memtree.SAXAdapter(); + final javax.xml.parsers.SAXParser saxParser = javax.xml.parsers.SAXParserFactory.newDefaultInstance().newSAXParser(); + saxParser.getXMLReader().setContentHandler(adapter); + saxParser.getXMLReader().setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + saxParser.getXMLReader().parse(new org.xml.sax.InputSource( + new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + final org.exist.dom.memtree.DocumentImpl doc = adapter.getDocument(); + + final org.exist.storage.BrokerPool pool = org.exist.storage.BrokerPool.getInstance(); + try (final org.exist.storage.DBBroker broker = pool.getBroker()) { + final org.exist.xquery.XQuery xqueryService = pool.getXQueryService(); + + // Replace attribute name with name1 + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "replace node ./employee/@name with attribute name1 {\"new name\"}"); + xqueryService.execute(broker, compiled, doc); + ctx.runCleanupTasks(); + } + + // Verify: check the result + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "string(./employee/@name1)"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, doc); + assertEquals("new name", result.itemAt(0).getStringValue()); + } + + // Verify: old attribute is gone + { + final org.exist.xquery.XQueryContext ctx = new org.exist.xquery.XQueryContext(pool); + final org.exist.xquery.CompiledXQuery compiled = xqueryService.compile(ctx, + "count(./employee/@name)"); + final org.exist.xquery.value.Sequence result = xqueryService.execute(broker, compiled, doc); + assertEquals("0", result.itemAt(0).getStringValue()); + } + } + } + + /** + * Verify that constructed in-memory elements have no parent + * (explicitlyCreated=false makes getParentNode() return null), + * and that replace node correctly raises XUDY0009. + */ + @Test + public void replaceNodeParentlessElementXUDY0009() throws XMLDBException { + final XQueryService service = storeXMLStringAndGetQueryService("xudy0009.xml", ""); + final String query = + "let $var := " + + "return replace node $var with "; + try { + service.query(query); + fail("Expected XUDY0009 error for parentless element"); + } catch (final XMLDBException e) { + assertTrue("Expected XUDY0009 but got: " + e.getMessage(), + e.getMessage().contains("XUDY0009")); + } + } + + /** + * Verify XUTY0008 is raised when replace target is multiple nodes. + */ + @Test + public void replaceNodeMultipleTargetsXUTY0008() throws XMLDBException { + final XQueryService service = storeXMLStringAndGetQueryService("xuty0008.xml", ""); + final String query = + "let $doc := doc('/db/test/xuty0008.xml') " + + "return replace node $doc/root/child::* with "; + try { + service.query(query); + fail("Expected XUTY0008 error for multiple targets"); + } catch (final XMLDBException e) { + assertTrue("Expected XUTY0008 but got: " + e.getMessage(), + e.getMessage().contains("XUTY0008")); + } + } + + // === Compatibility tests: replaceNode + replaceElementContent interaction === + + @Test + public void replaceValueOfElementAndReplaceNodeChildPersistent() throws XMLDBException { + // Matches compatibility-027: replace value of node + replace node on child + final String docName = "compat027.xml"; + final XQueryService service = storeXMLStringAndGetQueryService(docName, + "E1P140"); + + // replace value of element replaces ALL children; replaceNode of child should be skipped + service.query( + "let $var := doc('/db/test/compat027.xml')/employee " + + "return ( " + + " replace value of node $var with 'on leave', " + + " replace node $var/empnum with on leave " + + ")"); + + final ResourceSet result = service.query("doc('/db/test/compat027.xml')/employee"); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertTrue("Expected text 'on leave' in employee, got: " + xml, xml.contains("on leave")); + // The element should have only text content (no child elements) after replaceElementContent + assertFalse("Expected no child after replaceElementContent, got: " + xml, xml.contains("E1"); + + service.query( + "let $var := doc('/db/test/compat029.xml')/employee " + + "return ( " + + " replace value of node $var with 'on leave', " + + " insert node into $var " + + ")"); + + final ResourceSet result = service.query("doc('/db/test/compat029.xml')/employee"); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertTrue("Expected text 'on leave' in employee, got: " + xml, xml.contains("on leave")); + // replaceElementContent should supersede the insert + assertFalse("Expected no comment after replaceElementContent, got: " + xml, xml.contains("")); + assertFalse("hours should not contain '40'", xml.contains("40")); + } + + @Test + public void applyUpdates013InMemoryInsertDeleteAttributeSameName() throws XMLDBException { + // applyUpdates-013: insert attribute name="Sylvia" and delete @name + final XQueryService service = testCollection.getService(XQueryService.class); + final String query = + "copy $data := \n" + + " E1\n" + + " P1\n" + + " 40\n" + + "\n" + + "modify (\n" + + " insert node attribute name {'Sylvia'} into $data,\n" + + " delete node $data/@name\n" + + ")\n" + + "return $data"; + final ResourceSet result = service.query(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("applyUpdates013_inMemory result: " + xml); + assertTrue("should have name='Sylvia'", xml.contains("name=\"Sylvia\"")); + assertFalse("should not have 'Jane Doe 1'", xml.contains("Jane Doe 1")); + } + + @Test + public void applyUpdates001PersistentInsertThenDelete() throws XMLDBException { + // applyUpdates-001: insert comment into hours, delete hours/text() + final XQueryService service = storeXMLStringAndGetQueryService("works-mod.xml", + "\n" + + " E1\n" + + " P1\n" + + " 40\n" + + ""); + + // Run the update: insert comment into hours AND delete hours/text() + service.query( + "let $var := doc('/db/test/works-mod.xml')/employee " + + "return (\n" + + " insert node comment { 'Testing' } into $var/hours,\n" + + " delete node $var/hours/text()\n" + + ")"); + + // Verify: hours should have comment but no text node + final ResourceSet result = service.query( + "doc('/db/test/works-mod.xml')/employee/hours"); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("applyUpdates001 result: " + xml); + assertTrue("hours should contain comment", xml.contains("")); + assertFalse("hours should not contain '40'", xml.contains("40")); + } + + @Test + public void transformExpr034CopyDocumentRename() throws XMLDBException { + // id-transform-expr-034: copy a document, rename its root element + final String query = + "let $doc := document { }\n" + + "return copy $var1 := $doc\n" + + " modify rename node $var1/works as \"workers\"\n" + + " return $var1"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertTrue("Root should be renamed to 'workers'", xml.contains("\n" + + "return copy $var1 := $var/@name\n" + + " modify replace value of node $var1 with \"Ursula Le Guin\"\n" + + " return { $var1 }"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertEquals("", xml); + } + + @Test + public void transformExprXUDY0014TargetOutsideCopy() throws XMLDBException { + // XUDY0014: update target must be created by the copy clause + final String query = + "let $outside := 1\n" + + "return copy $c := \n" + + " modify replace value of node $outside/a with \"2\"\n" + + " return $c"; + try { + existEmbeddedServer.executeQuery(query); + fail("Expected XUDY0014"); + } catch (final XMLDBException e) { + assertTrue("Should raise XUDY0014", e.getMessage().contains("XUDY0014")); + } + } + + @Test + public void commaExpr015TwoReplaceValuesSnapshotIsolation() throws XMLDBException { + // id-comma-expr-015: two replace value ops referencing each other's targets + // Tests W3C snapshot semantics: content expressions evaluated BEFORE updates applied + final String query = + "let $doc := \n" + + " \n" + + " 40\n" + + " \n" + + " \n" + + " 70\n" + + " 20\n" + + " \n" + + "\n" + + "return copy $c := $doc\n" + + "modify (\n" + + " let $var1 := $c/employee[1]\n" + + " let $var2 := $c/employee[2]\n" + + " return (\n" + + " replace value of node $var1/hours[1] with $var2/hours[1],\n" + + " replace value of node $var2/hours[2] with $var1/hours[1]\n" + + " )\n" + + ")\n" + + "return ($c/employee[1]/hours, $c/employee[2]/hours)"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(3L, result.getSize()); + // employee[1]/hours[1]: was 40, replaced with $var2/hours[1]=70 + assertEquals("70", result.getResource(0).getContent().toString().replaceAll("", "")); + // employee[2]/hours[1]: unchanged = 70 + assertEquals("70", result.getResource(1).getContent().toString().replaceAll("", "")); + // employee[2]/hours[2]: was 20, replaced with $var1/hours[1]=40 (original, snapshot) + assertEquals("40", result.getResource(2).getContent().toString().replaceAll("", "")); + } + + @Test + public void replaceNode029ReplaceTextNodes() throws XMLDBException { + // id-replace-expr-029: replace text nodes + final String query = + "copy $c := \n" + + " E1\n" + + " P1\n" + + " 40\n" + + "\n" + + "modify (\n" + + " replace node $c/empnum[1]/text() with \"E1000\",\n" + + " replace node $c/hours[1]/text() with 10\n" + + ")\n" + + "return $c"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertTrue("empnum should be E1000", xml.contains("E1000")); + assertTrue("hours should be 10", xml.contains("10")); + } + + @Test + public void deleteMultipleAttributesForLoop() throws XMLDBException { + // Delete attributes on multiple elements using for loop (workaround for //(@attr) bug) + final String query = + "let $doc := \n" + + " \n" + + " \n" + + "\n" + + "return copy $c := $doc\n" + + "modify (\n" + + " for $e in $c//* return delete nodes ($e/@y, $e/@z)\n" + + ")\n" + + "return $c"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + // After deleting @y and @z, only @x should remain + assertTrue("a should have only x", xml.contains(" }\n" + + "return copy $c := $doc\n" + + "modify delete nodes $c\n" + + "return $c"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("deleteDocumentNode: " + xml); + assertTrue("Document should be preserved", xml.contains("")); + } + + @Test + public void replaceValueOfElementWithMarkup() throws XMLDBException { + // complex-replacevalues-q14: replace value with string that looks like markup + final String query = + "copy $c := old\n" + + "modify replace value of node $c/target with \"value\"\n" + + "return $c/target"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("replaceValueMarkup: " + xml); + // The markup string should be escaped text, not parsed as XML + assertTrue("Should contain escaped markup", + xml.contains("<notANode>value</notANode>")); + } + + /** + * Test insert + delete on same parent element in a single PUL. + * Reproduces XQTS applyUpdates-001: insert comment into element then delete its text child. + * After PUL application, the element should contain only the comment (text deleted). + * Verifies that getFirstChildFor can find appended children when positional children are deleted. + */ + @Test + public void applyUpdates001InsertCommentDeleteText() throws XMLDBException { + final String query = + "copy $c := 40\n" + + "modify (\n" + + " insert node comment { 'Testing' } into $c/hours,\n" + + " delete node $c/hours/text()\n" + + ")\n" + + "return $c/hours"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("applyUpdates001: " + xml); + // Should contain the comment but NOT the text "40" + assertTrue("Should contain comment", xml.contains("")); + assertFalse("Should not contain original text '40'", xml.contains("40")); + } + + /** + * Test delete text + insert comment (reverse order) on same parent. + * Reproduces XQTS applyUpdates-002. + */ + @Test + public void applyUpdates002DeleteTextInsertComment() throws XMLDBException { + final String query = + "copy $c := 40\n" + + "modify (\n" + + " delete node $c/hours/text(),\n" + + " insert node comment { 'Testing' } into $c/hours\n" + + ")\n" + + "return $c/hours"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("applyUpdates002: " + xml); + assertTrue("Should contain comment", xml.contains("")); + assertFalse("Should not contain original text '40'", xml.contains("40")); + } + + /** + * Test rename on elements accessed via in-memory document navigation. + * Reproduces XQTS complex-renames-q4: rename one of multiple matching elements. + */ + @Test + public void renameInMemoryElementSingleFromMultiple() throws XMLDBException { + final String query = + "copy $c := \n" + + "modify rename node ($c//a)[1] as 'b'\n" + + "return \n" + + " {count($c//a)}\n" + + " {count($c//b)}\n" + + ""; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("renameInMemory: " + xml); + assertTrue("Should have 1 'a' element", xml.contains("1")); + assertTrue("Should have 1 'b' element", xml.contains("1")); + } + + /** + * Test replace value on in-memory elements via for loop. + * Reproduces XQTS complex-replacevalues-q8 pattern. + */ + @Test + public void replaceValueInMemoryElementsForLoop() throws XMLDBException { + final String query = + "copy $c := old1old2\n" + + "modify for $a in $c//item return replace value of node $a with 'new'\n" + + "return $c"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("replaceValueForLoop: " + xml); + assertFalse("Should not contain 'old1'", xml.contains("old1")); + assertFalse("Should not contain 'old2'", xml.contains("old2")); + assertTrue("Should contain 'new'", xml.contains("new")); + } + + /** + * Test delete document-level comments using >> (follows) operator. + * Reproduces XQTS complex-deletes-q2: delete trailing comments. + */ + @Test + public void deleteDocumentCommentsFollowsOperator() throws XMLDBException { + // Simulates the structure: document has root element, then comments after it + final String query = + "let $doc := \n" + + "return\n" + + "copy $c := $doc\n" + + "modify ()\n" + + "return $c"; + // Basic test: just make sure >> operator works + final String followsTest = + "let $doc := parse-xml('')\n" + + "return count($doc/root/*[. >> $doc/root/a])"; + final ResourceSet result = existEmbeddedServer.executeQuery(followsTest); + assertEquals(1L, result.getSize()); + assertEquals("1", result.getResource(0).getContent().toString()); + } + + /** + * Test replace value of element on persistent document via top-level PUL. + * Reproduces XQTS complex-replacevalues-q8 on stored documents. + */ + @Test + public void replaceValuePersistentForLoop() throws XMLDBException { + final XQueryService queryService = storeXMLStringAndGetQueryService( + "topMany.xml", + ""); + queryService.setProperty("base-uri", testCollection.getName()); + + // Update: replace value of all se elements + queryService.query( + "let $doc := doc('" + testCollection.getName() + "/topMany.xml')\n" + + "for $a in $doc//se\n" + + "return replace value of node $a with 'content'"); + + // Verify + final ResourceSet result = queryService.query( + "let $doc := doc('" + testCollection.getName() + "/topMany.xml')\n" + + "return {$doc//se}"); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("replaceValuePersistent: " + xml); + assertTrue("First se should have content", + xml.contains("content")); + assertTrue("Second se should have content", + xml.contains("content")); + } + + /** + * Test replace value on in-memory document via top-level PUL (not copy-modify). + * Simulates what the XQTS runner does: parse XML, apply top-level update, query result. + * This is a two-step query: first update, then verify in separate query. + */ + @Test + public void replaceValueTopLevelPULInMemoryDoc() throws XMLDBException { + // Store the XML in the database first, so we can do a two-step update+verify + final XQueryService queryService = storeXMLStringAndGetQueryService( + "inmem.xml", + ""); + queryService.setProperty("base-uri", testCollection.getName()); + + // Step 1: use copy-modify to simulate top-level PUL on in-memory doc + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $doc := parse-xml('')\n" + + "return\n" + + " copy $c := $doc\n" + + " modify for $a in $c//se return replace value of node $a with 'content'\n" + + " return {$c//se}"); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("replaceValueTopLevelPUL: " + xml); + assertTrue("First se should have content", + xml.contains("content")); + assertTrue("Second se should have content", + xml.contains("content")); + } + + /** + * Test rename on persistent document via top-level PUL. + * Reproduces XQTS complex-renames-q2 on stored documents. + */ + @Test + public void renamePersistentMultipleElements() throws XMLDBException { + final XQueryService queryService = storeXMLStringAndGetQueryService( + "topMany.xml", + ""); + queryService.setProperty("base-uri", testCollection.getName()); + + // Update: rename all se elements to 'renamed' + queryService.query( + "let $doc := doc('" + testCollection.getName() + "/topMany.xml')\n" + + "for $a in $doc//se\n" + + "return rename node $a as 'renamed'"); + + // Verify + final ResourceSet result = queryService.query( + "let $doc := doc('" + testCollection.getName() + "/topMany.xml')\n" + + "return \n" + + " {count($doc//se)}\n" + + " {count($doc//renamed)}\n" + + ""); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("renamePersistent: " + xml); + assertTrue("Should have 0 'se' elements", xml.contains("0")); + assertTrue("Should have 2 'renamed' elements", xml.contains("2")); + } + + /** + * XQTS update10keywords: XQuery Update keywords can be used as variable names. + */ + @Test + public void updateKeywordsAsVariableNames() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $ascending := 1 let $descending := 2 let $greatest := 3 " + + "let $least := 4 let $satisfies := 5 let $revalidation := 6 " + + "let $skip := 7 let $strict := 8 let $lax := 9 " + + "let $insert := 10 let $delete := 11 let $replace := 12 " + + "let $rename := 13 let $copy := 14 let $modify := 15 " + + "let $value := 16 let $into := 17 let $with := 18 " + + "let $after := 19 let $before := 20 let $first := 21 " + + "let $last := 22 let $nodes := 23 let $updating := 24 " + + "return $ascending + $descending"); + assertEquals(1L, result.getSize()); + assertEquals("3", result.getResource(0).getContent().toString()); + } + + /** + * XQTS propagateNamespaces01: namespace propagation in copy-modify insert. + */ + @Test + public void propagateNamespacesPreserveInherit() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + "declare copy-namespaces preserve, inherit;\n" + + "copy $data := \n" + + "modify insert node into $data\n" + + "return\n" + + " let $w := $data/w\n" + + " let $x := $w/x\n" + + " let $y := $x/y\n" + + " let $z := $y/z\n" + + " return \n" + + " {namespace-uri-for-prefix('a', $w), namespace-uri-for-prefix('b',$w)}\n" + + " {namespace-uri-for-prefix('a', $x), namespace-uri-for-prefix('b',$x)}\n" + + " {namespace-uri-for-prefix('a', $y), namespace-uri-for-prefix('b',$y)}\n" + + " {namespace-uri-for-prefix('a', $z), namespace-uri-for-prefix('b',$z)}\n" + + " "); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("propagateNamespaces: " + xml); + // With preserve+inherit, inserted children should inherit parent's namespaces + assertTrue("w should inherit a-one", xml.contains("a-one b-one")); + assertTrue("x should override a with a-two", xml.contains("a-two b-one")); + assertTrue("y should override b with b-two", xml.contains("a-two b-two")); + assertTrue("z should inherit from y", xml.contains("a-two b-two")); + } + + /** + * Simulate XQTS FullAxis complex-replacevalues-q8: replaceValue on multiple sibling + * empty elements, then verify with following-sibling axis and predicate filter. + */ + @Test + public void replaceValueEmptyElementsFollowingSiblingAxis() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $doc := parse-xml('" + + "
" + + " text-5A" + + " text-6A text-6B text-5B" + + " text-4E" + + " text-4G" + + " text-4H" + + "
')\n" + + "return\n" + + " copy $c := $doc\n" + + " modify for $a in $c//south-east return replace value of node $a with 'very south east'\n" + + " return (\n" + + " let $a := $c//near-south/following-sibling::node()\n" + + " return {$a},\n" + + " let $a := $c//south-east[. = 'very south east']\n" + + " return {$a}\n" + + " )"); + + assertEquals(2L, result.getSize()); + final String r1 = result.getResource(0).getContent().toString(); + final String r2 = result.getResource(1).getContent().toString(); + System.err.println("replaceValueFollowingSibling r1: " + r1); + System.err.println("replaceValueFollowingSibling r2: " + r2); + + // r2 should find both south-east elements with the replaced text + assertTrue("Should find south-east with replaced value", + r2.contains("very south east")); + assertTrue("Should find both south-east elements", + r2.contains("count=\"2\"")); + } + + /** + * Test that //(element | element) union expressions with descendant axis work correctly. + * Note: //(@attr) with parenthesized attribute expressions is a pre-existing eXist + * limitation where the // axis handling incorrectly overwrites the attribute axis. + * The non-parenthesized //@x form works correctly. See PR #6106 for the fix. + */ + @Test + public void parenthesizedAttributeUnionWithDescendant() throws XMLDBException { + // //@x (non-parenthesized) should work correctly + final ResourceSet result = existEmbeddedServer.executeQuery( + "let $doc := " + + "
" + + " " + + "\n" + + "return {count($doc//@x)}"); + assertEquals(1L, result.getSize()); + assertEquals("//@x should find 2", "2", result.getResource(0).getContent().toString()); + + // //(element-name | element-name) should find descendants + final ResourceSet result2 = existEmbeddedServer.executeQuery( + "let $doc := 123\n" + + "return {count($doc//(b | c))}"); + assertEquals(1L, result2.getSize()); + assertEquals("//(b | c) should find 3 elements", "3", + result2.getResource(0).getContent().toString()); + + // //(element-union) in nested structure should find all matching descendants + final ResourceSet result3 = existEmbeddedServer.executeQuery( + "let $doc := \n" + + " \n" + + " \n" + + " \n" + + "
\n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "return (\n" + + " {for $n in $doc//(north | near-south) return local-name($n)},\n" + + " {count($doc//(north | near-south)/preceding-sibling::comment())}\n" + + ")"); + assertEquals(2L, result3.getSize()); + assertTrue("Should find both elements", + result3.getResource(0).getContent().toString().contains("north") + && result3.getResource(0).getContent().toString().contains("near-south")); + assertEquals("Should find 2 preceding-sibling comments", + "2", result3.getResource(1).getContent().toString()); + } + + // ---- Mutual exclusion: legacy + XQUF in same module ---- + + @Test + public void mixedLegacyAndXQUFIsRejected() throws XMLDBException { + final XQueryService queryService = + existEmbeddedServer.getRoot().getService(XQueryService.class); + try { + queryService.query( + "let $doc := " + + "return (update insert into $doc, insert node into $doc)"); + fail("Should reject mixing legacy and XQUF syntax"); + } catch (final XMLDBException e) { + assertTrue("Should report syntax conflict", + e.getMessage().contains("legacy") || e.getMessage().contains("W3C")); + } + } + + @Test + public void mixedXQUFThenLegacyIsRejected() throws XMLDBException { + final XQueryService queryService = + existEmbeddedServer.getRoot().getService(XQueryService.class); + try { + queryService.query( + "let $doc := " + + "return (insert node into $doc, update insert into $doc)"); + fail("Should reject mixing XQUF and legacy syntax"); + } catch (final XMLDBException e) { + assertTrue("Should report syntax conflict", + e.getMessage().contains("legacy") || e.getMessage().contains("W3C")); + } + } + + @Test + public void pureXQUFIsAccepted() throws XMLDBException { + final XQueryService queryService = + existEmbeddedServer.getRoot().getService(XQueryService.class); + // Should not throw - pure XQUF is fine + queryService.query( + "copy $c := " + + "modify insert node into $c " + + "return $c"); + } + + @Test + public void pureLegacyIsAccepted() throws XMLDBException { + final XQueryService queryService = + storeXMLStringAndGetQueryService("test-legacy.xml", ""); + // Should not throw - pure legacy is fine + queryService.query("update insert into doc('/db/test/test-legacy.xml')/root"); + } + + // === Namespace propagation diagnostic tests === + + /** + * XQTS propagateNamespaces01: preserve, inherit. + * After inserting content into a copied element, namespace bindings + * from the parent should propagate to the inserted children. + */ + @Test + public void propagateNamespaces01PreserveInherit() throws XMLDBException { + final String query = + "declare copy-namespaces preserve, inherit; " + + "declare boundary-space preserve; " + + "copy $data := " + + "modify insert node into $data " + + "return let $w := $data/w let $x := $w/x let $y := $x/y let $z := $y/z " + + "return " + + " {namespace-uri-for-prefix('a', $w), namespace-uri-for-prefix('b',$w)} " + + " {namespace-uri-for-prefix('a', $x), namespace-uri-for-prefix('b',$x)} " + + " {namespace-uri-for-prefix('a', $y), namespace-uri-for-prefix('b',$y)} " + + " {namespace-uri-for-prefix('a', $z), namespace-uri-for-prefix('b',$z)} " + + ""; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("propagateNS01 (preserve,inherit): " + xml); + // Expected: a-one b-one a-two b-one a-two b-two a-two b-two + assertTrue("w should inherit a-one", xml.contains("a-one b-one")); + assertTrue("x should have a-two and inherit b-one", xml.contains("a-two b-one")); + assertTrue("y should have a-two b-two", xml.contains("a-two b-two")); + assertTrue("z should have a-two b-two", xml.contains("a-two b-two")); + } + + /** + * XQTS propagateNamespaces02: preserve, no-inherit. + * Inserted children should NOT inherit ns from insertion target, + * but internal ns scoping within inserted content should still work. + */ + @Test + public void propagateNamespaces02PreserveNoInherit() throws XMLDBException { + final String query = + "declare copy-namespaces preserve, no-inherit; " + + "declare boundary-space preserve; " + + "copy $data := " + + "modify insert node into $data " + + "return let $w := $data/w let $x := $w/x let $y := $x/y let $z := $y/z " + + "return " + + " {namespace-uri-for-prefix('a', $w), namespace-uri-for-prefix('b',$w)} " + + " {namespace-uri-for-prefix('a', $x), namespace-uri-for-prefix('b',$x)} " + + " {namespace-uri-for-prefix('a', $y), namespace-uri-for-prefix('b',$y)} " + + " {namespace-uri-for-prefix('a', $z), namespace-uri-for-prefix('b',$z)} " + + ""; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("propagateNS02 (preserve,no-inherit): " + xml); + // Expected: a-two a-two b-two a-two b-two + assertTrue("w should be empty (no-inherit blocks parent ns)", xml.contains("")); + assertTrue("x should have own a-two only", xml.contains("a-two")); + assertTrue("y should see a-two from x and own b-two", xml.contains("a-two b-two")); + assertTrue("z should see a-two b-two from ancestors", xml.contains("a-two b-two")); + } + + /** + * XQTS propagateNamespaces03: no-preserve, inherit. + * Copy strips unused ns from copied element; inserted content + * is also deep-copied with no-preserve stripping. + */ + @Test + public void propagateNamespaces03NoPreserveInherit() throws XMLDBException { + final String query = + "declare copy-namespaces no-preserve, inherit; " + + "declare boundary-space preserve; " + + "copy $data := " + + "modify insert node into $data " + + "return let $w := $data/w let $x := $w/x let $y := $x/y let $z := $y/z " + + "return " + + " {namespace-uri-for-prefix('a', $w), namespace-uri-for-prefix('b',$w)} " + + " {namespace-uri-for-prefix('a', $x), namespace-uri-for-prefix('b',$x)} " + + " {namespace-uri-for-prefix('a', $y), namespace-uri-for-prefix('b',$y)} " + + " {namespace-uri-for-prefix('a', $z), namespace-uri-for-prefix('b',$z)} " + + ""; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("propagateNS03 (no-preserve,inherit): " + xml); + // Expected: all empty — no-preserve strips unused ns, nothing to propagate + assertTrue("w should be empty", xml.contains("")); + assertTrue("x should be empty", xml.contains("") || xml.contains("")); + assertTrue("y should be empty", xml.contains("") || xml.contains("")); + assertTrue("z should be empty", xml.contains("") || xml.contains("")); + } + + /** + * XQTS propagateNamespaces04: no-preserve, no-inherit. + * Both modes strip — all elements should have no namespace bindings. + */ + @Test + public void propagateNamespaces04NoPreserveNoInherit() throws XMLDBException { + final String query = + "declare copy-namespaces no-preserve, no-inherit; " + + "declare boundary-space preserve; " + + "copy $data := " + + "modify insert node into $data " + + "return let $w := $data/w let $x := $w/x let $y := $x/y let $z := $y/z " + + "return " + + " {namespace-uri-for-prefix('a', $w), namespace-uri-for-prefix('b',$w)} " + + " {namespace-uri-for-prefix('a', $x), namespace-uri-for-prefix('b',$x)} " + + " {namespace-uri-for-prefix('a', $y), namespace-uri-for-prefix('b',$y)} " + + " {namespace-uri-for-prefix('a', $z), namespace-uri-for-prefix('b',$z)} " + + ""; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("propagateNS04 (no-preserve,no-inherit): " + xml); + // Expected: all empty + assertTrue("w should be empty", xml.contains("")); + assertTrue("x should be empty", xml.contains("") || xml.contains("")); + assertTrue("y should be empty", xml.contains("") || xml.contains("")); + assertTrue("z should be empty", xml.contains("") || xml.contains("")); + } + + /** + * XQTS attribute-errors-q14: Simultaneous attribute replacements + * where one replaces @name with @salary and another replaces @gender with @name. + */ + @Test + public void attributeReplaceSwap() throws XMLDBException { + final String query = + "copy $in := " + + " E1P1" + + " " + + "modify (" + + " replace node $in/@name with attribute {'salary'} {'10'}," + + " replace node $in/@gender with attribute {'name'} {'Blodwyn Jones'}" + + ") " + + "return $in"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + System.err.println("attrReplaceSwap: " + xml); + assertTrue("Should have salary attr", xml.contains("salary=\"10\"")); + assertTrue("Should have name attr with new value", xml.contains("name=\"Blodwyn Jones\"")); + assertFalse("Should NOT have gender attr", xml.contains("gender=")); + } + + // ======================== FullAxis tests ======================== + + /** + * Baseline: following axis from document element (no updates). + */ + @Test + public void followingAxisFromDocElementBaseline() throws XMLDBException { + final String query = + "let $doc := document { " + + " , , " + + " , " + + " , " + + "} " + + "return let $a := $doc/*/following::node() " + + "return {$a}"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + assertTrue("Should have count 2, got: " + xml, xml.contains("count=\"2\"")); + } + + /** + * Following axis from document element after deleting trailing comments. + * Based on XQTS upd-FullAxis/complex-deletes-q2. + */ + @Test + public void followingAxisAfterDeleteTrailingComments() throws XMLDBException { + final String query = + "let $doc := document { " + + " , , , " + + " , " + + " , , " + + "} " + + "return copy $d := $doc " + + "modify delete nodes $d/comment()[. >> $d/*] " + + "return let $a := $d/*/following::node() " + + "return {$a}"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + // After deleting trailing comments, only PI-2 should remain as following + assertTrue("Should have count 1, got: " + xml, xml.contains("count=\"1\"")); + assertTrue("Should contain the PI", xml.contains("pi-2")); + } + + /** + * Following axis from document element after replacing trailing comment values. + * Based on XQTS upd-FullAxis/complex-replacevalues-q2. + */ + @Test + public void followingAxisAfterReplaceTrailingCommentValues() throws XMLDBException { + final String query = + "let $doc := document { " + + " , , , " + + " , " + + " , , " + + "} " + + "return copy $d := $doc " + + "modify ( " + + " for $c in $d/comment()[. >> $d/*] " + + " return replace value of node $c with 'Replaced' " + + ") " + + "return let $a := $d/*/following::node() " + + "return {$a}"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + // Trailing comments replaced + PI = 3 following nodes + assertTrue("Should have count 3", xml.contains("count=\"3\"")); + assertTrue("Should contain replaced comment", xml.contains("Replaced")); + assertTrue("Should contain the PI", xml.contains("pi-2")); + } + + /** + * Preceding axis after replacing text nodes with empty strings. + * Based on XQTS upd-FullAxis/complex-replacevalues-q7. + * Verifies that soft-deleted nodes from mergeAdjacentTextNodes don't crash selectPreceding. + */ + @Test + public void precedingAxisAfterReplaceTextWithEmpty() throws XMLDBException { + final String query = + "let $doc := document { " + + " " + + " text-a" + + " " + + " text-after-comment" + + " " + + " text-c" + + " " + + " text-after-pi" + + " " + + " " + + " " + + "} " + + "return copy $d := $doc " + + "modify ( " + + " for $t in $d//text()[preceding-sibling::node()[1]/(self::comment() | self::processing-instruction())] " + + " return replace value of node $t with '' " + + ") " + + "return let $a := $d//target/preceding::text() " + + "return {for $t in $a return {$t}}"; + final ResourceSet result = existEmbeddedServer.executeQuery(query); + assertEquals(1L, result.getSize()); + final String xml = result.getResource(0).getContent().toString(); + // Should not crash with "node not found" + assertTrue("Should have text-a in preceding", xml.contains("text-a")); + assertTrue("Should have text-c in preceding", xml.contains("text-c")); + } + +} diff --git a/exist-core/src/test/java/org/exist/xquery/xquf/XQUFBenchmark.java b/exist-core/src/test/java/org/exist/xquery/xquf/XQUFBenchmark.java new file mode 100644 index 00000000000..dc5b042a3d9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/xquf/XQUFBenchmark.java @@ -0,0 +1,443 @@ +/* + * 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.xquf; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import static org.junit.Assert.assertTrue; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.CollectionManagementService; +import org.xmldb.api.modules.XMLResource; +import org.xmldb.api.modules.XQueryService; + +/** + * Performance benchmark for W3C XQuery Update Facility 3.0 operations. + * + *

Measures wall-clock time for W3C XQUF persistent update operations (insert, + * delete, replace node, replace value, rename), their legacy eXist-db equivalents + * (update insert, update delete, etc.), and in-memory copy-modify (transform) + * expressions at various data sizes.

+ * + *

This class intentionally does not end in {@code *Test} so that + * Surefire will not discover it during normal {@code mvn test} runs. + * Run explicitly with:

+ *
+ *   mvn test -pl exist-core -Dtest=XQUFBenchmark \
+ *       -Dexist.run.benchmarks=true -Ddependency-check.skip=true
+ * 
+ */ +public class XQUFBenchmark { + + @ClassRule + public static final ExistXmldbEmbeddedServer server = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String COLLECTION_NAME = "benchmark-xquf"; + private static final String COLLECTION_PATH = "/db/" + COLLECTION_NAME; + + private static final int WARMUP_ITERATIONS = 3; + private static final int MEASURE_ITERATIONS = 5; + private static final int[] DATA_SIZES = {10, 50, 200}; + + @BeforeClass + public static void assumeBenchmarks() { + Assume.assumeTrue("Benchmarks are disabled. Set -Dexist.run.benchmarks=true to enable.", + Boolean.getBoolean("exist.run.benchmarks")); + } + + @BeforeClass + public static void setUp() throws XMLDBException { + if (!Boolean.getBoolean("exist.run.benchmarks")) { + return; + } + + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + cms.createCollection(COLLECTION_NAME); + } + + @AfterClass + public static void tearDown() throws XMLDBException { + if (!Boolean.getBoolean("exist.run.benchmarks")) { + return; + } + + final CollectionManagementService cms = + server.getRoot().getService(CollectionManagementService.class); + cms.removeCollection(COLLECTION_NAME); + } + + // ---- Persistent update benchmarks ---- + + @Test + public void insertInto() throws XMLDBException { + System.out.println("\n=== insert node ... into (persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("insert-into", size, (queryService, docPath) -> { + // Reset document with N items + storeDocument(size); + + // Insert a new child into each item + final String update = String.format( + "for $item in doc('%s/bench.xml')//item " + + "return insert node into $item", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("insert-into benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void deleteNode() throws XMLDBException { + System.out.println("\n=== delete node (persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("delete-node", size, (queryService, docPath) -> { + // Reset document with N items, each having a child + storeDocument(size); + + // Delete the child from each item + final String update = String.format( + "for $v in doc('%s/bench.xml')//item/value " + + "return delete node $v", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("delete-node benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void replaceValue() throws XMLDBException { + System.out.println("\n=== replace value of node (persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("replace-value", size, (queryService, docPath) -> { + // Reset document + storeDocument(size); + + // Replace value of each item's @id attribute + final String update = String.format( + "for $item in doc('%s/bench.xml')//item " + + "return replace value of node $item/@id with concat('new-', $item/@id)", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("replace-value benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void renameNode() throws XMLDBException { + System.out.println("\n=== rename node (persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("rename-node", size, (queryService, docPath) -> { + // Reset document + storeDocument(size); + + // Rename each element to + final String update = String.format( + "for $v in doc('%s/bench.xml')//item/value " + + "return rename node $v as 'renamed'", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("rename-node benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void replaceNode() throws XMLDBException { + System.out.println("\n=== replace node (persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("replace-node", size, (queryService, docPath) -> { + // Reset document + storeDocument(size); + + // Replace each with a + final String update = String.format( + "for $v in doc('%s/bench.xml')//item/value " + + "return replace node $v with {string($v)}", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("replace-node benchmark should complete in positive time", avgMs >= 0); + } + } + + // ---- Legacy persistent update benchmarks (DEPRECATED syntax) ---- + + @Test + public void legacyInsertInto() throws XMLDBException { + System.out.println("\n=== update insert ... into (legacy persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("legacy-insert-into", size, (queryService, docPath) -> { + storeDocument(size); + final String update = String.format( + "for $item in doc('%s/bench.xml')//item " + + "return update insert into $item", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("legacy-insert-into benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void legacyDeleteNode() throws XMLDBException { + System.out.println("\n=== update delete (legacy persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("legacy-delete-node", size, (queryService, docPath) -> { + storeDocument(size); + final String update = String.format( + "for $v in doc('%s/bench.xml')//item/value " + + "return update delete $v", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("legacy-delete-node benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void legacyReplaceValue() throws XMLDBException { + System.out.println("\n=== update value (legacy persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("legacy-replace-value", size, (queryService, docPath) -> { + storeDocument(size); + final String update = String.format( + "for $item in doc('%s/bench.xml')//item " + + "return update value $item/@id with concat('new-', $item/@id)", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("legacy-replace-value benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void legacyRenameNode() throws XMLDBException { + System.out.println("\n=== update rename (legacy persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("legacy-rename-node", size, (queryService, docPath) -> { + storeDocument(size); + final String update = String.format( + "for $v in doc('%s/bench.xml')//item/value " + + "return update rename $v as 'renamed'", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("legacy-rename-node benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void legacyReplaceNode() throws XMLDBException { + System.out.println("\n=== update replace (legacy persistent) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final double avgMs = runPersistentBenchmark("legacy-replace-node", size, (queryService, docPath) -> { + storeDocument(size); + final String update = String.format( + "for $v in doc('%s/bench.xml')//item/value " + + "return update replace $v with {string($v)}", + COLLECTION_PATH); + queryService.query(update); + }); + assertTrue("legacy-replace-node benchmark should complete in positive time", avgMs >= 0); + } + } + + // ---- In-memory copy-modify benchmarks ---- + + @Test + public void copyModifySingle() throws XMLDBException { + System.out.println("\n=== copy-modify single node (in-memory) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final String query = String.format( + "let $doc := { for $i in 1 to %d return {$i} } " + + "return copy $c := $doc modify ( replace value of node $c//item[@id = '1']/value with 'modified' ) return $c//item[@id = '1']/value/string()", + size); + final double avgMs = runInMemoryBenchmark("copy-modify-single", size, query); + assertTrue("copy-modify-single benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void copyModifyMultiple() throws XMLDBException { + System.out.println("\n=== copy-modify multiple replaceValue (in-memory) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final String query = String.format( + "let $doc := { for $i in 1 to %d return {$i} } " + + "return copy $c := $doc modify ( " + + " for $v in $c//item/value return replace value of node $v with concat('m-', $v) " + + ") return count($c//item)", + size); + final double avgMs = runInMemoryBenchmark("copy-modify-multi", size, query); + assertTrue("copy-modify-multi benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void copyModifyInsertDelete() throws XMLDBException { + System.out.println("\n=== copy-modify insert + delete (in-memory) ==="); + printHeader(); + + for (final int size : DATA_SIZES) { + final String query = String.format( + "let $doc := { for $i in 1 to %d return {$i} } " + + "return copy $c := $doc modify ( " + + " insert node into $c, " + + " for $v in $c//item[@id = ('1','2','3')]/value return delete node $v " + + ") return count($c//item)", + size); + final double avgMs = runInMemoryBenchmark("copy-modify-ins-del", size, query); + assertTrue("copy-modify-ins-del benchmark should complete in positive time", avgMs >= 0); + } + } + + @Test + public void copyModifyDeepTree() throws XMLDBException { + System.out.println("\n=== copy-modify deep tree (in-memory) ==="); + printHeader(); + + // Nested structure: root > section > subsection > item (depth=4) + for (final int size : DATA_SIZES) { + final String query = String.format( + "let $doc := { " + + " for $s in 1 to %d " + + " return
{ " + + " for $ss in 1 to 3 " + + " return data " + + " }
" + + "}
" + + "return copy $c := $doc modify ( " + + " for $item in $c//item return replace value of node $item with 'updated' " + + ") return count($c//item[. = 'updated'])", + size); + final double avgMs = runInMemoryBenchmark("copy-modify-deep", size, query); + assertTrue("copy-modify-deep benchmark should complete in positive time", avgMs >= 0); + } + } + + // ---- Helpers ---- + + private void storeDocument(final int numItems) throws XMLDBException { + final Collection col = server.getRoot().getChildCollection(COLLECTION_NAME); + final StringBuilder sb = new StringBuilder("\n"); + for (int i = 1; i <= numItems; i++) { + sb.append(String.format(" val-%d\n", i, i)); + } + sb.append(""); + + final XMLResource res = col.createResource("bench.xml", XMLResource.class); + res.setContent(sb.toString()); + col.storeResource(res); + } + + @FunctionalInterface + interface PersistentOperation { + void execute(XQueryService queryService, String docPath) throws XMLDBException; + } + + private double runPersistentBenchmark(final String label, final int size, + final PersistentOperation operation) throws XMLDBException { + final XQueryService queryService = server.getRoot().getService(XQueryService.class); + + // warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + operation.execute(queryService, COLLECTION_PATH + "/bench.xml"); + } + + // measure + long totalNs = 0; + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + final long start = System.nanoTime(); + operation.execute(queryService, COLLECTION_PATH + "/bench.xml"); + final long elapsed = System.nanoTime() - start; + totalNs += elapsed; + } + + final double avgMs = (totalNs / (double) MEASURE_ITERATIONS) / 1_000_000.0; + System.out.printf(" %-24s size=%3d avg=%8.2f ms%n", label, size, avgMs); + return avgMs; + } + + private double runInMemoryBenchmark(final String label, final int size, + final String query) throws XMLDBException { + final XQueryService queryService = server.getRoot().getService(XQueryService.class); + + // warmup + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + queryService.query(query); + } + + // measure + long totalNs = 0; + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + final long start = System.nanoTime(); + final ResourceSet result = queryService.query(query); + if (result.getSize() != 1) { + throw new AssertionError(label + " (size=" + size + "): expected 1 result, got " + result.getSize()); + } + final long elapsed = System.nanoTime() - start; + totalNs += elapsed; + } + + final double avgMs = (totalNs / (double) MEASURE_ITERATIONS) / 1_000_000.0; + System.out.printf(" %-24s size=%3d avg=%8.2f ms%n", label, size, avgMs); + return avgMs; + } + + private static void printHeader() { + System.out.printf(" %-24s %8s %12s%n", "Operation", "Size", "Avg (ms)"); + System.out.println(" " + "-".repeat(50)); + } +} diff --git a/exist-core/src/test/java/xquery/xquery4/XQuery4Tests.java b/exist-core/src/test/java/xquery/xquery4/XQuery4Tests.java new file mode 100644 index 00000000000..df6e65ce8d5 --- /dev/null +++ b/exist-core/src/test/java/xquery/xquery4/XQuery4Tests.java @@ -0,0 +1,32 @@ +/* + * 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 xquery.xquery4; + +import org.exist.test.runner.XSuite; +import org.junit.runner.RunWith; + +@RunWith(XSuite.class) +@XSuite.XSuiteFiles({ + "src/test/xquery/xquery4", +}) +public class XQuery4Tests { +} diff --git a/exist-core/src/test/resources-filtered/conf.xml b/exist-core/src/test/resources-filtered/conf.xml index 9a76f8c79a5..68bf433090b 100644 --- a/exist-core/src/test/resources-filtered/conf.xml +++ b/exist-core/src/test/resources-filtered/conf.xml @@ -896,6 +896,8 @@ + + @@ -908,8 +910,12 @@ + + + + diff --git a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml index 15d68dea5fb..d7dc294c785 100644 --- a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml @@ -901,6 +901,8 @@ + + diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml index b9bc14f5b53..07a99ef29cd 100644 --- a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml @@ -920,6 +920,8 @@ + + diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml index 7f2354f9f40..9c5d2a5bf85 100644 --- a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml @@ -912,6 +912,8 @@ + + diff --git a/exist-core/src/test/resources/openapi-demo/controller.json b/exist-core/src/test/resources/openapi-demo/controller.json new file mode 100644 index 00000000000..2886cefc635 --- /dev/null +++ b/exist-core/src/test/resources/openapi-demo/controller.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "apis": [ + { + "spec": "modules/api.json", + "priority": "1" + } + ], + "routes": { + "/": { "redirect": "index.html" } + }, + "cors": { + "allow-origin": "*" + } +} diff --git a/exist-core/src/test/resources/openapi-demo/modules/api.json b/exist-core/src/test/resources/openapi-demo/modules/api.json new file mode 100644 index 00000000000..8ac4ec6e471 --- /dev/null +++ b/exist-core/src/test/resources/openapi-demo/modules/api.json @@ -0,0 +1,58 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "OpenAPI Demo", + "version": "1.0.0", + "description": "Minimal demo of eXist-db's built-in OpenAPI routing — no Roaster, no controller.xql" + }, + "paths": { + "/api/hello": { + "get": { + "summary": "Say hello", + "operationId": "hello:greet", + "responses": { + "200": { + "description": "Greeting message" + } + } + } + }, + "/api/hello/{name}": { + "get": { + "summary": "Say hello to someone", + "operationId": "hello:greet-name", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Personalized greeting" + } + } + } + }, + "/api/echo": { + "post": { + "summary": "Echo back the request body", + "operationId": "hello:echo", + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }, + "responses": { + "200": { + "description": "Echoed request" + } + } + } + } + } +} diff --git a/exist-core/src/test/resources/openapi-demo/modules/hello.xqm b/exist-core/src/test/resources/openapi-demo/modules/hello.xqm new file mode 100644 index 00000000000..3609c6d5808 --- /dev/null +++ b/exist-core/src/test/resources/openapi-demo/modules/hello.xqm @@ -0,0 +1,76 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Demo handler module for OpenAPI-native routing. + : + : No Roaster import. No controller.xql. Just a plain XQuery module + : with functions that accept a $request map and return data. + : + : The module namespace URI follows the convention: + : http://exist-db.org/apps/{prefix} + : where {prefix} matches the operationId prefix in api.json. + :) +module namespace hello = "http://exist-db.org/apps/hello"; + +declare namespace output = "http://www.w3.org/2010/xslt-xquery-serialization"; +declare option output:method "json"; +declare option output:media-type "application/json"; + +(:~ + : Simple greeting. + : GET /api/hello + :) +declare function hello:greet($request as map(*)) { + map { + "message": "Hello from eXist-db's built-in OpenAPI routing!", + "method": $request?method, + "path": $request?path, + "note": "No Roaster. No controller.xql. Just controller.json + api.json + this module." + } +}; + +(:~ + : Personalized greeting. + : GET /api/hello/{name} + :) +declare function hello:greet-name($request as map(*)) { + let $name := $request?parameters?name + return map { + "message": "Hello, " || $name || "!", + "parameters": $request?parameters + } +}; + +(:~ + : Echo the request. + : POST /api/echo + :) +declare function hello:echo($request as map(*)) { + map { + "echo": true(), + "method": $request?method, + "body": $request?body, + "parameters": $request?parameters + } +}; diff --git a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml index 76b81502392..941e0e04f7d 100644 --- a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml +++ b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml @@ -26,6 +26,7 @@ + diff --git a/exist-core/src/test/resources/standalone-webapp/WEB-INF/web.xml b/exist-core/src/test/resources/standalone-webapp/WEB-INF/web.xml index 4722b24716c..0538e0af0e9 100644 --- a/exist-core/src/test/resources/standalone-webapp/WEB-INF/web.xml +++ b/exist-core/src/test/resources/standalone-webapp/WEB-INF/web.xml @@ -25,9 +25,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database @@ -73,6 +73,23 @@ org.exist.http.servlets.XSLTServlet + + + NativeRestXqServlet + org.exist.http.restxq.NativeRestXqServlet + + scan-root + /db/restxq-test + + + + + NativeRestXqServlet + /restxq/* + + XQueryURLRewrite /* diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/book.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/book.xml new file mode 100644 index 00000000000..752c6d122f5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/book.xml @@ -0,0 +1,12 @@ + + + +You will enjoy this book. + + +It was a dark and stormy night. + + +And the all lived happily ever after. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/extract.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/extract.xml new file mode 100644 index 00000000000..9c5cf205748 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/extract.xml @@ -0,0 +1,9 @@ + + + +You will enjoy this book. + + +It was a dark and stormy night. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/lang.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/lang.xml new file mode 100644 index 00000000000..1ef15f14827 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/result/lang.xml @@ -0,0 +1,20 @@ + + The cat sat on the mat. + Jackdaws love my big sphinx of quartz. + Hi. + Yo.. + Hello. + Hello. + Bonjour. + Guten Tag. + G'day mate. + + Hi. + Yo.. + Hello. + Hello. + Bonjour. + Guten Tag. + G'day mate. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/book.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/book.xml new file mode 100644 index 00000000000..1859d2dc3cb --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/book.xml @@ -0,0 +1,11 @@ + + +]> + + +You will enjoy this book. + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/chap1.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/chap1.xml new file mode 100644 index 00000000000..3a7fd2e764f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/chap1.xml @@ -0,0 +1,6 @@ + +]> + +It was a dark and stormy night. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/chap2.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/chap2.xml new file mode 100644 index 00000000000..fbe09206cb9 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/chap2.xml @@ -0,0 +1,6 @@ + +]> + +And the all lived happily ever after. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/extract.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/extract.xml new file mode 100644 index 00000000000..9e590aa0768 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/extract.xml @@ -0,0 +1,7 @@ + +]> + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/lang-samples.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/lang-samples.xml new file mode 100644 index 00000000000..7b77b551d99 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/lang-samples.xml @@ -0,0 +1,20 @@ + +]> + + Hi. + Hello. + Bonjour. + + Hello. + + + Guten Tag. + + G'day mate. + + + Yo.. + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/lang.xml b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/lang.xml new file mode 100644 index 00000000000..ec8969ca015 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/EdUni/test/lang.xml @@ -0,0 +1,20 @@ + + The cat sat on the mat. + Jackdaws love my big sphinx of quartz. + + + + + + + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include1.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include1.xml new file mode 100644 index 00000000000..9b638afe422 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include1.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include2.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include2.xml new file mode 100644 index 00000000000..544701ec8f5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include2.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include3.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include3.xml new file mode 100644 index 00000000000..5b8ea508dc2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include3.xml @@ -0,0 +1,8 @@ + + +<?xml version='1.0' encoding='utf-8'?> +<foo xmlns:xi="http://www.w3.org/2001/XInclude"> + <xi:include href="include1.xml"/> +</foo> + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include4.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include4.xml new file mode 100644 index 00000000000..24160503709 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include4.xml @@ -0,0 +1,7 @@ + + + + Pieter Aaron + pieter.aaron@inter.net + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include5.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include5.xml new file mode 100644 index 00000000000..4e1920ae81c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include5.xml @@ -0,0 +1,13 @@ + + + + Pieter Aaron + pieter.aaron@inter.net + + Emeka Ndubuisi + endubuisi@spamtron.com + + Vasia Zhugenev + vxz@gog.ru + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include6.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include6.xml new file mode 100644 index 00000000000..143f3a4a376 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include6.xml @@ -0,0 +1,9 @@ + + + + + Pieter Aaron + pieter.aaron@inter.net + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include7.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include7.xml new file mode 100644 index 00000000000..a586aa670e1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/result/XInclude/include7.xml @@ -0,0 +1,4 @@ + + +Pieter Aaron + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include1.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include1.xml new file mode 100644 index 00000000000..38f6c0f86ed --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include1.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include2.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include2.xml new file mode 100644 index 00000000000..9099ca825b1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include2.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include3.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include3.xml new file mode 100644 index 00000000000..b515211f8f8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include3.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include4.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include4.xml new file mode 100644 index 00000000000..084d8311cfd --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include4.xml @@ -0,0 +1,3 @@ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include5.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include5.xml new file mode 100644 index 00000000000..c28c3f424cb --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include5.xml @@ -0,0 +1,3 @@ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include6.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include6.xml new file mode 100644 index 00000000000..23c9180e091 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include6.xml @@ -0,0 +1,3 @@ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include7.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include7.xml new file mode 100644 index 00000000000..8928f723bcf --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/ft-include7.xml @@ -0,0 +1,3 @@ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include1.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include1.xml new file mode 100644 index 00000000000..b35c9ec3f71 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include1.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include2.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include2.xml new file mode 100644 index 00000000000..621e82f879b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include2.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include3.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include3.xml new file mode 100644 index 00000000000..a765a0e76c3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include3.xml @@ -0,0 +1,8 @@ + + + + Pieter Aaron + pieter.aaron@inter.net + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include4.xml b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include4.xml new file mode 100644 index 00000000000..109acf6f757 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/FourThought/test/XInclude/docs/include4.xml @@ -0,0 +1,16 @@ + + + + Pieter Aaron + pieter.aaron@inter.net + + + Emeka Ndubuisi + endubuisi@spamtron.com + + + Vasia Zhugenev + vxz@gog.ru + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/EBCDIC.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/EBCDIC.xml new file mode 100755 index 00000000000..dc738636ba8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/EBCDIC.xml @@ -0,0 +1,7 @@ + + + <?xml version="1.0" encoding="Cp037"?> +<p>data</p> + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF16BigEndianWithByteOrderMark.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF16BigEndianWithByteOrderMark.xml new file mode 100755 index 00000000000..59499489fe2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF16BigEndianWithByteOrderMark.xml @@ -0,0 +1,2 @@ + +<t>0123456789</t> diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF16LittleEndianWithByteOrderMark.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF16LittleEndianWithByteOrderMark.xml new file mode 100755 index 00000000000..59499489fe2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF16LittleEndianWithByteOrderMark.xml @@ -0,0 +1,2 @@ + +<t>0123456789</t> diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF32BE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF32BE.xml new file mode 100755 index 00000000000..b80aa600020 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF32BE.xml @@ -0,0 +1,7 @@ + + + <?xml version="1.0"?> +<p>data</p> + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF32LE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF32LE.xml new file mode 100755 index 00000000000..b80aa600020 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF32LE.xml @@ -0,0 +1,7 @@ + + + <?xml version="1.0"?> +<p>data</p> + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF8WithByteOrderMark.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF8WithByteOrderMark.xml new file mode 100755 index 00000000000..59499489fe2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UTF8WithByteOrderMark.xml @@ -0,0 +1,2 @@ + +<t>0123456789</t> diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UnicodeBigUnmarked.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UnicodeBigUnmarked.xml new file mode 100755 index 00000000000..216557d4a06 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UnicodeBigUnmarked.xml @@ -0,0 +1,7 @@ + + + <?xml version="1.0"?> +<p>data</p> + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UnicodeLittleUnmarked.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UnicodeLittleUnmarked.xml new file mode 100755 index 00000000000..216557d4a06 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/UnicodeLittleUnmarked.xml @@ -0,0 +1,7 @@ + + + <?xml version="1.0"?> +<p>data</p> + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptascii.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptascii.xml new file mode 100755 index 00000000000..679b3037543 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptascii.xml @@ -0,0 +1,4 @@ + + +euc + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptenglish.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptenglish.xml new file mode 100755 index 00000000000..782c7974ebf --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptenglish.xml @@ -0,0 +1,4 @@ + + +data + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptfrench.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptfrench.xml new file mode 100755 index 00000000000..0c306df4fea --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptfrench.xml @@ -0,0 +1,4 @@ + + +donnees + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/accepthtml.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/accepthtml.xml new file mode 100755 index 00000000000..80bea8d4d4b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/accepthtml.xml @@ -0,0 +1,4 @@ + + +content]]> + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptjis.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptjis.xml new file mode 100755 index 00000000000..e5861d87a15 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptjis.xml @@ -0,0 +1,4 @@ + + +jis + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptplaintext.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptplaintext.xml new file mode 100755 index 00000000000..8d4458ba5e5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/acceptplaintext.xml @@ -0,0 +1,4 @@ + + +plain text + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/badelementschemedata.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/badelementschemedata.xml new file mode 100755 index 00000000000..ce0b25c1db4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/badelementschemedata.xml @@ -0,0 +1,6 @@ + + + This tests whether syntactically incorrect element scheme data + simply fails to identify a subresource. + success! + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1.xml new file mode 100755 index 00000000000..0c1f24b0ec0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1.xml @@ -0,0 +1,9 @@ + + +

120 Mz is adequate for an average home user.

+ +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1a.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1a.xml new file mode 100755 index 00000000000..8f9824a85ac --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1a.xml @@ -0,0 +1,9 @@ + + +

120 Mz is adequate for an average home user.

+ + The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization. + +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1b.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1b.xml new file mode 100755 index 00000000000..2633692ff8a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c1b.xml @@ -0,0 +1,5 @@ + + +

120 Mz is adequate for an average home user.

+ +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2.xml new file mode 100755 index 00000000000..5a152bef431 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2.xml @@ -0,0 +1,7 @@ + + +

This document has been accessed + 324387 times.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2a.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2a.xml new file mode 100755 index 00000000000..0a49598d640 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2a.xml @@ -0,0 +1,7 @@ + + +

This document has been accessed + times.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2b.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2b.xml new file mode 100755 index 00000000000..c50447188d7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2b.xml @@ -0,0 +1,7 @@ + + +

This document has been accessed + times.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2c.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2c.xml new file mode 100755 index 00000000000..c91b4e11daa --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2c.xml @@ -0,0 +1,7 @@ + + +

This document has been accessed + times.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2d.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2d.xml new file mode 100755 index 00000000000..d4ca1a7ee3b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c2d.xml @@ -0,0 +1,7 @@ + + +

This document has been accessed + times.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c3.xml new file mode 100755 index 00000000000..df1300077b2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c3.xml @@ -0,0 +1,10 @@ + + +

The following is the source of the "data.xml" resource:

+ <?xml version='1.0'?> +<data> + <item><![CDATA[Brooks & Shields]]></item> +</data> +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c5.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c5.xml new file mode 100755 index 00000000000..c2d7ae5e082 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/c5.xml @@ -0,0 +1,4 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/d1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/d1.xml new file mode 100755 index 00000000000..da7df4620f4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/d1.xml @@ -0,0 +1,9 @@ + + +

120 Mz is adequate for an average home user.

+ +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/disclaimer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/disclaimer.xml new file mode 100755 index 00000000000..0c1f24b0ec0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/disclaimer.xml @@ -0,0 +1,9 @@ + + +

120 Mz is adequate for an average home user.

+ +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/emptyfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/emptyfallback.xml new file mode 100755 index 00000000000..915fd7fd0f8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/emptyfallback.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/encodingheuristicstest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/encodingheuristicstest.xml new file mode 100755 index 00000000000..88230abd4df --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/encodingheuristicstest.xml @@ -0,0 +1,6 @@ + + +

This is working

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/fallbacktest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/fallbacktest.xml new file mode 100755 index 00000000000..3cbc35ceb48 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/fallbacktest.xml @@ -0,0 +1,4 @@ + + + some data + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/fallbacktest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/fallbacktest2.xml new file mode 100755 index 00000000000..608fd83e9d3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/fallbacktest2.xml @@ -0,0 +1,27 @@ + + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + text + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/goodiri.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/goodiri.xml new file mode 100755 index 00000000000..211343e8de7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/goodiri.xml @@ -0,0 +1 @@ +Correct! diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/ignoresfragmentid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/ignoresfragmentid.xml new file mode 100755 index 00000000000..dbb0ed83013 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/ignoresfragmentid.xml @@ -0,0 +1,8 @@ + + + Test that xpointer is used and fragment ID isn't + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/includedocumentwithintradocumentreferences.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/includedocumentwithintradocumentreferences.xml new file mode 100755 index 00000000000..a3470a2275b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/includedocumentwithintradocumentreferences.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/includefromsamedocumentwithbase.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/includefromsamedocumentwithbase.xml new file mode 100755 index 00000000000..7532e4cd8b5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/includefromsamedocumentwithbase.xml @@ -0,0 +1,6 @@ + + + Make sure base URIs are preserved when including from the same document. + This should be duplicated + This should be duplicated + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest1.xml new file mode 100755 index 00000000000..7d3f5959885 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest1.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ Zut Alors! +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest2.xml new file mode 100755 index 00000000000..2fea94a1903 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest2.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ Zut Alors! +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest3.xml new file mode 100755 index 00000000000..054b8db0137 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/langtest3.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ Zut Alors! +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/latin1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/latin1.xml new file mode 100755 index 00000000000..8a85cb079c1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/latin1.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/legalcircle.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/legalcircle.xml new file mode 100755 index 00000000000..704103ff687 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/legalcircle.xml @@ -0,0 +1,9 @@ + + +

This will be duplicated if we're successful

+ +

This will be duplicated if we're successful

+
+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/lineends.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/lineends.xml new file mode 100755 index 00000000000..04a8ae0e944 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/lineends.xml @@ -0,0 +1,4 @@ + +linefeed +CRLF +carriage return \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/marshtest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/marshtest.xml new file mode 100755 index 00000000000..8962a151bc2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/marshtest.xml @@ -0,0 +1,11 @@ +]> + + + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/marshtestwithxmlbase.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/marshtestwithxmlbase.xml new file mode 100755 index 00000000000..aec07127af6 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/marshtestwithxmlbase.xml @@ -0,0 +1,12 @@ + +]> + + + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest.xml new file mode 100755 index 00000000000..e7724b15b8c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest.xml @@ -0,0 +1,6 @@ + + + + some data + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest2.xml new file mode 100755 index 00000000000..e6bdf9ebcba --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + text + + + + + + + + + + +]]> + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest5.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest5.xml new file mode 100755 index 00000000000..505e443cb0c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest5.xml @@ -0,0 +1,3 @@ + + fallback text + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest6.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest6.xml new file mode 100755 index 00000000000..3b4da40e10d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktest6.xml @@ -0,0 +1,3 @@ + + test + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktestwithxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktestwithxpointer.xml new file mode 100755 index 00000000000..d6728adba53 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktestwithxpointer.xml @@ -0,0 +1,4 @@ + + + some data + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktestwithxpointer2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktestwithxpointer2.xml new file mode 100755 index 00000000000..666bb8924b0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktestwithxpointer2.xml @@ -0,0 +1,5 @@ + + + some datamore data + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktotexttest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktotexttest.xml new file mode 100755 index 00000000000..666bb8924b0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/metafallbacktotexttest.xml @@ -0,0 +1,5 @@ + + + some datamore data + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/namespacetest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/namespacetest.xml new file mode 100755 index 00000000000..bb8a906df1f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/namespacetest.xml @@ -0,0 +1,32 @@ + + + + + The above tag should have a mapped prefix + + + + Make sure the XLinks come across + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + text + + + + + + The above tag should have a mapped prefix + + + + Make sure the XLinks come across + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/onlyxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/onlyxpointer.xml new file mode 100755 index 00000000000..8b40df08665 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/onlyxpointer.xml @@ -0,0 +1,10 @@ + + + Test that a document can include a piece of itself using an xpointer attribute. + + This will be copied + + + This will be copied + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/paralleltest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/paralleltest.xml new file mode 100755 index 00000000000..3d1b7559f30 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/paralleltest.xml @@ -0,0 +1,5 @@ + + +

This is from file a.xml in directory a

+

This is from file B in directory b

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/recursewithinsamedocument.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/recursewithinsamedocument.xml new file mode 100755 index 00000000000..c46e5c5131d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/recursewithinsamedocument.xml @@ -0,0 +1,14 @@ + + + Test that a document can include pieces of itself while + recursively following xincludes + + This will be copied + + + This will be copied + + + This will be copied + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/resolvethruxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/resolvethruxpointer.xml new file mode 100755 index 00000000000..526e4ee4809 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/resolvethruxpointer.xml @@ -0,0 +1,8 @@ + + + +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+
\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/roottest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/roottest.xml new file mode 100755 index 00000000000..2fa2f186a00 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/roottest.xml @@ -0,0 +1,29 @@ + + + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + text + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/roottest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/roottest2.xml new file mode 100755 index 00000000000..0d81e507f0a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/roottest2.xml @@ -0,0 +1,29 @@ + + + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + text + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/simple.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/simple.xml new file mode 100755 index 00000000000..be2ce336509 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/simple.xml @@ -0,0 +1,25 @@ + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + text + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/test.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/test.xml new file mode 100755 index 00000000000..ef3311cff56 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/test.xml @@ -0,0 +1,25 @@ + + + +]> + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + text + + + + + + trjsagdkasgdhasdgashgdhsadgashdg + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/tobintop.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/tobintop.xml new file mode 100755 index 00000000000..88bbbaad2fa --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/tobintop.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/triple.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/triple.xml new file mode 100755 index 00000000000..202f285f9e5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/triple.xml @@ -0,0 +1,13 @@ + + +

120 Mz is adequate for an average home user.

+ +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/unrecognizedschemewithfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/unrecognizedschemewithfallback.xml new file mode 100755 index 00000000000..5b6fcf17dfe --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/unrecognizedschemewithfallback.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ Oops! +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/utf16.txt b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/utf16.txt new file mode 100755 index 00000000000..3b14256991c Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/utf16.txt differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/utf16.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/utf16.xml new file mode 100755 index 00000000000..802a7cd8b23 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/utf16.xml @@ -0,0 +1,4 @@ + + + some UTF-16 data + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest.xml new file mode 100755 index 00000000000..b1a92c28014 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest.xml @@ -0,0 +1,7 @@ + + + Test that xml:base attribute is used to resolve href attributes + + test data + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest2.xml new file mode 100755 index 00000000000..c879dbc7d61 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest2.xml @@ -0,0 +1,7 @@ + + + Test that xml:base attribute is used to resolve href attributes + + test data + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest3.xml new file mode 100755 index 00000000000..96bb3002fae --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xmlbasetest3.xml @@ -0,0 +1,6 @@ + + + Test that xml:base attribute in unincluded element + still applies in merged document + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xpointeroverridesfragmentid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xpointeroverridesfragmentid.xml new file mode 100755 index 00000000000..aa68b82fa33 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xpointeroverridesfragmentid.xml @@ -0,0 +1,5 @@ + + + Test that xpointer is used and fragment ID isn't + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrdandtumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrdandtumblertest.xml new file mode 100755 index 00000000000..152563b4a25 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrdandtumblertest.xml @@ -0,0 +1,6 @@ + + + + You should see a p element: + If you only see the q element the test worked + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrfallback.xml new file mode 100755 index 00000000000..04df4953bcc --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrfallback.xml @@ -0,0 +1,7 @@ + + + There should be a resource error resolving this due to + the illegal XPointer syntax which causes the fallback to + be used: + Fallback worked! + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridandtumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridandtumblertest.xml new file mode 100755 index 00000000000..7cb5ae0a4a1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridandtumblertest.xml @@ -0,0 +1,5 @@ + + + You should see a q element: + If you only see the q element the test worked + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridtest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridtest.xml new file mode 100755 index 00000000000..4616f9537f5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridtest.xml @@ -0,0 +1,5 @@ + + + You should see a p element: +

Test worked

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridtest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridtest2.xml new file mode 100755 index 00000000000..23187042491 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptridtest2.xml @@ -0,0 +1,7 @@ + + + You should see no other elements + besides the root because the ID does + not exist in the document being pointed to. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrtumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrtumblertest.xml new file mode 100755 index 00000000000..4616f9537f5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/result/xptrtumblertest.xml @@ -0,0 +1,5 @@ + + + You should see a p element: +

Test worked

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/EBCDIC.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/EBCDIC.xml new file mode 100755 index 00000000000..950b1812a07 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/EBCDIC.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UCS4BE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UCS4BE.xml new file mode 100755 index 00000000000..ee92de4a872 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UCS4BE.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UCS4LE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UCS4LE.xml new file mode 100755 index 00000000000..ced11d615ef --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UCS4LE.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF16BigEndianWithByteOrderMark.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF16BigEndianWithByteOrderMark.xml new file mode 100755 index 00000000000..1c9443d2071 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF16BigEndianWithByteOrderMark.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF16LittleEndianWithByteOrderMark.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF16LittleEndianWithByteOrderMark.xml new file mode 100755 index 00000000000..45d1b1757f6 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF16LittleEndianWithByteOrderMark.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF32BE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF32BE.xml new file mode 100755 index 00000000000..5550083726a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF32BE.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF32LE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF32LE.xml new file mode 100755 index 00000000000..9ca741bbf19 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF32LE.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF8WithByteOrderMark.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF8WithByteOrderMark.xml new file mode 100755 index 00000000000..71d99db855e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UTF8WithByteOrderMark.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UnicodeBigUnmarked.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UnicodeBigUnmarked.xml new file mode 100755 index 00000000000..7a5d28979ae --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UnicodeBigUnmarked.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UnicodeLittleUnmarked.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UnicodeLittleUnmarked.xml new file mode 100755 index 00000000000..2181ac2a042 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/UnicodeLittleUnmarked.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/a/a.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/a/a.xml new file mode 100755 index 00000000000..8aa78e94413 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/a/a.xml @@ -0,0 +1 @@ +

This is from file a.xml in directory a

\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptenglish.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptenglish.xml new file mode 100755 index 00000000000..9cab9b180f2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptenglish.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptfrench.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptfrench.xml new file mode 100755 index 00000000000..16b5207aef6 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptfrench.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/accepthtml.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/accepthtml.xml new file mode 100755 index 00000000000..17832c35d91 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/accepthtml.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptplaintext.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptplaintext.xml new file mode 100755 index 00000000000..11ea70d539f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/acceptplaintext.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/b/b.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/b/b.xml new file mode 100755 index 00000000000..b9e1579029d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/b/b.xml @@ -0,0 +1 @@ +

This is from file B in directory b

\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badaccept1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badaccept1.xml new file mode 100755 index 00000000000..c896181cd64 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badaccept1.xml @@ -0,0 +1,4 @@ + +

120 MHz is adequate for an average home user.

+ +
\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badaccept2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badaccept2.xml new file mode 100755 index 00000000000..2d980516b0a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badaccept2.xml @@ -0,0 +1,4 @@ + +

120 MHz is adequate for an average home user.

+ +
\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badelementschemedata.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badelementschemedata.xml new file mode 100755 index 00000000000..9559ded2347 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badelementschemedata.xml @@ -0,0 +1,6 @@ + + + This tests whether syntactically incorrect element scheme data + simply fails to identify a subresource. + success! + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badiri.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badiri.xml new file mode 100755 index 00000000000..1b6b03d828e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badiri.xml @@ -0,0 +1,3 @@ + + +Ooops! \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badiri2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badiri2.xml new file mode 100755 index 00000000000..14e279eab5a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badiri2.xml @@ -0,0 +1,3 @@ + + +Ooops! \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badparseattribute.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badparseattribute.xml new file mode 100755 index 00000000000..2401d147326 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badparseattribute.xml @@ -0,0 +1,6 @@ + + + This file is just for making sure my XIncluder can detect + bad values for parse attributes. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr.xml new file mode 100755 index 00000000000..cba36166a0b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr.xml @@ -0,0 +1,6 @@ + + + There should be a resource error resolving this due to + the illegal XPointer syntax + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr2.xml new file mode 100755 index 00000000000..0f8a25e58c2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr2.xml @@ -0,0 +1,7 @@ + + + There should be a resource error resolving this due to + the illegal XPointer syntax. Specifically, the space in + initial ID. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr3.xml new file mode 100755 index 00000000000..1fb3a673003 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr3.xml @@ -0,0 +1,6 @@ + + + There should be a syntax error resolving this due to + the illegal XPointer syntax + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr4.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr4.xml new file mode 100755 index 00000000000..e7f8689968c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/badxptr4.xml @@ -0,0 +1,6 @@ + + + There should be a syntax error resolving this due to + the illegal XPointer syntax + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/basedata/red.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/basedata/red.xml new file mode 100755 index 00000000000..78034ad4471 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/basedata/red.xml @@ -0,0 +1,4 @@ + + + test data + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/baseinclude.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/baseinclude.xml new file mode 100755 index 00000000000..675e3d96fb5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/baseinclude.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/bdisclaimer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/bdisclaimer.xml new file mode 100755 index 00000000000..acff9ead30e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/bdisclaimer.xml @@ -0,0 +1,8 @@ + + +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c1.xml new file mode 100755 index 00000000000..fdd200a6212 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c1.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c2.xml new file mode 100755 index 00000000000..571dbdc82af --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c2.xml @@ -0,0 +1,7 @@ + + +

This document has been accessed + times.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c3.xml new file mode 100755 index 00000000000..e041586aa3f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c3.xml @@ -0,0 +1,5 @@ + + +

The following is the source of the "data.xml" resource:

+ +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c5.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c5.xml new file mode 100755 index 00000000000..5533d06a6b0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/c5.xml @@ -0,0 +1,10 @@ + +
+ + + + Report error + + + +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle1.xml new file mode 100755 index 00000000000..6f89f1975c7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle1.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle2a.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle2a.xml new file mode 100755 index 00000000000..f0fecfdbce8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle2a.xml @@ -0,0 +1,6 @@ + + + This file is just for making sure my XIncluder can detect + circular references. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle2b.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle2b.xml new file mode 100755 index 00000000000..fd5a6b4fa90 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circle2b.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circleback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circleback.xml new file mode 100755 index 00000000000..e8403f8a7c7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circleback.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer1.xml new file mode 100755 index 00000000000..b0e21e6f1db --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer1.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer2.xml new file mode 100755 index 00000000000..b0aacefe6ef --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer2.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer3.xml new file mode 100755 index 00000000000..55bd60f845d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/circlepointer3.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/colonizedschemename.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/colonizedschemename.xml new file mode 100755 index 00000000000..6840029f43a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/colonizedschemename.xml @@ -0,0 +1,5 @@ + + + You should see a p element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/count.txt b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/count.txt new file mode 100755 index 00000000000..2317cfd92c7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/count.txt @@ -0,0 +1 @@ +324387 \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/d1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/d1.xml new file mode 100755 index 00000000000..d7d654cbbe2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/d1.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/data.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/data.xml new file mode 100755 index 00000000000..d54d452f1a0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/data.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/disclaimer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/disclaimer.xml new file mode 100755 index 00000000000..8e693db5bca --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/disclaimer.xml @@ -0,0 +1,8 @@ + + +

The opinions represented herein represent those of the individual + and should not be interpreted as official policy endorsed by this + organization.

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/docwithid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/docwithid.xml new file mode 100755 index 00000000000..385dcf4ae19 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/docwithid.xml @@ -0,0 +1,8 @@ + +]> + +

Test worked

+ No, it didn't the Test failed! +
+ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/docwithidandtumbler.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/docwithidandtumbler.xml new file mode 100755 index 00000000000..2be767b12e6 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/docwithidandtumbler.xml @@ -0,0 +1,14 @@ + +]> + +

+ +

+ If you only see the q element the test worked + bad! + + If you see me, the test failed +

+
+ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/emptyfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/emptyfallback.xml new file mode 100755 index 00000000000..7468265c69f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/emptyfallback.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/encodingheuristicstest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/encodingheuristicstest.xml new file mode 100755 index 00000000000..e4d6ee5d9a7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/encodingheuristicstest.xml @@ -0,0 +1,6 @@ + + +

+
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/extraattributes.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/extraattributes.xml new file mode 100755 index 00000000000..c535a4c52db --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/extraattributes.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackbadparseattribute.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackbadparseattribute.xml new file mode 100755 index 00000000000..bdaf868e61c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackbadparseattribute.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackbadxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackbadxpointer.xml new file mode 100755 index 00000000000..f84acec76ea --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackbadxpointer.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackcontainsfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackcontainsfallback.xml new file mode 100755 index 00000000000..5d2779e6c55 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackcontainsfallback.xml @@ -0,0 +1,11 @@ + + + + + some data + + some data + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackinternalxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackinternalxpointer.xml new file mode 100755 index 00000000000..319c138a50d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackinternalxpointer.xml @@ -0,0 +1,5 @@ + + + + test + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacknohreforparse.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacknohreforparse.xml new file mode 100755 index 00000000000..9263c618717 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacknohreforparse.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest.xml new file mode 100755 index 00000000000..04181e0f420 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest.xml @@ -0,0 +1,4 @@ + + + some data + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest2.xml new file mode 100755 index 00000000000..2bb118982f5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest2.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest3.xml new file mode 100755 index 00000000000..c814a189f78 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktest3.xml @@ -0,0 +1,4 @@ + + + some datamore data + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktotext.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktotext.xml new file mode 100755 index 00000000000..a4363562c05 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbacktotext.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackxpointerpointsnowhere.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackxpointerpointsnowhere.xml new file mode 100755 index 00000000000..c348824b56d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/fallbackxpointerpointsnowhere.xml @@ -0,0 +1,5 @@ + + + fallback text + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/french.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/french.xml new file mode 100755 index 00000000000..3b9e24de6a7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/french.xml @@ -0,0 +1 @@ +Zut Alors! \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/french2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/french2.xml new file mode 100755 index 00000000000..94f1905a4e1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/french2.xml @@ -0,0 +1,3 @@ + + Zut Alors! + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/goodiri.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/goodiri.xml new file mode 100755 index 00000000000..c9c1a99b564 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/goodiri.xml @@ -0,0 +1 @@ +Correct! \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ignoresfragmentid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ignoresfragmentid.xml new file mode 100755 index 00000000000..0247d5c842a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ignoresfragmentid.xml @@ -0,0 +1,5 @@ + + + Test that xpointer is used and fragment ID isn't + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/includedocumentwithintradocumentreferences.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/includedocumentwithintradocumentreferences.xml new file mode 100755 index 00000000000..0c8eaeb6009 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/includedocumentwithintradocumentreferences.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/includefromsamedocumentwithbase.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/includefromsamedocumentwithbase.xml new file mode 100755 index 00000000000..903dad01aa8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/includefromsamedocumentwithbase.xml @@ -0,0 +1,6 @@ + + + Make sure base URIs are preserved when including from the same document. + + This should be duplicated + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/internalcircular.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/internalcircular.xml new file mode 100755 index 00000000000..d9cb3a12dfa --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/internalcircular.xml @@ -0,0 +1,6 @@ + + + This file is just for making sure my XIncluder can detect + circular local links. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/internalcircularviaancestor.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/internalcircularviaancestor.xml new file mode 100755 index 00000000000..3fddd87ef35 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/internalcircularviaancestor.xml @@ -0,0 +1,6 @@ + + + This file is just for making sure my XIncluder can detect + circular local links. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest1.xml new file mode 100755 index 00000000000..898ca3d3f98 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest1.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest2.xml new file mode 100755 index 00000000000..eb8a3f8b4f2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest2.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest3.xml new file mode 100755 index 00000000000..02ee3e28b74 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/langtest3.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/laterfailure.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/laterfailure.xml new file mode 100755 index 00000000000..795e121880f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/laterfailure.xml @@ -0,0 +1,7 @@ + + + This tests whether a syntax error in the second + XPointer part will throw the exception as required. + Nothing should be included. Instead, there should be failure. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/laterfailure2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/laterfailure2.xml new file mode 100755 index 00000000000..ba3aff1d3d2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/laterfailure2.xml @@ -0,0 +1,9 @@ + + + This tests whether a syntax error in the second + XPointer part will throw the exception as required. + Specifically, I'm looking to see if a missing last parenthesis + causes the necessary exception. + Nothing should be included. Instead, there should be failure. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/latin1.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/latin1.xml new file mode 100755 index 00000000000..8a85cb079c1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/latin1.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/legalcircle.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/legalcircle.xml new file mode 100755 index 00000000000..c3a5b17a706 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/legalcircle.xml @@ -0,0 +1,7 @@ + + +

This will be duplicated if we're successful

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/lineends.txt b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/lineends.txt new file mode 100755 index 00000000000..ef0454b06fe --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/lineends.txt @@ -0,0 +1,3 @@ +linefeed +CRLF +carriage return \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/lineends.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/lineends.xml new file mode 100755 index 00000000000..9768f73f313 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/lineends.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtest.xml new file mode 100755 index 00000000000..8a3443aebdb --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtest.xml @@ -0,0 +1,10 @@ + +]> + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtestwithxmlbase.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtestwithxmlbase.xml new file mode 100755 index 00000000000..cc202e4d4ed --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtestwithxmlbase.xml @@ -0,0 +1,10 @@ + +]> + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtestwithxmlbaseandemptyhref.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtestwithxmlbaseandemptyhref.xml new file mode 100755 index 00000000000..31d8768c08c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/marshtestwithxmlbaseandemptyhref.xml @@ -0,0 +1,10 @@ + +]> + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/meaninglessfragmentid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/meaninglessfragmentid.xml new file mode 100755 index 00000000000..bda1342c4ed --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/meaninglessfragmentid.xml @@ -0,0 +1,5 @@ + + + Test that xpointer is used and fragment ID isn't + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest.xml new file mode 100755 index 00000000000..79a107ae009 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest2.xml new file mode 100755 index 00000000000..9ad0046aa36 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest2.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest3.xml new file mode 100755 index 00000000000..caf42da6505 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest3.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest4.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest4.xml new file mode 100755 index 00000000000..4cd88275738 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest4.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest5.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest5.xml new file mode 100755 index 00000000000..c176d07a281 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest5.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest6.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest6.xml new file mode 100755 index 00000000000..fb444506a0f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktest6.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithfragmentid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithfragmentid.xml new file mode 100755 index 00000000000..219510d6078 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithfragmentid.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithxpointer.xml new file mode 100755 index 00000000000..05b7536b6f8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithxpointer.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithxpointer2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithxpointer2.xml new file mode 100755 index 00000000000..57e31aa2dd5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktestwithxpointer2.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktotexttest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktotexttest.xml new file mode 100755 index 00000000000..35fc4021b3a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbacktotexttest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbackwithbadxpointertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbackwithbadxpointertest.xml new file mode 100755 index 00000000000..7fa0b9ad417 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metafallbackwithbadxpointertest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metamissingfallbacktestwithxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metamissingfallbacktestwithxpointer.xml new file mode 100755 index 00000000000..7b105689272 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/metamissingfallbacktestwithxpointer.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/missingfile.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/missingfile.xml new file mode 100755 index 00000000000..de75dbc6557 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/missingfile.xml @@ -0,0 +1,6 @@ + + + This file is just for making sure my XIncluder can detect + missing links. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/missinghref.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/missinghref.xml new file mode 100755 index 00000000000..700504fe089 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/missinghref.xml @@ -0,0 +1,6 @@ + + + This file is just for making sure my XIncluder can detect + missing href attributes. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/multiplefallbacks.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/multiplefallbacks.xml new file mode 100755 index 00000000000..c29f78e2536 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/multiplefallbacks.xml @@ -0,0 +1,10 @@ + + +

120 Mz is adequate for an average home user.

+ + First fallback + Second fallback + +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/multiplefallbacks2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/multiplefallbacks2.xml new file mode 100755 index 00000000000..bcbacf426d7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/multiplefallbacks2.xml @@ -0,0 +1,12 @@ + + +

120 Mz is adequate for an average home user.

+ + Multiple fallbacks are illegal even when the resource + exists and the fallbacks aren't processed. + First fallback + Second fallback + +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nakedfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nakedfallback.xml new file mode 100755 index 00000000000..39b36c2c911 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nakedfallback.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ It is illegal to have a fallback that is not a child of an include element. +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/namespaceinner.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/namespaceinner.xml new file mode 100755 index 00000000000..148f627eb21 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/namespaceinner.xml @@ -0,0 +1,10 @@ + + + + The above tag should have a mapped prefix + + + + Make sure the XLinks come across + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/namespacetest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/namespacetest.xml new file mode 100755 index 00000000000..e060007130d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/namespacetest.xml @@ -0,0 +1,21 @@ + + + + The above tag should have a mapped prefix + + + + Make sure the XLinks come across + + + + + + + + text + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nestedxinclude.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nestedxinclude.xml new file mode 100755 index 00000000000..996ae24b925 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nestedxinclude.xml @@ -0,0 +1,11 @@ + + +

120 Mz is adequate for an average home user.

+ + It is illegal for one xi:include element to + contain another, even when the resource is avaialable. + + +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nestedxincludenamespace.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nestedxincludenamespace.xml new file mode 100755 index 00000000000..f6ce8631257 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nestedxincludenamespace.xml @@ -0,0 +1,12 @@ + + +

120 Mz is adequate for an average home user.

+ + It is illegal for one xi:include element to + contain anything from the XInclude namespace + except a single fallback element. + + +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nofallbacktest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nofallbacktest.xml new file mode 100755 index 00000000000..88baedac66a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nofallbacktest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nolang.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nolang.xml new file mode 100755 index 00000000000..933e9a585fa --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/nolang.xml @@ -0,0 +1,3 @@ + + Zut Alors! + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/onedown.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/onedown.xml new file mode 100755 index 00000000000..23bf4c2decd --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/onedown.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/onlyxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/onlyxpointer.xml new file mode 100755 index 00000000000..8bf52a0cd1d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/onlyxpointer.xml @@ -0,0 +1,8 @@ + + + Test that a document can include a piece of itself using an xpointer attribute. + + + This will be copied + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/paralleltest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/paralleltest.xml new file mode 100755 index 00000000000..e76fae9ca46 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/paralleltest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/parseequalxml.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/parseequalxml.xml new file mode 100755 index 00000000000..6f102e46b2e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/parseequalxml.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/recursewithinsamedocument.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/recursewithinsamedocument.xml new file mode 100755 index 00000000000..09503b170a4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/recursewithinsamedocument.xml @@ -0,0 +1,10 @@ + + + Test that a document can include pieces of itself while + recursively following xincludes + + + + This will be copied + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/red.dtd b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/red.dtd new file mode 100755 index 00000000000..147fe93bde0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/red.dtd @@ -0,0 +1,4 @@ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/resolvethruxpointer.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/resolvethruxpointer.xml new file mode 100755 index 00000000000..e936553cf23 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/resolvethruxpointer.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/rootfailuretest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/rootfailuretest.xml new file mode 100755 index 00000000000..052b096cbab --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/rootfailuretest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/roottest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/roottest.xml new file mode 100755 index 00000000000..052b096cbab --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/roottest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/roottest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/roottest2.xml new file mode 100755 index 00000000000..51accef2567 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/roottest2.xml @@ -0,0 +1,4 @@ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/simple.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/simple.xml new file mode 100755 index 00000000000..526fa76ff61 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/simple.xml @@ -0,0 +1,18 @@ + + + + + + + + text + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/simple2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/simple2.xml new file mode 100755 index 00000000000..bf1599e65d4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/simple2.xml @@ -0,0 +1,21 @@ + + + + + + + + + text + + + + + + + + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test.xml new file mode 100755 index 00000000000..b354a9c82bc --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test.xml @@ -0,0 +1,20 @@ + + + +]> + + + + + + + text + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test0.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test0.xml new file mode 100755 index 00000000000..d66451698cf --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test0.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test2.xml new file mode 100755 index 00000000000..ff80106b373 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test2.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test3.xml new file mode 100755 index 00000000000..25bcb320d0c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/test3.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/EBCDIC.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/EBCDIC.xml new file mode 100755 index 00000000000..5a6c34a3594 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/EBCDIC.xml @@ -0,0 +1 @@ +Lo@~K@~×on%LnLan \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF32BE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF32BE.xml new file mode 100755 index 00000000000..2a55fdb3091 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF32BE.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF32LE.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF32LE.xml new file mode 100755 index 00000000000..cc5e0684e75 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF32LE.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF8.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF8.xml new file mode 100755 index 00000000000..3d702c4dde4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UTF8.xml @@ -0,0 +1,2 @@ + +

data

\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UnicodeBigUnmarked.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UnicodeBigUnmarked.xml new file mode 100755 index 00000000000..c9097429fd5 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UnicodeBigUnmarked.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UnicodeLittleUnmarked.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UnicodeLittleUnmarked.xml new file mode 100755 index 00000000000..bd86790f299 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/text/UnicodeLittleUnmarked.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobinbottom.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobinbottom.xml new file mode 100755 index 00000000000..b03f4660a56 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobinbottom.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobinmiddle.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobinmiddle.xml new file mode 100755 index 00000000000..e31e347044e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobinmiddle.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobintop.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobintop.xml new file mode 100755 index 00000000000..6426ec1fbba --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/tobintop.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/toplevel.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/toplevel.xml new file mode 100755 index 00000000000..6b83344bd5c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/toplevel.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ucs4bigendian.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ucs4bigendian.xml new file mode 100755 index 00000000000..7722f40c60f Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ucs4bigendian.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ucs4littleendian.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ucs4littleendian.xml new file mode 100755 index 00000000000..494ff8f6a89 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/ucs4littleendian.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/unrecognizedscheme.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/unrecognizedscheme.xml new file mode 100755 index 00000000000..b280e72b338 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/unrecognizedscheme.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/unrecognizedschemewithfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/unrecognizedschemewithfallback.xml new file mode 100755 index 00000000000..4d06573bfff --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/unrecognizedschemewithfallback.xml @@ -0,0 +1,9 @@ + + +

120 Mz is adequate for an average home user.

+ + Oops! + +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16.txt b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16.txt new file mode 100755 index 00000000000..3b14256991c Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16.txt differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16.xml new file mode 100755 index 00000000000..5324ef452ed --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16be.txt b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16be.txt new file mode 100755 index 00000000000..b938c63381d Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16be.txt differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16be.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16be.xml new file mode 100755 index 00000000000..270726ead17 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16be.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16le.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16le.xml new file mode 100755 index 00000000000..441da02910e Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf16le.xml differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf8.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf8.xml new file mode 100755 index 00000000000..4da7e0e0300 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf8.xml @@ -0,0 +1 @@ +trjsagdkasgdhasdgashgdhsadgashdg diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf8bom.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf8bom.xml new file mode 100755 index 00000000000..30a8f3314c4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/utf8bom.xml @@ -0,0 +1 @@ +0123456789 \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest.xml new file mode 100755 index 00000000000..802aece6882 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest.xml @@ -0,0 +1,5 @@ + + + Test that xml:base attribute is used to resolve href attributes + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest2.xml new file mode 100755 index 00000000000..e954b392f7c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest2.xml @@ -0,0 +1,5 @@ + + + Test that xml:base attribute is used to resolve href attributes + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest3.xml new file mode 100755 index 00000000000..d3816247be9 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xmlbasetest3.xml @@ -0,0 +1,6 @@ + + + Test that xml:base attribute in unincluded element + still applies in merged document + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointeroverridesfragmentid.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointeroverridesfragmentid.xml new file mode 100755 index 00000000000..271a122def3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointeroverridesfragmentid.xml @@ -0,0 +1,5 @@ + + + Test that xpointer is used and fragment ID isn't + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointerselectsnonelements.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointerselectsnonelements.xml new file mode 100755 index 00000000000..fdd200a6212 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointerselectsnonelements.xml @@ -0,0 +1,7 @@ + + +

120 Mz is adequate for an average home user.

+ +
+ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointerwithpercentescape.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointerwithpercentescape.xml new file mode 100755 index 00000000000..8580b7d1411 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xpointerwithpercentescape.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptr2tumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptr2tumblertest.xml new file mode 100755 index 00000000000..d4ade8de90a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptr2tumblertest.xml @@ -0,0 +1,5 @@ + + + You should see a p element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrdoubletumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrdoubletumblertest.xml new file mode 100755 index 00000000000..00e11435165 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrdoubletumblertest.xml @@ -0,0 +1,5 @@ + + + You should see a p element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrfallback.xml new file mode 100755 index 00000000000..6e33b431f74 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrfallback.xml @@ -0,0 +1,9 @@ + + + There should be a resource error resolving this due to + the illegal XPointer syntax which causes the fallback to + be used: + + Fallback worked! + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridandtumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridandtumblertest.xml new file mode 100755 index 00000000000..ae79360a87d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridandtumblertest.xml @@ -0,0 +1,5 @@ + + + You should see a q element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridtest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridtest.xml new file mode 100755 index 00000000000..c888048958d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridtest.xml @@ -0,0 +1,5 @@ + + + You should see a p element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridtest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridtest2.xml new file mode 100755 index 00000000000..861126a6182 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptridtest2.xml @@ -0,0 +1,7 @@ + + + You should see no other elements + besides the root because the ID does + not exist in the document being pointed to. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrsyntaxerrorbutfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrsyntaxerrorbutfallback.xml new file mode 100755 index 00000000000..97fe18d8857 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrsyntaxerrorbutfallback.xml @@ -0,0 +1,7 @@ + + + You should see a p element: + +

Test worked

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblerfailsbutfallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblerfailsbutfallback.xml new file mode 100755 index 00000000000..0d87a177175 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblerfailsbutfallback.xml @@ -0,0 +1,7 @@ + + + You should see a p element: + +

Test worked

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest.xml new file mode 100755 index 00000000000..48b0e45cc3c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest.xml @@ -0,0 +1,5 @@ + + + You should see a p element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest2.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest2.xml new file mode 100755 index 00000000000..373b2201d2c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest2.xml @@ -0,0 +1,7 @@ + + + You should see no other elements + besides the root because the ID does + not exist in the document being pointed to. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest3.xml b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest3.xml new file mode 100755 index 00000000000..fcf00625ee5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Harold/test/xptrtumblertest3.xml @@ -0,0 +1,6 @@ + + + You should see a p element: + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/docids.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/docids.xml new file mode 100644 index 00000000000..ad8e6e6bae2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/docids.xml @@ -0,0 +1,19 @@ + + + + + +]> + + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/fallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/fallback.xml new file mode 100644 index 00000000000..d7eed528738 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/fallback.xml @@ -0,0 +1,5 @@ + + + + Inclusion failed + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/include.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/include.xml new file mode 100644 index 00000000000..3a9366d5782 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/include.xml @@ -0,0 +1,9 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/nodes.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/nodes.xml new file mode 100644 index 00000000000..48030d1f48b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/nodes.xml @@ -0,0 +1,5 @@ + + + +

something

really

simple

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/pex6a.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/pex6a.xml new file mode 100644 index 00000000000..c0f2927104b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/pex6a.xml @@ -0,0 +1,5 @@ + + + + <t>0123456789</t> + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/pex6b.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/pex6b.xml new file mode 100644 index 00000000000..1433a32e9c5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/pex6b.xml @@ -0,0 +1,5 @@ + + + + <t>0123456789</t> + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/recursive.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/recursive.xml new file mode 100644 index 00000000000..4af17380653 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/recursive.xml @@ -0,0 +1,3 @@ + +is a test + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/txtinclude.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/txtinclude.xml new file mode 100644 index 00000000000..0114e4f2f8e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/result/XInclude/txtinclude.xml @@ -0,0 +1,6 @@ + + + + this is some text in ASCII + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/docids.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/docids.xml new file mode 100644 index 00000000000..7791620239a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/docids.xml @@ -0,0 +1,15 @@ + + + + +]> + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/fallback.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/fallback.xml new file mode 100644 index 00000000000..e80222e4fa9 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/fallback.xml @@ -0,0 +1,6 @@ + + + + Inclusion failed + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/include.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/include.xml new file mode 100644 index 00000000000..806ac23d665 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/include.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/nodes.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/nodes.xml new file mode 100644 index 00000000000..881c4104b1b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/nodes.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex1.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex1.xml new file mode 100644 index 00000000000..676d24cacd3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex1.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex11.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex11.xml new file mode 100644 index 00000000000..2ef31be8bb5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex11.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex1a.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex1a.xml new file mode 100644 index 00000000000..d3b6c55ec80 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex1a.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex6a.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex6a.xml new file mode 100644 index 00000000000..781ca3d8757 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex6a.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex6b.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex6b.xml new file mode 100644 index 00000000000..e1e90dec66f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/pex6b.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/recursive.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/recursive.xml new file mode 100644 index 00000000000..a9285acca2b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/recursive.xml @@ -0,0 +1,3 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/txtinclude.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/txtinclude.xml new file mode 100644 index 00000000000..f1af6a26bf5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/docs/txtinclude.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/ids.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/ids.xml new file mode 100644 index 00000000000..819323172f4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/ids.xml @@ -0,0 +1,10 @@ + + + +]> + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/inc.txt b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/inc.txt new file mode 100644 index 00000000000..d5cdd7ccb2f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/inc.txt @@ -0,0 +1 @@ +is a test diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/something.txt b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/something.txt new file mode 100644 index 00000000000..48c21b7affe --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/something.txt @@ -0,0 +1 @@ +this is some text in ASCII diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/something.xml b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/something.xml new file mode 100644 index 00000000000..9bba6834622 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/something.xml @@ -0,0 +1,5 @@ + +

something

+

really

+

simple

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/sub-inc.ent b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/sub-inc.ent new file mode 100644 index 00000000000..7726c9d82b4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/sub-inc.ent @@ -0,0 +1,2 @@ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/utf16lewithbom b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/utf16lewithbom new file mode 100644 index 00000000000..270726ead17 Binary files /dev/null and b/exist-core/src/test/resources/xinclude-test-suite/Imaq/test/XInclude/ents/utf16lewithbom differ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-01.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-01.xml new file mode 100644 index 00000000000..3324c13f26e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-01.xml @@ -0,0 +1,18 @@ + + + + +chapter 1. + first subtitle + Second subtitle + +chapter 2. + first subtitle + Second subtitle + +chapter 3. + first subtitle + Second subtitle + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-02.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-02.xml new file mode 100644 index 00000000000..d31004195bc --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-02.xml @@ -0,0 +1,9 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-04.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-04.xml new file mode 100644 index 00000000000..4cfa776224b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-04.xml @@ -0,0 +1,10 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-06.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-06.xml new file mode 100644 index 00000000000..3c96a0ed927 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-06.xml @@ -0,0 +1,11 @@ + + + + + +

with

+

something

+

else

+
+ +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-07.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-07.xml new file mode 100644 index 00000000000..eda8ca0f0e5 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-07.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-09.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-09.xml new file mode 100644 index 00000000000..2dd31652c8e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-09.xml @@ -0,0 +1,9 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-10.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-10.xml new file mode 100644 index 00000000000..3fa2fb7982c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-10.xml @@ -0,0 +1,10 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-13.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-13.xml new file mode 100644 index 00000000000..73fdfe0d5a2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-13.xml @@ -0,0 +1,5 @@ + + + + With something else. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-14.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-14.xml new file mode 100644 index 00000000000..f7285b0c14b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-14.xml @@ -0,0 +1,7 @@ + + + + + Add something else. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-16.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-16.xml new file mode 100644 index 00000000000..4f49a733ecb --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-16.xml @@ -0,0 +1,19 @@ + + + + +chapter 1. + first subtitle + Second subtitle + +chapter 2. + first subtitle + Second subtitle + +chapter 3. + first subtitle + Second subtitle + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-17.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-17.xml new file mode 100644 index 00000000000..183400e4999 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-17.xml @@ -0,0 +1,14 @@ + + + + +

something

+

really

+

simple

+
+ +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-18.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-18.xml new file mode 100644 index 00000000000..e47483bbde6 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-18.xml @@ -0,0 +1,14 @@ + + + + chapter 1. + first subtitle + Second subtitle +chapter 2. + first subtitle + Second subtitle +chapter 3. + first subtitle + Second subtitle + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-19.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-19.xml new file mode 100644 index 00000000000..96ec6d5f512 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-19.xml @@ -0,0 +1,8 @@ + + + + chapter 1. + first subtitle + Second subtitle + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-20.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-20.xml new file mode 100644 index 00000000000..befda009955 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-20.xml @@ -0,0 +1,22 @@ + + + + +chapter 1. + first subtitle + Second subtitle + +chapter 2. + first subtitle + Second subtitle + +chapter 3. + first subtitle + Second subtitle + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-21.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-21.xml new file mode 100644 index 00000000000..9f284b4f6e3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-21.xml @@ -0,0 +1,11 @@ + + + + + +]> + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-22.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-22.xml new file mode 100644 index 00000000000..da5d735faac --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-22.xml @@ -0,0 +1,19 @@ + + + + +chapter 1. + first subtitle + Second subtitle + +chapter 2. + first subtitle + Second subtitle + +chapter 3. + first subtitle + Second subtitle + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-26.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-26.xml new file mode 100644 index 00000000000..0ae32f29f1c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-26.xml @@ -0,0 +1,3 @@ + +testing relative uri reference. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-27.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-27.xml new file mode 100644 index 00000000000..d8e8d2e65fe --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-27.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-28.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-28.xml new file mode 100644 index 00000000000..f46a64fd634 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-28.xml @@ -0,0 +1,15 @@ + + + + + +]> + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-29.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-29.xml new file mode 100644 index 00000000000..aecd8c42ecc --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-29.xml @@ -0,0 +1,12 @@ + + + + + + +]> + + + Lopez + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-30.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-30.xml new file mode 100644 index 00000000000..557eb597920 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-30.xml @@ -0,0 +1,12 @@ + + + + + + +]> + + + Jackson + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-31.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-31.xml new file mode 100644 index 00000000000..ab76a873af2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-31.xml @@ -0,0 +1,8 @@ + + + +

The relevant excerpt is:

+ +

Sentence 1. Sentence 2.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-34.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-34.xml new file mode 100644 index 00000000000..4a4d26d248c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-34.xml @@ -0,0 +1,5 @@ + + + + Lopez + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-35.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-35.xml new file mode 100644 index 00000000000..48902dea2ec --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-35.xml @@ -0,0 +1,10 @@ + + + + + Lopez + Clark + Jackson + Medina + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-36.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-36.xml new file mode 100644 index 00000000000..09f1d5e695a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-36.xml @@ -0,0 +1,5 @@ + + + + Clark + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-37.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-37.xml new file mode 100644 index 00000000000..1cc999cd575 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-37.xml @@ -0,0 +1,6 @@ + + + + Clark + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-38.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-38.xml new file mode 100644 index 00000000000..1bf61a045fd --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-38.xml @@ -0,0 +1,5 @@ + + + + Clark + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-39.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-39.xml new file mode 100644 index 00000000000..d5008931135 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-39.xml @@ -0,0 +1,9 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-40.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-40.xml new file mode 100644 index 00000000000..e432cd6a0c1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-40.xml @@ -0,0 +1,9 @@ + + + + +

something

+

really

+

simple

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-49.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-49.xml new file mode 100644 index 00000000000..41cde1e5f20 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-49.xml @@ -0,0 +1,8 @@ + + +]> + + + &e; + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-50.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-50.xml new file mode 100644 index 00000000000..ffb970f6708 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-50.xml @@ -0,0 +1,12 @@ + + + + + Lopez + Clark + Jackson + Medina + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-51.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-51.xml new file mode 100644 index 00000000000..4dfb30c97c8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-51.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-52.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-52.xml new file mode 100644 index 00000000000..2547a03244f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-52.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-55.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-55.xml new file mode 100644 index 00000000000..443b1248795 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/result/nist-include-55.xml @@ -0,0 +1,17 @@ + + + + + + + + + + +]> + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-01.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-01.xml new file mode 100644 index 00000000000..1c3f203a987 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-01.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-02.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-02.xml new file mode 100644 index 00000000000..d23a4d3306c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-02.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-03.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-03.xml new file mode 100644 index 00000000000..215b1d49916 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-03.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-04.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-04.xml new file mode 100644 index 00000000000..e548b3cbac1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-04.xml @@ -0,0 +1,7 @@ + + + + some text in ascii + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-05.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-05.xml new file mode 100644 index 00000000000..a995bee1b03 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-05.xml @@ -0,0 +1,7 @@ + + + + I am part of the source infoset + some text in ascii. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-06.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-06.xml new file mode 100644 index 00000000000..347919f6ae6 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-06.xml @@ -0,0 +1,12 @@ + + + + + +

with

+

something

+

else

+
+
+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-07.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-07.xml new file mode 100644 index 00000000000..e21efca14d8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-07.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-08.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-08.xml new file mode 100644 index 00000000000..2b95cb95473 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-08.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-09.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-09.xml new file mode 100644 index 00000000000..0e92bfd1904 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-09.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-10.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-10.xml new file mode 100644 index 00000000000..3cf98608104 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-10.xml @@ -0,0 +1,7 @@ + + + + This content should be ignored + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-11.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-11.xml new file mode 100644 index 00000000000..17841aec5e3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-11.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-12.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-12.xml new file mode 100644 index 00000000000..0a31c64b26b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-12.xml @@ -0,0 +1,13 @@ + + + + + +

with

+

something

+

else

+
+
+ only one fallback is allowed +
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-13.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-13.xml new file mode 100644 index 00000000000..c34bc06ffc7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-13.xml @@ -0,0 +1,6 @@ + + + + With something else. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-14.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-14.xml new file mode 100644 index 00000000000..a0cbfc13d34 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-14.xml @@ -0,0 +1,8 @@ + + + + + Add something else. + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-15.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-15.xml new file mode 100644 index 00000000000..5a7047f7973 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-15.xml @@ -0,0 +1,2 @@ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-16.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-16.xml new file mode 100644 index 00000000000..c7731974f4a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-16.xml @@ -0,0 +1,5 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-17.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-17.xml new file mode 100644 index 00000000000..a02eae604ba --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-17.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-18.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-18.xml new file mode 100644 index 00000000000..39c2e369352 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-18.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-19.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-19.xml new file mode 100644 index 00000000000..fcf0e033541 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-19.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-20.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-20.xml new file mode 100644 index 00000000000..71e80e6805d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-20.xml @@ -0,0 +1,8 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-21.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-21.xml new file mode 100644 index 00000000000..f29e5ffcb4e --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-21.xml @@ -0,0 +1,12 @@ + + + + + + +]> + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-22.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-22.xml new file mode 100644 index 00000000000..ee7aa424415 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-22.xml @@ -0,0 +1,6 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-23.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-23.xml new file mode 100644 index 00000000000..51876060ca2 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-23.xml @@ -0,0 +1,5 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-24.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-24.xml new file mode 100644 index 00000000000..b17f4074761 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-24.xml @@ -0,0 +1,5 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-25.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-25.xml new file mode 100644 index 00000000000..a8b806f2f66 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-25.xml @@ -0,0 +1,3 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-26.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-26.xml new file mode 100644 index 00000000000..36c94fbc84a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-26.xml @@ -0,0 +1,3 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-27.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-27.xml new file mode 100644 index 00000000000..16c255513d4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-27.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-28.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-28.xml new file mode 100644 index 00000000000..f923ea3310c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-28.xml @@ -0,0 +1,15 @@ + + + + +]> + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-29.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-29.xml new file mode 100644 index 00000000000..de54fe8d67b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-29.xml @@ -0,0 +1,12 @@ + + + + + + +]> + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-30.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-30.xml new file mode 100644 index 00000000000..fc1683c8a09 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-30.xml @@ -0,0 +1,12 @@ + + + + + + +]> + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-31.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-31.xml new file mode 100644 index 00000000000..44aaa83c724 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-31.xml @@ -0,0 +1,13 @@ + + + +

The relevant excerpt is:

+ + + +
+ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-32.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-32.xml new file mode 100644 index 00000000000..fc261ddce0a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-32.xml @@ -0,0 +1,13 @@ + + + +

The relevant excerpt is:

+ + + +
+ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-33.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-33.xml new file mode 100644 index 00000000000..1a06c73fcfe --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-33.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-34.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-34.xml new file mode 100644 index 00000000000..0531532ec2b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-34.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-35.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-35.xml new file mode 100644 index 00000000000..b8df2e951eb --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-35.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-36.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-36.xml new file mode 100644 index 00000000000..15127f10af0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-36.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-37.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-37.xml new file mode 100644 index 00000000000..182b4fb5e5f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-37.xml @@ -0,0 +1,5 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-38.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-38.xml new file mode 100644 index 00000000000..4c25d3370df --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-38.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-39.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-39.xml new file mode 100644 index 00000000000..bbb7e304581 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-39.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-40.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-40.xml new file mode 100644 index 00000000000..c00b8ee632a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-40.xml @@ -0,0 +1,8 @@ + + + + + Resource not available. + This element must be ignored. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-41.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-41.xml new file mode 100644 index 00000000000..2e7c04c4bcb --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-41.xml @@ -0,0 +1,9 @@ + + + +

The report is available.

+ + + +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-42.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-42.xml new file mode 100644 index 00000000000..4364fa73548 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-42.xml @@ -0,0 +1,8 @@ + + + + + A resource is missing. + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-43.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-43.xml new file mode 100644 index 00000000000..2bcff562da1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-43.xml @@ -0,0 +1,9 @@ + + + + + + Resource not available. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-44.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-44.xml new file mode 100644 index 00000000000..6a6b72d76b9 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-44.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-45.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-45.xml new file mode 100644 index 00000000000..e1ef46e734a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-45.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-46.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-46.xml new file mode 100644 index 00000000000..2503bb94947 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-46.xml @@ -0,0 +1,9 @@ + + + + + Resource not available. + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-47.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-47.xml new file mode 100644 index 00000000000..ef3cfe0918a --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-47.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-48.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-48.xml new file mode 100644 index 00000000000..248524fe71b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-48.xml @@ -0,0 +1,9 @@ + + +]> + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-49.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-49.xml new file mode 100644 index 00000000000..299b4881638 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-49.xml @@ -0,0 +1,4 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-50.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-50.xml new file mode 100644 index 00000000000..961a53dcde8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-50.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-51.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-51.xml new file mode 100644 index 00000000000..1f4a0e27ab4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-51.xml @@ -0,0 +1,6 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-52.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-52.xml new file mode 100644 index 00000000000..163ffc67cae --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-52.xml @@ -0,0 +1,6 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-53.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-53.xml new file mode 100644 index 00000000000..6994b962185 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-53.xml @@ -0,0 +1,5 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-54.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-54.xml new file mode 100644 index 00000000000..b17f6907f58 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-54.xml @@ -0,0 +1,5 @@ + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-55.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-55.xml new file mode 100644 index 00000000000..07a49e61451 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-55.xml @@ -0,0 +1,20 @@ + + + + + + + + +]> + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-56.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-56.xml new file mode 100644 index 00000000000..aa76d5f1a71 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/docs/nist-include-56.xml @@ -0,0 +1,21 @@ + + + + + + + + +]> + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/004.ent b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/004.ent new file mode 100644 index 00000000000..0b7088ec633 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/004.ent @@ -0,0 +1 @@ +Data diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/books.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/books.xml new file mode 100644 index 00000000000..57263ae3ce8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/books.xml @@ -0,0 +1,19 @@ + + + +]> + +chapter 1. + first subtitle + Second subtitle + +chapter 2. + first subtitle + Second subtitle + +chapter 3. + first subtitle + Second subtitle + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/chapter.txt b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/chapter.txt new file mode 100644 index 00000000000..154c0ff7998 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/chapter.txt @@ -0,0 +1,2 @@ +Chapter1. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/extref.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/extref.xml new file mode 100644 index 00000000000..1fa3d02c4b7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/extref.xml @@ -0,0 +1,5 @@ + + +]> +&e; diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/getnodes.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/getnodes.xml new file mode 100644 index 00000000000..13be4a79978 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/getnodes.xml @@ -0,0 +1,21 @@ + + + +]> + +chapter 1. + + first subtitle + Second subtitle + +chapter 2. + + first subtitle + Second subtitle + +chapter 3. + first subtitle + Second subtitle + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/idtst.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/idtst.xml new file mode 100644 index 00000000000..580275718ae --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/idtst.xml @@ -0,0 +1,16 @@ + + + + + +]> + + Lopez + Clark + Jackson + Medina + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/include.txt b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/include.txt new file mode 100644 index 00000000000..66cdfe0cd77 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/include.txt @@ -0,0 +1 @@ +testing relative uri reference. diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/include1.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/include1.xml new file mode 100644 index 00000000000..b35c9ec3f71 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/include1.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/morebooks.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/morebooks.xml new file mode 100644 index 00000000000..f22cd9e09e4 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/morebooks.xml @@ -0,0 +1,18 @@ + + + +]> + +chapter 1. + first subtitle + Second subtitle + +chapter 2. + Second subtitle + +chapter 3. + Second subtitle + Third subtitle + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwf1.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwf1.xml new file mode 100644 index 00000000000..e98c2498bf0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwf1.xml @@ -0,0 +1,5 @@ +Wrong ordering between prolog and element! + + +]> diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwf2.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwf2.xml new file mode 100644 index 00000000000..3496fccc21c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwf2.xml @@ -0,0 +1,9 @@ + + + +]> +Wrong combination! + +Wrong combination! + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwfsomething.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwfsomething.xml new file mode 100644 index 00000000000..b86b6f3202b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/nwfsomething.xml @@ -0,0 +1,5 @@ +doc> +

something

+

really

+

simple

+ diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/part1.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/part1.xml new file mode 100644 index 00000000000..afb4d130d3f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/part1.xml @@ -0,0 +1,3 @@ +xmlns:xi="http://www.w3.org/2001/XInclude"> + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/prtids.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/prtids.xml new file mode 100644 index 00000000000..aac43a928e7 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/prtids.xml @@ -0,0 +1,12 @@ + + + + +]> + + + + +just an additional element. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/ptrtst.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/ptrtst.xml new file mode 100644 index 00000000000..752b27e837c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/ptrtst.xml @@ -0,0 +1,6 @@ + + +

Sentence 1. Sentence 2.

+

Sentence 3. Sentence 4. Sentence 5.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/reluriref.ent b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/reluriref.ent new file mode 100644 index 00000000000..9fdd320e29d --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/reluriref.ent @@ -0,0 +1,2 @@ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/something.txt b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/something.txt new file mode 100644 index 00000000000..48c21b7affe --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/something.txt @@ -0,0 +1 @@ +this is some text in ASCII diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/something.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/something.xml new file mode 100644 index 00000000000..9bba6834622 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/something.xml @@ -0,0 +1,5 @@ + +

something

+

really

+

simple

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/spec.dtd b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/spec.dtd new file mode 100644 index 00000000000..b9a7d8fe89f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/spec.dtd @@ -0,0 +1,975 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/unparent.xml b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/unparent.xml new file mode 100644 index 00000000000..2672b1e3b02 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/Nist/test/ents/unparent.xml @@ -0,0 +1,9 @@ + + + + + +]> + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/testcases.dtd b/exist-core/src/test/resources/xinclude-test-suite/testcases.dtd new file mode 100644 index 00000000000..9839d87722f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/testcases.dtd @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/testdescr.xml b/exist-core/src/test/resources/xinclude-test-suite/testdescr.xml new file mode 100644 index 00000000000..196662ca516 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/testdescr.xml @@ -0,0 +1,1460 @@ + + + + + + Daniel Veillard +
4.2
+ 8-Mar-2002 + Simple test of including another XML document. + ../../../result/XInclude/include.xml +
+ + + Daniel Veillard +
4.2.7
+ 8-Mar-2002 + Test recursive inclusion. + ../../../result/XInclude/recursive.xml +
+ + + Daniel Veillard +
4.2.2
+ 8-Mar-2002 + Simple test of including a set of nodes from an XML document. + ../../../result/XInclude/nodes.xml +
+ + + Daniel Veillard +
4.2
+ 8-Mar-2002 + including another XML document with IDs + ../../../result/XInclude/docids.xml +
+ + + Daniel Veillard +
4.3
+ 8-Mar-2002 + Simple test of including another text document + ../../../result/XInclude/txtinclude.xml +
+ + + Daniel Veillard +
4.4
+ 21-Aug-2002 + Simple test of a fallback on unavailable URI. + ../../../result/XInclude/fallback.xml +
+ + + Daniel Veillard +
3.2
+ 2006-09-27 + Unused fallbacks must not generate errors, even if broken. + ../../../result/XInclude/include.xml +
+ + Henry S. Thompson +
3.2
+ 2006-09-27 + Broken fallback must generate errors, if they're needed. +
+ + + Daniel Veillard +
4.3
+ 2006-09-27 + A BOM in UTF16 is not added to the resulting document. + ../../../result/XInclude/pex6a.xml +
+ + + Daniel Veillard +
4.3
+ 2006-09-27 + A BOM in UTF16LE is added to the resulting document. + ../../../result/XInclude/pex6b.xml +
+ + + Daniel Veillard +
3.1
+ 2006-09-27 + 'accept' attributes with characters outside the range #x20 through #x7E must + be flagged as fatal errors. +
+
+ + + John Evdemon +
3.1
+ 18-September-2002 + Simple test of including another XML document. + ../../../result/XInclude/include1.xml +
+ + + John Evdemon +
4.2.7
+ 18-September-2002 + Test recursive inclusion. + ../../../result/XInclude/include2.xml +
+ + + + John Evdemon +
3.1
+ 18-September-2002 + Simple test of including another text document. + ../../../result/XInclude/include3.xml +
+ + + John Evdemon +
4.2.2
+ 18-September-2002 + Simple test of including a set of nodes from an XML document. + ../../../result/XInclude/include4.xml +
+ + + John Evdemon +
4.2.2
+ 18-September-2002 + Simple test of including a set of nodes from an XML document. + ../../../result/XInclude/include5.xml +
+ + + John Evdemon +
4.2.1
+ 18-September-2002 + Simple test of including a set of nodes from an XML document. + ../../../result/XInclude/include6.xml +
+ + + John Evdemon +
4.2.5
+ 18-September-2002 + Simple test of including a set of nodes from an XML document. + ../../../result/XInclude/include7.xml +
+ +
+ + + + Sandra I. Martinez +
3.1
+ 18-September-2002 + Test the inclusion of another XML document. + ../../result/nist-include-01.xml +
+ + + Sandra I. Martinez +
3.1
+ September-2002 + Test that the encoding attribute in the Xinclude element has no effect when parse="xml". + ../../result/nist-include-02.xml +
+ + + Sandra I. Martinez +
3.1
+ September, 2002 + Test that values other than xml or text, in the parse attribute of the XInclude + element, result in fatal errors. +
+ + + Sandra I. Martinez +
3.2
+ September, 2002 + Test of fallback element appearing as a child of an xinclude element. + ../../result/nist-include-04.xml +
+ + + Sandra I. Martinez +
3.2
+ Septembe, 2002 + Test a fallback element not appearing as a direct + child of an xinclude element. A fatal error should be generated. +
+ + + Sandra I. Martinez +
3.2
+ September, 2002 + Test a fallback when a resource error occurs. + ../../result/nist-include-06.xml +
+ + + Sandra I. Martinez +
3.2
+ September, 2002 + Test an empty fallback element. The xinclude element is + removed from the results. + ../../result/nist-include-07.xml +
+ + + Sandra I. Martinez +
3.2
+ September, 2002 + Test of a fallback element missing from the + include element. A resource error results in a fatal error. + +
+ + Sandra I. Martinez +
3.1
+ September, 2002 + Test unqualified attributes in the include element. They must be ignored. + ../../result/nist-include-09.xml +
+ + + Sandra I. Martinez +
3.1
+ September, 2002 + Test content other than the fallback, in the xinclude element. + This content must be ignored. + ../../result/nist-include-10.xml +
+ + + + Sandra I. Martinez +
4.2
+ September, 2002 + Test a resource containing non-well-formed XML. The inclusion results in a fatal error. + +
+ + + Sandra I. Martinez +
4.4
+ September, 2002 + Test that is a fatal error for an include element to contain more than one fallback elements. + +
+ + + Sandra I. Martinez +
4.4
+ September, 2002 + Test a fallback element containing markup when parse="text". + ../../result/nist-include-13.xml +
+ + + Sandra I. Martinez +
4.4
+ September, 2002 + Test a fallback element containing markup when parse="text". + ../../result/nist-include-14.xml +
+ + + Sandra I. Martinez +
4.2.7
+ September, 2002 + It is illegal for an include element to point to itself, when parse="xml". + +
+ + + Sandra I. Martinez +
4.2.1
+ September, 2002 + Test a document type declaration information item child + in the resource information set. the DTD should be excluded for inclusion in the source infoset. + ../../result/nist-include-16.xml +
+ + + Sandra I. Martinez +
4.5
+ September, 2002 + Test intra-document reference within include elements. + ../../result/nist-include-17.xml +
+ + + Sandra I. Martinez +
4.2.2
+ September, 2002 + Simple test of including a set of nodes from an XML document. + ../../result/nist-include-18.xml +
+ + Sandra I. Martinez +
4.2.2
+ September, 2002 + Test the inclusion of a set of nodes from an XML document. + ../../result/nist-include-19.xml +
+ + + Sandra I. Martinez +
4.2.2
+ September, 2002 + Test an include location identifying a document information item with + an xpointer locating the document root. In this case + the set of top level include items is the children of acquired infoset's + document information item, except for the document type information + item. + ../../result/nist-include-20.xml +
+ + + Sandra I. Martinez +
4.5.1
+ January, 2003 + Including an XML document with an unparsed entity. + ../../result/nist-include-21.xml +
+ + + Sandra I. Martinez +
4.5
+ January, 2003 + Testing when the document (top level) element in the source infoset is an + include element. + ../../result/nist-include-22.xml +
+ + + Sandra I. Martinez +
4.5
+ January, 2003 + Testing an include element in the document (top-level) element in the source doc. + Test should fail because is including more than one element. + +
+ + + Sandra I. Martinez +
4.5
+ January, 2003 + Testing an include element in the document (top-level) element in the source doc. + Test should fail because is including only a processing instruction. +
+ + + Sandra I. Martinez +
4.5
+ January, 2003 + Testing an include element in the document (top-level) element in the source doc. + Test should fail because is including only a comment. + +
+ + + Sandra I. Martinez +
4.5.5
+ January, 2003 + Test relative URI references in the included infoset. + ../../result/nist-include-26.xml +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Test that the encoding attribute when parse="xml" does not translate the incoming document. + ../../result/nist-include-27.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + including another XML document with IDs, using a shorthand pointer. + ../../result/nist-include-28.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + including another XML document with IDs, using a shorthand pointer. + ../../result/nist-include-29.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including another XML document with IDs, using a shorthand pointer. + ../../result/nist-include-30.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including an XML document using an XPointer element scheme. + ../../result/nist-include-31.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including an XML document using an XPointer element scheme. + +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including an XML document using an XPointer element scheme. + +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including another XML document with ids using XPointer element scheme. + ../../result/nist-include-34.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including an XML document using an XPointer element scheme. + ../../result/nist-include-35.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including an XML document using an XPointer element scheme. + ../../result/nist-include-36.xml + +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including another XML document using XPointer Framework scheme-base pointer. + If the processor does not support the scheme used in a pointer part, it skip that pointer part. + ../../result/nist-include-37.xml +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Including another XML document using XPointer Framework. + If the processor does not support the scheme used in a pointer part, it skip that pointer part. + ../../result/nist-include-38.xml +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + The comment should be ignored . + ../../result/nist-include-39.xml +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + The element should be ignored . + ../../result/nist-include-40.xml +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + This test should result in a fatal error. + +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + The xinclude element may contain a fallback element; + other elements from the xinclude namespace result in a fatal error. + +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + The content must be one fallback. This test should result in a fatal error. + +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Test a resource that contains not-well-formed XML. + This test should result in a fatal error. + +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + Test a resource that contains not-well-formed XML. + This test should result in a fatal error. + +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + The xinclude element may contain a fallback element; other elements from the xinclude namespace result in a fatal error. + +
+ + + Sandra I. Martinez +
3.1
+ January, 2003 + Testing the content of the xinclude element. + The xinclude element may contain a fallback element; other elements from the xinclude namespace result in a fatal error. + +
+ + + Sandra I. Martinez +
4.2
+ January, 2003 + It is a fatal error to resolve an xpointer scheme on a document + that contains unexpanded entity reference information items. + +
+ + + Sandra I. Martinez +
4.5
+ January, 2003 + The unexpanded entity reference information items, if present in the source infoset, + will appear in the result infoset. + ../../result/nist-include-49.xml +
+ + + Sandra I. Martinez +
4.5
+ January, 2003 + Test an include location identifying the document information item without an Xpointer, + The set of top-level included items should be the children of the acquired inforset's document + information item, except for the document type declaration information item. + ../../result/nist-include-50.xml +
+ + + Sandra I. Martinez +
4.2.5
+ January, 2003 + Test an include location having an XPointer identifying a comment. + The set of top-level included items should consist of the information item corresponding + to the comment node in the acquired infoset. + ../../result/nist-include-51.xml +
+ + + Sandra I. Martinez +
4.2.5
+ January, 2003 + Test an include location having an XPointer identifying a processing instruction. + The set of top-level included items should consist of the information item corresponding + to the processing instruction node in the acquired infoset. + ../../result/nist-include-52.xml +
+ + + Sandra I. Martinez +
4.2.6
+ January, 2003 + Test that an include location identifying an attribute node will result in a + fatal error. +
+ + + Sandra I. Martinez +
4.2.6
+ January, 2003 + Test that an include location identifying an attribute node will result in a + fatal error. +
+ + + Sandra I. Martinez +
4.5.1
+ January, 2003 + Including a duplicate unparsed entity. + Test should ignore duplicate unparsed entity. + ../../result/nist-include-55.xml +
+ + + Sandra I. Martinez +
4.5.1
+ January, 2003 + Including an unparsed entity with same name, + but different sysid. Test should fail. +
+ + + +
+ + + + + Richard Tobin +
4.2
+ 30-Jun-2004 + Simple whole-file inclusion. + ../result/book.xml +
+ + + Richard Tobin +
4.2
+ 30-Jun-2004 + Verify that xi:include elements in the target have been processed in the acquired infoset, ie before the xpointer is applied. + ../result/extract.xml +
+ + + Richard Tobin +
4.2
+ 1-Sep-2004 + Check xml:lang fixup + ../result/lang.xml +
+ +
+ + + + + Elliotte Rusty Harold +
4.1
+ 31-Aug-2004 + xml:base attribute is used to resolve relative URLs in href attributes + ../result/xmlbasetest.xml +
+ + + Elliotte Rusty Harold +
4.2.5
+ 31-Aug-2004 + Use XPointer to include an include element in another document, + and make sure that's fully resolved too + ../result/resolvethruxpointer.xml +
+ + + Elliotte Rusty Harold +
4.1
+ 31-Aug-2004 + xml:base attribute on the xi:include element is used to resolve relative URL in href + ../result/xmlbasetest2.xml +
+ + + Elliotte Rusty Harold +
4.1
+ 31-Aug-2004 + xml:base attribute from an unincluded element + still applies to its included descendants + ../result/xmlbasetest3.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + An include element includes its following sibling element, which has a child + include element including the sibling element after that one. + ../result/marshtest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Include a document that uses XPointers to reference various parts of itself + ../result/includedocumentwithintradocumentreferences.xml +
+ + + Elliotte Rusty Harold +
4.5.6
+ 31-Aug-2004 + xml:lang attribute from including document does not override xml:lang attribute in included document + ../result/langtest1.xml +
+ + + Elliotte Rusty Harold +
4.5.6
+ 31-Aug-2004 + xml:lang attribute is added to retain the included element's language, even + though the language was originaly declared on an unincluded element + ../result/langtest2.xml +
+ + + Elliotte Rusty Harold +
4.5.6
+ 31-Aug-2004 + xml:lang='' is added when the included document does not declare a language + and the including element does + ../result/langtest3.xml +
+ + + + Elliotte Rusty Harold +
4.1
+ 31-Aug-2004 + Test that the + xml:base attribute is not used to resolve a missing href. + According to RFC 2396 empty string URI always refers to the current document irrespective of base URI. + ../result/marshtestwithxmlbase.xml +
+ + + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + There's no difference between href="" and no href attribute. + ../result/marshtestwithxmlbase.xml +
+ + + + Elliotte Rusty Harold +
4.5.5
+ + 31-Aug-2004 + Make sure base URIs are preserved when including from the same document. + ../result/includefromsamedocumentwithbase.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + Syntactically incorrect IRI is a fatal error (Eitehr I'm missing something or the spec needs to state this prinicple more clearly.) +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + Syntactically incorrect IRI with an unrecognized scheme is a fatal error +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 +Syntactically correct IRI with an unrecognized scheme is a resource error +../result/goodiri.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + accept attribute contains carriage-return/linefeed pair +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + accept attribute contains Latin-1 character (non-breaking space) +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + Unprefixed, unrecognized attributes on an include element are ignored + ../result/c1.xml +
+ + + Elliotte Rusty Harold +
3.2
+ 31-Aug-2004 + Fallback elements can be empty + ../result/emptyfallback.xml +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + Included documents can themselves use fallbacks + ../result/metafallbacktest.xml +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + An included document can use a fallback that points into the included document + ../result/metafallbacktest6.xml +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + An included document can use a fallback that includes another document as text + ../result/metafallbacktest2.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + A fallback element in an included document contains an include element with a parse attribute with an illegal value. +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + A fallback element in an included document contains an include element with neither an xpointer nor an href attribute. +
+ + + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + A fallback element in an included document contains an include element whose href attribute has a fragment ID. +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + The XPointer does not select anything in the acquired infoset, but does select something in the source infoset. +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + A fallback in an included document contains some text and a comment, but no elements. + ../result/metafallbacktotexttest.xml +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + Include element points to another include element, which has a missing resource + and therefore activates a fallback. + ../result/metafallbacktestwithxpointer.xml +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + An include element can include another include element that + then uses a fallback. + ../result/metafallbacktestwithxpointer2.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + An include element can include another include element that + then fails to find a resource, which is a fatal error if there's no fallback. +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + An include element can include another include element that + then fails to find a resource, but it has a fallback, which itself has an include child, which then throws a fatal error. +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Basic test from XInclude spec + ../result/c1.xml +
+ + + Elliotte Rusty Harold +
4.2.7
+ 31-Aug-2004 + An include element points to a document that includes it, using an xpointer to select part of that document. +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + An include element points to another include element in the same document. + ../result/recursewithinsamedocument.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Include elements can be siblings + ../result/paralleltest.xml +
+ + + Elliotte Rusty Harold +
4
+ 31-Aug-2004 + Namespaces (and lack thereof) must be preserved in included documents + ../result/namespacetest.xml +
+ + + Elliotte Rusty Harold +
4.2.7
+ 31-Aug-2004 + Detect an inclusion loop when an include element refers to itself +
+ + + Elliotte Rusty Harold +
4.2.7
+ 31-Aug-2004 + Detect an inclusion loop when an include element refers to its ancestor element + in the same document +
+ + + Elliotte Rusty Harold +
4
+ 31-Aug-2004 + Processing a document that contains no include elements produces the same document. + ../result/latin1.xml +
+ + + Elliotte Rusty Harold +
4
+ 31-Aug-2004 + Basic inclusion + ../result/simple.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + The root element of a document can be an include element. + ../result/roottest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + The root element of a document can be an include element. + In this test the included document has a prolog and an epilog and the root element is replaced + ../result/roottest2.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + testIncludeElementsCannotHaveIncludeChildren +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + Include elements cannot have children from the xinclude namespace except for fallback. +
+ + + Elliotte Rusty Harold +
3.2
+ 31-Aug-2004 + Fallback can only be a child of xinclude element +
+ + + Elliotte Rusty Harold +
3.2
+ + 31-Aug-2004 + A fallback element cannot have a fallback child element. +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + "The appearance of more than one xi:fallback element, an xi:include element, + or any other element from the XInclude namespace is a fatal error." + In this test the fallback is activated. +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + "The appearance of more than one xi:fallback element, an xi:include element, + or any other element from the XInclude namespace is a fatal error." + In this test the fallback is not activated. +
+ + + Elliotte Rusty Harold +
4.2.7
+ 31-Aug-2004 + A document cannot include itself +
+ + + Elliotte Rusty Harold +
4.2.7
+ 31-Aug-2004 + Document A includes document B which includes document A +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + Include element is missing an href and xpointer attribute +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + parse attribute must have value xml or text +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Missing resource is fatal when there's no fallback +
+ + + Elliotte Rusty Harold +
4.4
+ 31-Aug-2004 + Missing resource is non-fatal when there's a fallback + ../result/fallbacktest.xml +
+ + + Elliotte Rusty Harold +
3.2
+ 31-Aug-2004 + Fallback elements can themselves contain include elements + ../result/fallbacktest2.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + encoding="UTF-16" + ../result/utf16.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + A shorthand XPointer + ../result/xptridtest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + XPointer that selects nothing is a resource error, and fatal because there's no fallback. +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + XPointers of the forms described in [XPointer Framework] and [XPointer element() scheme] must be supported. + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Unrecognized colonized XPointer schemes are skipped, and the following scheme is used. + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Even if the first XPointer part locates a resource, a syntax error in + the second XPointer part is still a fatal error. +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Even if the first XPointer part locates a resource, a syntax error in + the second XPointer part is still a fatal error. +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + You can include another element from the same document without an href attribute. + ../result/onlyxpointer.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Test with 3 element schemes in the XPointer. + The first and second one point to nothing. The third one + selects something. XPointer parts are evaluated from left to right until one finds something. + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Test with 2 element schemes in the XPointer. + The first one uses an ID that doesn't exist + and points to nothing. The second one + selects something. + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Make sure XPointer syntax errors are treated as a resource + error, not a fatal error; and thus fallbacks are applied + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Make sure XPointer syntax errors are treated as a resource + error, not a fatal error; and thus fallbacks are applied + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Test with 3 element schemes in the XPointer, separated by white space. + The first one points to nothing. The third one + selects something. + ../result/xptrtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + An XPointer that doesn't point to anything is a resource error; and fatal because there's no fallback +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Syntax error in an XPointer is a resource error; and fatal because there's no fallback +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Syntax error in an XPointer is a resource error; and fatal because there's no fallback +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Unrecognized XPointer scheme activates fallback + ../result/xptrfallback.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + XPointer uses an element scheme where the first part is an ID + ../result/xptridandtumblertest.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Can autodetect UTF16 big endian files with a with a byte order mark when parse="text" + ../result/UTF16BigEndianWithByteOrderMark.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Can autodetect UTF16 little endian files with a with a byte order mark when parse="text" + ../result/UTF16LittleEndianWithByteOrderMark.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Can autodetect UTF-8 files with a with a byte order mark when parse="text" + ../result/UTF8WithByteOrderMark.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Can autodetect UCS2 big endian files with a without a byte order mark when parse="text" + ../result/UnicodeBigUnmarked.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Can autodetect UCS2 little endian files with a without a byte order mark when parse="text" + ../result/UnicodeLittleUnmarked.xml +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Can autodetect EBCDIC files with a without a byte order mark when parse="text" + ../result/EBCDIC.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Syntax error in an XPointer is a resource error; and fatal becaue there's no fallback +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Syntax error in an XPointer is a resource error; and fatal becaue there's no fallback +
+ + + Elliotte Rusty Harold +
4.2.7
+ 31-Aug-2004 + Circular references via xpointer are fatal +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + href attribute with fragment ID is a fatal error even when there's an xpointer attribute +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + href attribute with fragment ID is a fatal error +
+ + + Elliotte Rusty Harold +
4.3
+ 31-Aug-2004 + Line breaks must be preserved verbatim when including a document with parse="text" + ../result/lineends.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + A fragment identifier is semantically bad; but still meets the + syntax of fragment IDs from RFC 2396. +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + accept-language="fr" + This test connects to IBiblio to load the included +data. This is necessary because file URLs don't support +content negotiation + ../result/acceptfrench.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + accept-language="en" + This test connects to IBiblio to load the included +data. This is necessary because file URLs don't support +content negotiation + ../result/acceptenglish.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + accept="text/plain" + This test connects to IBiblio to load the included +data. This is necessary because file URLs don't support +content negotiation + ../result/acceptplaintext.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + accept="text/html" + This test connects to IBiblio to load the included +data. This is necessary because file URLs don't support +content negotiation + ../result/accepthtml.xml +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Unrecognized scheme in XPointer is a fatal error if there's no fallback +
+ + + Elliotte Rusty Harold +
4.2
+ 31-Aug-2004 + Unrecognized scheme in XPointer is a resource error so fallbacks apply + ../result/unrecognizedschemewithfallback.xml +
+ + + Elliotte Rusty Harold +
4.5
+ 31-Aug-2004 + Basic inclusions as XML and text + ../result/test.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 31-Aug-2004 + Included document has an include element with neither href nor xpointer attribute +
+ + + Elliotte Rusty Harold +
3.1
+ 12-Sep-2004 + XPointers are resolved against the acquired infoset, not thge source infoset + ../result/tobintop.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 12-Sep-2004 + Test that a non-child sequence in an xpointer is treated as a resource error. + ../result/badelementschemedata.xml +
+ + + Elliotte Rusty Harold +
3.1
+ 13-Oct-2004 + Since the xpointer attribute is not a URI reference, %-escaping must not appear in the XPointer, nor is there any need for a processor to apply or reverse such escaping. +
+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/code.pl b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/code.pl new file mode 100644 index 00000000000..5e0cc49a382 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/code.pl @@ -0,0 +1,42 @@ +#!/usr/bin/perl -- # --*-Perl-*-- + +use strict; +use English; +use Getopt::Std; +use vars qw($opt_p $opt_q $opt_u $opt_m); + +my $usage = "Usage: $0 [-q] [-u|-p|-m] file [ file ... ]\n"; + +die $usage if ! getopts('qupm'); + +die $usage if ($opt_p + $opt_u + $opt_m) != 1; + +my $file = shift @ARGV || die $usage; + +my $opt = '-u' if $opt_u; +$opt = '-p' if $opt_p; +$opt = '-m' if $opt_m; + +while ($file) { + print "Converting $file to $opt linebreaks.\n" if !$opt_q; + open (F, "$file"); + binmode F; + read (F, $_, -s $file); + close (F); + + s/\r\n/\n/sg; + s/\r/\n/sg; + + if ($opt eq '-p') { + s/\n/\r\n/sg; + } elsif ($opt eq '-m') { + s/\n/\r/sg; + } + + open (F, ">$file"); + binmode F; + print F $_; + close (F); + + $file = shift @ARGV; +} diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/fallback.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/fallback.xml new file mode 100644 index 00000000000..5b162af5226 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/fallback.xml @@ -0,0 +1,8 @@ + + +

This example includes just the ‘use' lines from a Perl script.

+

+Resource error during XInclude, integrity constraint failed?
+
+

There are four of them.

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/result/fallback.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/result/fallback.xml new file mode 100644 index 00000000000..1d77e387c9c --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/more/result/fallback.xml @@ -0,0 +1,5 @@ + +

This example includes just the ‘use' lines from a Perl script.

+
Resource error during XInclude, integrity constraint failed?
+

There are four of them.

+
\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/attcopy-1.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/attcopy-1.xml new file mode 100644 index 00000000000..344c567373b --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/attcopy-1.xml @@ -0,0 +1,11 @@ + + +

This example includes a “definition” paragraph from some document +twice using attribute copying.

+ + + + + +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/attcopy-2.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/attcopy-2.xml new file mode 100644 index 00000000000..c1c1a23cca0 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/attcopy-2.xml @@ -0,0 +1,10 @@ + + +

This example shows attribute replacement.

+ + + + + +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/code.pl b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/code.pl new file mode 100644 index 00000000000..5e0cc49a382 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/code.pl @@ -0,0 +1,42 @@ +#!/usr/bin/perl -- # --*-Perl-*-- + +use strict; +use English; +use Getopt::Std; +use vars qw($opt_p $opt_q $opt_u $opt_m); + +my $usage = "Usage: $0 [-q] [-u|-p|-m] file [ file ... ]\n"; + +die $usage if ! getopts('qupm'); + +die $usage if ($opt_p + $opt_u + $opt_m) != 1; + +my $file = shift @ARGV || die $usage; + +my $opt = '-u' if $opt_u; +$opt = '-p' if $opt_p; +$opt = '-m' if $opt_m; + +while ($file) { + print "Converting $file to $opt linebreaks.\n" if !$opt_q; + open (F, "$file"); + binmode F; + read (F, $_, -s $file); + close (F); + + s/\r\n/\n/sg; + s/\r/\n/sg; + + if ($opt eq '-p') { + s/\n/\r\n/sg; + } elsif ($opt eq '-m') { + s/\n/\r/sg; + } + + open (F, ">$file"); + binmode F; + print F $_; + close (F); + + $file = shift @ARGV; +} diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/attcopy-1.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/attcopy-1.xml new file mode 100644 index 00000000000..7fc9aedd476 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/attcopy-1.xml @@ -0,0 +1,10 @@ + +

This example includes a “definition” paragraph from some document +twice using attribute copying.

+ + Some definition. + + Some definition. + +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/attcopy-2.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/attcopy-2.xml new file mode 100644 index 00000000000..7f06ad27707 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/attcopy-2.xml @@ -0,0 +1,13 @@ + +

This example shows attribute replacement.

+ + +

Consider the Wombat.

+
+ + +

Consider the Wombat.

+
+ +
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/rfc5147-1.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/rfc5147-1.xml new file mode 100644 index 00000000000..2bcdf677bdf --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/rfc5147-1.xml @@ -0,0 +1,9 @@ + +

This example includes just the ‘use' lines from a Perl script.

+
use strict;
+use English;
+use Getopt::Std;
+use vars qw($opt_p $opt_q $opt_u $opt_m);
+
+

There are four of them.

+
\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/rfc5147-2.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/rfc5147-2.xml new file mode 100644 index 00000000000..c1640a74694 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/result/rfc5147-2.xml @@ -0,0 +1,8 @@ + +

This example includes a range of characters.

+
_q $opt_u $opt_m);
+
+my $usage = "Usage: $0 [-q] [-u|-p|-m] file [ file ... ]\n";
+
+die $usage if ! ge
+
\ No newline at end of file diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/rfc5147-1.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/rfc5147-1.xml new file mode 100644 index 00000000000..8a630b12028 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/rfc5147-1.xml @@ -0,0 +1,6 @@ + + +

This example includes just the ‘use' lines from a Perl script.

+
+

There are four of them.

+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/rfc5147-2.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/rfc5147-2.xml new file mode 100644 index 00000000000..71111beb18f --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/rfc5147-2.xml @@ -0,0 +1,5 @@ + + +

This example includes a range of characters.

+
+
diff --git a/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/src.xml b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/src.xml new file mode 100644 index 00000000000..439363eb716 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xinclude-11/spec/src.xml @@ -0,0 +1,5 @@ + + Some paragraph. + Some definition. + Some other paragraph. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/ab-doc.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/ab-doc.xml new file mode 100644 index 00000000000..d4128417dc8 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/ab-doc.xml @@ -0,0 +1,2 @@ + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/ab-xinclude-lang.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/ab-xinclude-lang.xml new file mode 100644 index 00000000000..c14a0a51d53 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/ab-xinclude-lang.xml @@ -0,0 +1,4 @@ + + + Ein deutscher Text. + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/input-xinclude-recursive-1.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/input-xinclude-recursive-1.xml new file mode 100644 index 00000000000..92598944993 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/input-xinclude-recursive-1.xml @@ -0,0 +1,4 @@ + + some para + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/xinclude/input-xinclude-recursive-2.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/xinclude/input-xinclude-recursive-2.xml new file mode 100644 index 00000000000..34503dfe5c3 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/xinclude/input-xinclude-recursive-2.xml @@ -0,0 +1 @@ + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/xinclude/para.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/xinclude/para.xml new file mode 100644 index 00000000000..6c77100b7fd --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/ents/xinclude/para.xml @@ -0,0 +1 @@ +another para diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-002.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-002.xml new file mode 100644 index 00000000000..04cebe4b514 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-002.xml @@ -0,0 +1,4 @@ + + some para + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-016.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-016.xml new file mode 100644 index 00000000000..4db22655ba1 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-016.xml @@ -0,0 +1,3 @@ + + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-017.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-017.xml new file mode 100644 index 00000000000..63650956270 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-017.xml @@ -0,0 +1,4 @@ + + This is an english paragraph. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-018.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-018.xml new file mode 100644 index 00000000000..63650956270 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-018.xml @@ -0,0 +1,4 @@ + + This is an english paragraph. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-019.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-019.xml new file mode 100644 index 00000000000..63650956270 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/input/xproc-019.xml @@ -0,0 +1,4 @@ + + This is an english paragraph. + + diff --git a/exist-core/src/test/resources/xinclude-test-suite/xproc3/result/xproc-016.xml b/exist-core/src/test/resources/xinclude-test-suite/xproc3/result/xproc-016.xml new file mode 100644 index 00000000000..dc81732cd91 --- /dev/null +++ b/exist-core/src/test/resources/xinclude-test-suite/xproc3/result/xproc-016.xml @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<doc /> + diff --git a/exist-core/src/test/xquery/axes-persistent-nodes.xqm b/exist-core/src/test/xquery/axes-persistent-nodes.xqm index d9984336a2c..ece5539b2be 100644 --- a/exist-core/src/test/xquery/axes-persistent-nodes.xqm +++ b/exist-core/src/test/xquery/axes-persistent-nodes.xqm @@ -102,6 +102,25 @@ function axpn:preceding-with-predicate-db-map() { ! (./@id || ":" || (./preceding::pb[1]/@id, "PRECEDING_PB_NOT_FOUND")[1]) }; +declare + %test:assertEquals("w1:pb1", "w2:pb1", "w3:pb1", "w4:pb2", "w5:pb2") +function axpn:preceding-with-context-predicate-db-flwor() { + for $w in doc("/db/test/test.xml")//w[exists(.)] + let $preceding-page := $w/preceding::pb[1] + return + if ($preceding-page) then + $w/@id || ":" || $preceding-page/@id + else + $w/@id || ":PRECEDING_PB_NOT_FOUND" +}; + +declare + %test:assertEquals("w1:pb1", "w2:pb1", "w3:pb1", "w4:pb2", "w5:pb2") +function axpn:preceding-with-context-predicate-db-map() { + doc("/db/test/test.xml")//w[exists(.)] + ! (./@id || ":" || (./preceding::pb[1]/@id, "PRECEDING_PB_NOT_FOUND")[1]) +}; + declare %test:assertEquals("w1:pb1", "w2:pb1", "w3:pb1", "w4:pb2", "w5:pb2") function axpn:preceding-without-predicate-flwor() { @@ -161,6 +180,25 @@ function axpn:following-with-predicate-db-map() { ! (./@id || ":" || (./following::pb[1]/@id, "FOLLOWING_PB_NOT_FOUND")[1]) }; +declare + %test:assertEquals("w1:pb2", "w2:pb2", "w3:pb2", "w4:pb3", "w5:pb3") +function axpn:following-with-context-predicate-db-flwor() { + for $w in doc("/db/test/test.xml")//w[exists(.)] + let $following-page := $w/following::pb[1] + return + if ($following-page) then + $w/@id || ":" || $following-page/@id + else + $w/@id || ":FOLLOWING_PB_NOT_FOUND" +}; + +declare + %test:assertEquals("w1:pb2", "w2:pb2", "w3:pb2", "w4:pb3", "w5:pb3") +function axpn:following-with-context-predicate-db-map() { + doc("/db/test/test.xml")//w[exists(.)] + ! (./@id || ":" || (./following::pb[1]/@id, "FOLLOWING_PB_NOT_FOUND")[1]) +}; + declare %test:assertEquals("w1:pb2", "w2:pb2", "w3:pb2", "w4:pb3", "w5:pb3") function axpn:following-without-predicate-flwor() { diff --git a/exist-core/src/test/xquery/boolean-sequences.xq b/exist-core/src/test/xquery/boolean-sequences.xq index f5749bb1ffb..85e60b59aba 100644 --- a/exist-core/src/test/xquery/boolean-sequences.xq +++ b/exist-core/src/test/xquery/boolean-sequences.xq @@ -49,10 +49,9 @@ function boolseq:countPositivesContextItem() { }; (:~ - this is the failing issue + this was the failing issue — fixed in GitHub #2308 ~:) declare - %test:pending %test:assertEquals(1) function boolseq:countNegativesContextItem() { count($boolseq:sequence[not(.)]) diff --git a/exist-core/src/test/xquery/dates/format-dates.xql b/exist-core/src/test/xquery/dates/format-dates.xql index 840d3e8c8ad..472f8d61b36 100644 --- a/exist-core/src/test/xquery/dates/format-dates.xql +++ b/exist-core/src/test/xquery/dates/format-dates.xql @@ -440,7 +440,6 @@ function fd:format-dateTime-ZZ-positive-double-digit($date as xs:dateTime) { }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000-10:00") %test:assertEquals("HST") function fd:format-dateTime-ZN-negative-double-digit($date as xs:dateTime) { @@ -448,7 +447,6 @@ function fd:format-dateTime-ZN-negative-double-digit($date as xs:dateTime) { }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000-05:00") %test:assertEquals("EST") function fd:format-dateTime-ZN-negative-single-digit($date as xs:dateTime) { @@ -463,7 +461,6 @@ function fd:format-dateTime-ZN-zero($date as xs:dateTime) { }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000+05:30") %test:assertEquals("IST") function fd:format-dateTime-ZN-positive-single-digit($date as xs:dateTime) { @@ -471,7 +468,6 @@ function fd:format-dateTime-ZN-positive-single-digit($date as xs:dateTime) { }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000+13:00") %test:assertEquals("+13:00") function fd:format-dateTime-ZN-positive-double-digit($date as xs:dateTime) { @@ -479,40 +475,36 @@ function fd:format-dateTime-ZN-positive-double-digit($date as xs:dateTime) { }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000-10:00") - %test:assertEquals("06:00 EST") + %test:assertEquals("18:00 EDT") function fd:format-dateTime-ZN-NY-negative-double-digit($date as xs:dateTime) { format-dateTime($date, "[H00]:[M00] [ZN]", (), (), "America/New_York") }; declare %test:args("2012-06-26T12:00:00.000-05:00") - %test:assertEquals("12:00 EST") + %test:assertEquals("13:00 EDT") function fd:format-dateTime-ZN-NY-negative-single-digit($date as xs:dateTime) { format-dateTime($date, "[H00]:[M00] [ZN]", (), (), "America/New_York") }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000+00:00") - %test:assertEquals("07:00 EST") + %test:assertEquals("08:00 EDT") function fd:format-dateTime-ZN-NY-zero($date as xs:dateTime) { format-dateTime($date, "[H00]:[M00] [ZN]", (), (), "America/New_York") }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000+05:30") - %test:assertEquals("01:30 EST") + %test:assertEquals("02:30 EDT") function fd:format-dateTime-ZN-NY-positive-single-digit($date as xs:dateTime) { format-dateTime($date, "[H00]:[M00] [ZN]", (), (), "America/New_York") }; declare - %test:pending("[ZN] is not yet supported") %test:args("2012-06-26T12:00:00.000+13:00") - %test:assertEquals("18:00 EST") + %test:assertEquals("19:00 EDT") function fd:format-dateTime-ZN-NY-positive-double-digit($date as xs:dateTime) { format-dateTime($date, "[H00]:[M00] [ZN]", (), (), "America/New_York") }; @@ -594,3 +586,9 @@ declare function fd:written-day-en($date-str as xs:string) { format-date(xs:date($date-str), "[FNn]", "en", (), ()) }; + +declare + %test:assertError("XPTY0004") +function fd:string-instead-of-date() { + format-date('2022-05-30', '[MNn] [D], [Y]') +}; diff --git a/exist-core/src/test/xquery/dates/xq4-datetime-components.xqm b/exist-core/src/test/xquery/dates/xq4-datetime-components.xqm new file mode 100644 index 00000000000..5f7b7cdbd04 --- /dev/null +++ b/exist-core/src/test/xquery/dates/xq4-datetime-components.xqm @@ -0,0 +1,276 @@ +(: + : 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 + :) +xquery version "4.0"; + +(:~ + : XQuery 4.0 date/time component extraction tests (QT4CG PR #1481). + : In XQ4, fn:*-from-dateTime functions accept any Gregorian date/time type. + :) +module namespace dtc="http://exist-db.org/xquery/test/xq4-datetime-components"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(: ===== year-from-dateTime ===== :) + +declare + %test:assertEquals(2025) +function dtc:year-from-dateTime-date() { + fn:year-from-dateTime(xs:date("2025-03-15")) +}; + +declare + %test:assertEquals(2025) +function dtc:year-from-dateTime-gYear() { + fn:year-from-dateTime(xs:gYear("2025")) +}; + +declare + %test:assertEquals(2025) +function dtc:year-from-dateTime-gYearMonth() { + fn:year-from-dateTime(xs:gYearMonth("2025-06")) +}; + +declare + %test:assertEmpty +function dtc:year-from-dateTime-time() { + fn:year-from-dateTime(xs:time("13:20:00")) +}; + +declare + %test:assertEmpty +function dtc:year-from-dateTime-gMonth() { + fn:year-from-dateTime(xs:gMonth("--06")) +}; + +declare + %test:assertEmpty +function dtc:year-from-dateTime-gDay() { + fn:year-from-dateTime(xs:gDay("---15")) +}; + +declare + %test:assertEmpty +function dtc:year-from-dateTime-gMonthDay() { + fn:year-from-dateTime(xs:gMonthDay("--06-15")) +}; + +(: ===== month-from-dateTime ===== :) + +declare + %test:assertEquals(3) +function dtc:month-from-dateTime-date() { + fn:month-from-dateTime(xs:date("2025-03-15")) +}; + +declare + %test:assertEquals(6) +function dtc:month-from-dateTime-gYearMonth() { + fn:month-from-dateTime(xs:gYearMonth("2025-06")) +}; + +declare + %test:assertEquals(6) +function dtc:month-from-dateTime-gMonth() { + fn:month-from-dateTime(xs:gMonth("--06")) +}; + +declare + %test:assertEquals(6) +function dtc:month-from-dateTime-gMonthDay() { + fn:month-from-dateTime(xs:gMonthDay("--06-15")) +}; + +declare + %test:assertEmpty +function dtc:month-from-dateTime-time() { + fn:month-from-dateTime(xs:time("13:20:00")) +}; + +declare + %test:assertEmpty +function dtc:month-from-dateTime-gYear() { + fn:month-from-dateTime(xs:gYear("2025")) +}; + +declare + %test:assertEmpty +function dtc:month-from-dateTime-gDay() { + fn:month-from-dateTime(xs:gDay("---15")) +}; + +(: ===== day-from-dateTime ===== :) + +declare + %test:assertEquals(15) +function dtc:day-from-dateTime-date() { + fn:day-from-dateTime(xs:date("2025-03-15")) +}; + +declare + %test:assertEquals(15) +function dtc:day-from-dateTime-gDay() { + fn:day-from-dateTime(xs:gDay("---15")) +}; + +declare + %test:assertEquals(15) +function dtc:day-from-dateTime-gMonthDay() { + fn:day-from-dateTime(xs:gMonthDay("--06-15")) +}; + +declare + %test:assertEmpty +function dtc:day-from-dateTime-time() { + fn:day-from-dateTime(xs:time("13:20:00")) +}; + +declare + %test:assertEmpty +function dtc:day-from-dateTime-gYear() { + fn:day-from-dateTime(xs:gYear("2025")) +}; + +(: ===== hours-from-dateTime ===== :) + +declare + %test:assertEquals(13) +function dtc:hours-from-dateTime-time() { + fn:hours-from-dateTime(xs:time("13:20:30")) +}; + +declare + %test:assertEmpty +function dtc:hours-from-dateTime-date() { + fn:hours-from-dateTime(xs:date("2025-03-15")) +}; + +declare + %test:assertEmpty +function dtc:hours-from-dateTime-gYear() { + fn:hours-from-dateTime(xs:gYear("2025")) +}; + +(: ===== minutes-from-dateTime ===== :) + +declare + %test:assertEquals(20) +function dtc:minutes-from-dateTime-time() { + fn:minutes-from-dateTime(xs:time("13:20:30")) +}; + +declare + %test:assertEmpty +function dtc:minutes-from-dateTime-date() { + fn:minutes-from-dateTime(xs:date("2025-03-15")) +}; + +(: ===== seconds-from-dateTime ===== :) + +declare + %test:assertEquals(30) +function dtc:seconds-from-dateTime-time() { + fn:seconds-from-dateTime(xs:time("13:20:30")) +}; + +declare + %test:assertEquals(30.5) +function dtc:seconds-from-dateTime-time-fractional() { + fn:seconds-from-dateTime(xs:time("13:20:30.5")) +}; + +declare + %test:assertEmpty +function dtc:seconds-from-dateTime-date() { + fn:seconds-from-dateTime(xs:date("2025-03-15")) +}; + +(: ===== timezone-from-dateTime ===== :) + +declare + %test:assertEquals("PT5H") +function dtc:timezone-from-dateTime-date() { + fn:timezone-from-dateTime(xs:date("2025-03-15+05:00")) +}; + +declare + %test:assertEquals("-PT5H") +function dtc:timezone-from-dateTime-time() { + fn:timezone-from-dateTime(xs:time("13:20:00-05:00")) +}; + +declare + %test:assertEmpty +function dtc:timezone-from-dateTime-no-tz() { + fn:timezone-from-dateTime(xs:gYear("2025")) +}; + +(: ===== Existing XQ 3.1 behavior: xs:dateTime arg still works ===== :) + +declare + %test:assertEquals(1999) +function dtc:year-from-dateTime-dateTime() { + fn:year-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +declare + %test:assertEquals(5) +function dtc:month-from-dateTime-dateTime() { + fn:month-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +declare + %test:assertEquals(31) +function dtc:day-from-dateTime-dateTime() { + fn:day-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +declare + %test:assertEquals(13) +function dtc:hours-from-dateTime-dateTime() { + fn:hours-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +declare + %test:assertEquals(20) +function dtc:minutes-from-dateTime-dateTime() { + fn:minutes-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +declare + %test:assertEquals(0) +function dtc:seconds-from-dateTime-dateTime() { + fn:seconds-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +declare + %test:assertEquals("-PT5H") +function dtc:timezone-from-dateTime-dateTime() { + fn:timezone-from-dateTime(xs:dateTime("1999-05-31T13:20:00-05:00")) +}; + +(: ===== Empty sequence input ===== :) + +declare + %test:assertEmpty +function dtc:year-from-dateTime-empty() { + fn:year-from-dateTime(()) +}; diff --git a/exist-core/src/test/xquery/errors.xql b/exist-core/src/test/xquery/errors.xql index dff2823f6ee..56674f5c953 100644 --- a/exist-core/src/test/xquery/errors.xql +++ b/exist-core/src/test/xquery/errors.xql @@ -162,10 +162,10 @@ function et:nl-in-comment() { }; declare - %test:assertXPath("/error[@line='2'][contains(., 'Invalid qname console:log')]") + %test:assertXPath("/error[@line='2'][contains(., 'Invalid qname nonexistent:foo')]") function et:compile-query-unknown-func() { let $query := ``[ - console:log('foo') + nonexistent:foo('bar') ]`` return util:compile-query($query, "xmldb:exist://") @@ -189,13 +189,13 @@ function et:compile-query-variable-decl-pass() { (: Should not result in an NPE, see https://github.com/eXist-db/exist/pull/1520#issuecomment-604514099 :) declare - %test:assertXPath("/error[@line='5'][contains(., 'Invalid qname console:log')]") + %test:assertXPath("/error[@line='5'][contains(., 'Invalid qname nonexistent:foo')]") function et:compile-query-variable-decl() { let $query := ``[ declare variable $v := map { "a": "b", "f": function() { - console:log('foo') + nonexistent:foo('bar') } }; $v?f() diff --git a/exist-core/src/test/xquery/fn-not.xq b/exist-core/src/test/xquery/fn-not.xq new file mode 100644 index 00000000000..801fe533c5e --- /dev/null +++ b/exist-core/src/test/xquery/fn-not.xq @@ -0,0 +1,129 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + Tests for fn:not() predicate handling. + https://github.com/eXist-db/exist/issues/2159 + https://github.com/eXist-db/exist/issues/2308 +~:) +module namespace fn-not="http://exist-db.org/xquery/test/fn-not"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $fn-not:collection := 'test-fn-not'; +declare variable $fn-not:doc := 'test.xml'; +declare variable $fn-not:nodes := document { + + + + + +}; + +declare + %test:setUp +function fn-not:setup() { + xmldb:create-collection('/db', $fn-not:collection), + xmldb:store('/db/' || $fn-not:collection, $fn-not:doc, $fn-not:nodes) +}; + +declare + %test:tearDown +function fn-not:teardown() { + xmldb:remove('/db/' || $fn-not:collection) +}; + +(: #2159 — not(self::x) on empty derived path must not crash :) +declare + %test:assertEquals(0) +function fn-not:empty-path-not-self() { + let $doc := + return count($doc/nonexistent/*[not(self::abc)]) +}; + +(: #2159 — not(self::x) on non-empty path filters correctly :) +declare + %test:assertEquals(1) +function fn-not:non-empty-path-not-self() { + let $doc := + return count($doc/*[not(self::abc)]) +}; + +(: #2159 — not(*) on empty path returns empty :) +declare + %test:assertEquals(0) +function fn-not:empty-path-not-wildcard() { + let $doc := + return count($doc/nonexistent/*[not(*)]) +}; + +(: Standalone not(()) returns true — boolean path unaffected :) +declare + %test:assertTrue +function fn-not:standalone-not-empty() { + not(()) +}; + +(: Set-difference optimization on persistent nodes — not(child) :) +declare + %test:assertEquals(2) +function fn-not:persistent-not-child() { + let $dom := doc('/db/' || $fn-not:collection || '/' || $fn-not:doc) + return count($dom//item[not(child)]) +}; + +(: Set-difference optimization on persistent nodes — not(self::x) :) +declare + %test:assertEquals(1) +function fn-not:persistent-not-self() { + let $dom := doc('/db/' || $fn-not:collection || '/' || $fn-not:doc) + return count($dom//item[not(@type = 'a')]) +}; + +(: #2308 — not(.) on integer sequence :) +declare + %test:assertEquals(1) +function fn-not:not-dot-integers() { + count((0, 1, 2)[not(.)]) +}; + +(: #2308 — not(.) on string sequence :) +declare + %test:assertEquals(1) +function fn-not:not-dot-strings() { + count(("", "a")[not(.)]) +}; + +(: #2308 — not(.) on mixed booleans :) +declare + %test:assertEquals(1) +function fn-not:not-dot-booleans() { + count((true(), false())[not(.)]) +}; + +(: not(.) on in-memory node sequence filters correctly :) +declare + %test:assertEquals(0) +function fn-not:not-dot-nodes() { + count((, )[not(.)]) +}; diff --git a/exist-core/src/test/xquery/json.xml b/exist-core/src/test/xquery/json.xml index ad03639c5ad..54d34e7717c 100644 --- a/exist-core/src/test/xquery/json.xml +++ b/exist-core/src/test/xquery/json.xml @@ -33,7 +33,7 @@ - + Simple serialization test @@ -47,7 +47,7 @@ {"object":{"prop1":"PROP1","prop2":"PROP2"}} ]]> - + Attribute serialization test @@ -61,7 +61,7 @@ {"object":{"attr1":"a1","attr2":"a2","prop1":"PROP1","prop2":"PROP2"}} ]]> - + Multiple children with same name: serialize as array @@ -75,7 +75,7 @@ {"object":{"attr1":"a1","attr2":"a2","prop":["PROP1","PROP2"]}} ]]> - + Empty element: serialize as null @@ -88,7 +88,7 @@ {"object":{"attr1":"a1","attr2":"a2","prop":null}} ]]> - + Attributes plus text content: serialize as #text @@ -99,7 +99,7 @@ {"object":{"attr1":"a1","attr2":"a2","#text":"text"}} ]]> - + Nested element serialization @@ -115,7 +115,7 @@ {"object":{"address":{"street":"a street","city":"a city"}}} ]]> - + Nested elements of same name @@ -133,7 +133,7 @@ ]]> - + Enforce array for single child @@ -146,7 +146,7 @@ {"object":{"prop":["PROP1"]}} ]]> - + Enforce array for multiple children @@ -160,7 +160,7 @@ {"object":{"prop":["PROP1","PROP2"]}} ]]> - + Nested json:value @@ -175,7 +175,7 @@ ]]> - - + Enforce nested array of values @@ -202,7 +202,7 @@ {"object":[["PROP1"]]} ]]> - + Enforce nested array of values without object wrapper @@ -214,7 +214,7 @@ [["PROP1","PROP2"]] ]]> - + Array of objects @@ -232,7 +232,7 @@ [{"first":"Max","last":"Müller"},{"first":"Heinz","last":"Müller"}] ]]> - + Array of objects diff --git a/exist-core/src/test/xquery/maps/maps.xqm b/exist-core/src/test/xquery/maps/maps.xqm index 3a239fa1fa7..6b256336624 100644 --- a/exist-core/src/test/xquery/maps/maps.xqm +++ b/exist-core/src/test/xquery/maps/maps.xqm @@ -1019,3 +1019,49 @@ function mt:nested-map-for-each() { } => serialize(map{'indent':false()}) }; + +(: === XQuery 4.0 Map Comprehensions (PR2094) - merge entries === :) + +declare + %test:assertEquals(3) +function mt:merge-entry-basic() { + map:size(map { "a": 1, map {"b": 2, "c": 3} }) +}; + +declare + %test:assertEquals("a", "b", "c") +function mt:merge-entry-keys() { + map { "a": 1, map {"b": 2}, map {"c": 3} } => map:keys() => sort() +}; + +declare + %test:assertEquals(0) +function mt:merge-entry-empty-map() { + map:size(map { map {} }) +}; + +declare + %test:assertEquals(2) +function mt:merge-entry-conditional() { + let $include := true() + return map:size(map { "a": 1, if ($include) then map {"b": 2} else map {} }) +}; + +declare + %test:assertEquals(1) +function mt:merge-entry-conditional-false() { + let $include := false() + return map:size(map { "a": 1, if ($include) then map {"b": 2} else map {} }) +}; + +declare + %test:assertError("XQDY0137") +function mt:merge-entry-duplicate-key() { + map { "a": 1, map {"a": 2} } +}; + +declare + %test:assertError("XPTY0004") +function mt:merge-entry-non-map() { + map { "a": 1, "not-a-map" } +}; diff --git a/exist-core/src/test/xquery/numbers/format-number-map.xql b/exist-core/src/test/xquery/numbers/format-number-map.xql new file mode 100644 index 00000000000..75541c6554e --- /dev/null +++ b/exist-core/src/test/xquery/numbers/format-number-map.xql @@ -0,0 +1,142 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ Tests for fn:format-number with XQuery 4.0 map overload :) +module namespace fnm="http://exist-db.org/xquery/test/format-number-map"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(: === Basic map overload — custom separators === :) + +declare + %test:assertEquals("12.345,60") +function fnm:european-format() { + format-number(12345.6, '#.###,00', map { + 'decimal-separator': ',', + 'grouping-separator': '.' + }) +}; + +declare + %test:assertEquals("12 345,60") +function fnm:french-format() { + format-number(12345.6, '# ###,00', map { + 'decimal-separator': ',', + 'grouping-separator': ' ' + }) +}; + +(: === Custom infinity and NaN === :) + +declare + %test:assertEquals("∞") +function fnm:custom-infinity() { + format-number(1 div 0e0, '#', map { + 'infinity': '∞' + }) +}; + +declare + %test:assertEquals("N/A") +function fnm:custom-nan() { + format-number(number('NaN'), '#', map { + 'NaN': 'N/A' + }) +}; + +(: === Custom minus sign === :) + +declare + %test:assertEquals("(42)") +function fnm:custom-minus() { + format-number(-42, '#;(#)', map { + 'minus-sign': '−' + }) +}; + +(: === Custom percent and per-mille === :) + +declare + %test:assertEquals("75%") +function fnm:default-percent() { + format-number(0.75, '#%') +}; + +(: === Empty map = unnamed default === :) + +declare + %test:assertEquals("1,234.50") +function fnm:empty-map-uses-default() { + format-number(1234.5, '#,###.00', map {}) +}; + +(: === Map with format-name selects base format === :) +(: Note: this test uses the unnamed default since we can't declare + custom decimal formats in a module without declare decimal-format :) + +declare + %test:assertEquals("1,234.50") +function fnm:map-with-no-format-name() { + format-number(1234.5, '#,###.00', map {}) +}; + +(: === Zero digit override === :) + +declare + %test:assertEquals("١٢٣") +function fnm:arabic-digits() { + (: Arabic-Indic digit zero is U+0660, picture must use same digit family :) + format-number(123, '٠٠٠', map { + 'zero-digit': '٠' + }) +}; + +(: === Exponent separator === :) + +declare + %test:assertEquals("1.23E3") +function fnm:custom-exponent-separator() { + format-number(1230, '0.00E0', map { + 'exponent-separator': 'E' + }) +}; + +(: === Multiple overrides at once === :) + +declare + %test:assertEquals("1.234,56") +function fnm:multiple-overrides() { + format-number(1234.56, '#.###,00', map { + 'decimal-separator': ',', + 'grouping-separator': '.' + }) +}; + +(: === Backward compatibility: string arg still works === :) + +declare + %test:assertEquals("1,234.50") +function fnm:string-arg-still-works() { + (: No custom decimal format declared, so unnamed default :) + format-number(1234.5, '#,###.00') +}; diff --git a/exist-core/src/test/xquery/numbers/format-numbers.xql b/exist-core/src/test/xquery/numbers/format-numbers.xql index 23080940618..8746b4bf059 100644 --- a/exist-core/src/test/xquery/numbers/format-numbers.xql +++ b/exist-core/src/test/xquery/numbers/format-numbers.xql @@ -316,4 +316,104 @@ declare %test:assertEquals("1.235e-10") function fd:exponent-fails($number as xs:double, $picture as xs:string) { format-number($number, $picture) +}; + +(:~ + : Named decimal-format with European conventions (comma as decimal separator, dot as grouping separator). + :) +declare + %test:assertEquals("1.234,50") +function fd:named-decimal-format-eu() { + util:eval(' + declare namespace local = "http://local"; + declare decimal-format local:eu decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00", "local:eu") + ') +}; + +(:~ + : Default decimal-format with European conventions. + :) +declare + %test:assertEquals("1.234,50") +function fd:default-decimal-format-eu() { + util:eval(' + declare default decimal-format decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00") + ') +}; + +(:~ + : Custom NaN property. + :) +declare + %test:assertEquals("not a number") +function fd:custom-nan() { + util:eval(' + declare default decimal-format NaN = "not a number"; + format-number(number(''NaN''), "#.00") + ') +}; + +(:~ + : Custom infinity property. + :) +declare + %test:assertEquals("INFINITY") +function fd:custom-infinity() { + util:eval(' + declare default decimal-format infinity = "INFINITY"; + format-number(1 div 0e0, "#.00") + ') +}; + +(:~ + : Error: duplicate named decimal-format declaration. + :) +declare + %test:assertError("XQST0097") +function fd:error-duplicate-named-decimal-format() { + util:eval(' + declare namespace local = "http://local"; + declare decimal-format local:eu decimal-separator = "," grouping-separator = "."; + declare decimal-format local:eu decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00", "local:eu") + ') +}; + +(:~ + : Error: duplicate default decimal-format declaration. + :) +declare + %test:assertError("XQST0097") +function fd:error-duplicate-default-decimal-format() { + util:eval(' + declare default decimal-format decimal-separator = "," grouping-separator = "."; + declare default decimal-format decimal-separator = "," grouping-separator = "."; + format-number(1234.5, "#.##0,00") + ') +}; + +(:~ + : Error: non-distinct property values (decimal-separator and grouping-separator are the same). + :) +declare + %test:assertError("XQST0098") +function fd:error-non-distinct-properties() { + util:eval(' + declare default decimal-format decimal-separator = "." grouping-separator = "."; + format-number(1234.5, "#.##0.00") + ') +}; + +(:~ + : Error: invalid zero-digit value. + :) +declare + %test:assertError("XQST0098") +function fd:error-invalid-zero-digit() { + util:eval(' + declare default decimal-format zero-digit = "A"; + format-number(1234.5, "#,##0.00") + ') }; \ No newline at end of file diff --git a/exist-core/src/test/xquery/simple-map-predicate.xq b/exist-core/src/test/xquery/simple-map-predicate.xq new file mode 100644 index 00000000000..fe596dd73d3 --- /dev/null +++ b/exist-core/src/test/xquery/simple-map-predicate.xq @@ -0,0 +1,136 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + Tests for simple map operator inside predicates. + https://github.com/eXist-db/exist/issues/3289 + + The expression @*[name() ! contains(., 'DateTime')] crashes with + "Type error: the sequence cannot be converted into a node set. + Item type is xs:boolean" when used on persistent (stored) documents. +~:) +module namespace smp="http://exist-db.org/xquery/test/simple-map-predicate"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $smp:collection := 'test-simple-map-predicate'; +declare variable $smp:doc := 'sts135.xml'; +declare variable $smp:data := + + + + ; + +declare + %test:setUp +function smp:setup() { + xmldb:create-collection('/db', $smp:collection), + xmldb:store('/db/' || $smp:collection, $smp:doc, $smp:data) +}; + +declare + %test:tearDown +function smp:teardown() { + xmldb:remove('/db/' || $smp:collection) +}; + +(:~ + #3289 — @*[name() ! contains(., 'DateTime')] on stored doc crashes with + "Type error: the sequence cannot be converted into a node set." +~:) +declare + %test:assertEquals(1) +function smp:persistent-simple-map-contains-in-attr-predicate() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//*[@*[name() ! contains(., 'DateTime')]]) +}; + +(:~ + Workaround from #3289: contains(name(.), ...) — should always work. +~:) +declare + %test:assertEquals(1) +function smp:persistent-contains-name-dot() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//*[@*[contains(name(.), 'DateTime')]]) +}; + +(:~ + Another workaround from #3289: @* ! name()[contains(., ...)] +~:) +declare + %test:assertEquals(2) +function smp:persistent-attr-map-name-predicate() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + let $event := $doc//event[1] + return count($event/@* ! name()[contains(., 'DateTime')]) +}; + +(:~ + Workaround from #3289 comment: @*[name()[contains(., ...)]] +~:) +declare + %test:assertEquals(1) +function smp:persistent-nested-name-predicate() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//*[@*[name()[contains(., 'DateTime')]]]) +}; + +(:~ + Workaround from #3289 comment: //@*[name() ! contains(., ...)]/.. +~:) +declare + %test:assertEquals(1) +function smp:persistent-deref-attr-parent() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return count($doc//@*[name() ! contains(., 'DateTime')]/..) +}; + +(:~ + #3289 comment pattern 1: @* ! name() ! contains(., ...) is expected to fail + per spec when multiple attributes produce multiple booleans, since EBV is + undefined for a sequence of two or more booleans. +~:) +declare + %test:assertError("FORG0006") +function smp:persistent-double-map-expected-error() { + let $doc := doc('/db/' || $smp:collection || '/' || $smp:doc) + return $doc//*[@* ! name() ! contains(., 'DateTime')] +}; + +(:~ + In-memory version should also work — same pattern, no stored doc. +~:) +declare + %test:assertEquals(1) +function smp:inmemory-simple-map-contains-in-attr-predicate() { + let $doc := + + + + return count($doc//*[@*[name() ! contains(., 'DateTime')]]) +}; diff --git a/exist-core/src/test/xquery/util/moduleDiscoveryTest.xql b/exist-core/src/test/xquery/util/moduleDiscoveryTest.xql new file mode 100644 index 00000000000..a9fa2b54161 --- /dev/null +++ b/exist-core/src/test/xquery/util/moduleDiscoveryTest.xql @@ -0,0 +1,95 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace mdt="http://exist-db.org/xquery/test/module-discovery"; +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(: Built-in Java modules should be in registered-modules :) +declare + %test:assertEquals("true") +function mdt:util-is-registered() { + "http://exist-db.org/xquery/util" = util:registered-modules() +}; + +(: Every mapped XQuery module should also appear in registered-modules :) +declare + %test:assertTrue +function mdt:mapped-modules-in-registered() { + every $uri in util:mapped-modules() satisfies $uri = util:registered-modules() +}; + +(: registered-modules should contain no duplicates. + : Returns the number of duplicate entries (0 = pass). :) +declare + %test:assertEquals(0) +function mdt:no-duplicates() { + let $modules := util:registered-modules() + return count($modules) - count(distinct-values($modules)) +}; + +(: Every module info map must have uri, prefix, and source keys. + : Returns any maps that are missing required keys. :) +declare + %test:assertEmpty +function mdt:info-missing-keys() { + let $info := util:registered-modules-info() + return $info[not(map:contains(., "uri") and map:contains(., "prefix") and map:contains(., "source"))] +}; + +(: Every module info source must be "built-in", "package", or "mapped". + : Returns any maps with invalid source values. :) +declare + %test:assertEmpty +function mdt:info-invalid-sources() { + let $info := util:registered-modules-info() + return $info[not(map:get(., "source") = ("built-in", "package", "mapped"))] +}; + +(: Every URI in registered-modules should appear in registered-modules-info. + : Returns any registered URIs missing from info. :) +declare + %test:assertEmpty +function mdt:registered-not-in-info() { + let $registered := util:registered-modules() + let $info-uris := for $m in util:registered-modules-info() return map:get($m, "uri") + return $registered[not(. = $info-uris)] +}; + +(: Every URI in registered-modules-info should appear in registered-modules. + : Returns any info URIs missing from registered. :) +declare + %test:assertEmpty +function mdt:info-not-in-registered() { + let $registered := util:registered-modules() + let $info-uris := for $m in util:registered-modules-info() return map:get($m, "uri") + return $info-uris[not(. = $registered)] +}; + +(: The util module should have source "built-in" :) +declare + %test:assertEquals("built-in") +function mdt:util-is-built-in() { + let $info := util:registered-modules-info() + let $util := $info[map:get(., "uri") = "http://exist-db.org/xquery/util"] + return map:get($util, "source") +}; diff --git a/exist-core/src/test/xquery/util/profiling.xql b/exist-core/src/test/xquery/util/profiling.xql new file mode 100644 index 00000000000..c2b14fbc78c --- /dev/null +++ b/exist-core/src/test/xquery/util/profiling.xql @@ -0,0 +1,268 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for util:time(), util:memory(), and util:track() profiling functions. + :) +module namespace prof = "http://exist-db.org/xquery/test/profiling"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +(: === util:time tests === :) + +declare + %test:assertTrue +function prof:time-returns-result() { + let $result := util:time(1 + 1) + return $result eq 2 +}; + +declare + %test:assertEquals(5) +function prof:time-sequence() { + count(util:time(1 to 5)) +}; + +declare + %test:assertEquals("hello") +function prof:time-with-label() { + util:time("hello", "string test") +}; + +declare + %test:assertTrue +function prof:time-empty-sequence() { + empty(util:time(())) +}; + +(: === util:memory tests === :) + +declare + %test:assertTrue +function prof:memory-returns-result() { + let $result := util:memory(1 + 1) + return $result eq 2 +}; + +declare + %test:assertEquals("world") +function prof:memory-with-label() { + util:memory("world", "memory test") +}; + +(: === util:track tests === :) + +declare + %test:assertTrue +function prof:track-returns-map() { + let $result := util:track(1 + 1) + return $result instance of map(*) +}; + +declare + %test:assertTrue +function prof:track-has-time-key() { + let $result := util:track(1 + 1) + return map:contains($result, "time") +}; + +declare + %test:assertTrue +function prof:track-has-memory-key() { + let $result := util:track(1 + 1) + return map:contains($result, "memory") +}; + +declare + %test:assertTrue +function prof:track-has-value-key() { + let $result := util:track(1 + 1) + return map:contains($result, "value") +}; + +declare + %test:assertEquals(2) +function prof:track-value-correct() { + let $result := util:track(1 + 1) + return $result?value +}; + +declare + %test:assertTrue +function prof:track-time-is-duration() { + let $result := util:track(1 to 100) + return $result?time instance of xs:dayTimeDuration +}; + +declare + %test:assertTrue +function prof:track-memory-is-integer() { + let $result := util:track(1 to 100) + return $result?memory instance of xs:integer +}; + +declare + %test:assertTrue +function prof:track-with-label() { + let $result := util:track(1 to 10, "range test") + return map:contains($result, "label") and $result?label eq "range test" +}; + +declare + %test:assertEquals(5) +function prof:track-sequence-value() { + let $result := util:track(1 to 5) + return count($result?value) +}; + +(: === util:explain tests === :) + +declare + %test:assertTrue +function prof:explain-returns-element() { + let $result := util:explain('1 + 1') + return $result instance of element() +}; + +declare + %test:assertEquals("explain") +function prof:explain-root-element() { + let $result := util:explain('1 + 1') + return local-name($result) +}; + +declare + %test:assertTrue +function prof:explain-for-has-for-element() { + let $result := util:explain('for $x in 1 to 5 return $x') + return exists($result//for) +}; + +declare + %test:assertTrue +function prof:explain-let-has-let-element() { + let $result := util:explain('let $x := 42 return $x') + return exists($result//let) +}; + +declare + %test:assertTrue +function prof:explain-path-has-step() { + let $result := util:explain('//title') + return exists($result//step) +}; + +declare + %test:assertTrue +function prof:explain-function-call() { + let $result := util:explain('count(1 to 10)') + return exists($result//*[contains(@name, "count")]) +}; + +declare + %test:assertTrue +function prof:explain-conditional() { + let $result := util:explain('if (true()) then 1 else 2') + return exists($result//if) +}; + +(: === util:profile tests === :) + +declare + %test:assertTrue +function prof:profile-returns-map() { + let $result := util:profile('1 + 1') + return $result instance of map(*) +}; + +declare + %test:assertTrue +function prof:profile-has-result-key() { + let $result := util:profile('1 + 1') + return map:contains($result, "result") +}; + +declare + %test:assertTrue +function prof:profile-has-time-key() { + let $result := util:profile('1 + 1') + return map:contains($result, "time") +}; + +declare + %test:assertTrue +function prof:profile-has-memory-key() { + let $result := util:profile('1 + 1') + return map:contains($result, "memory") +}; + +declare + %test:assertTrue +function prof:profile-has-plan-key() { + let $result := util:profile('1 + 1') + return map:contains($result, "plan") +}; + +declare + %test:assertTrue +function prof:profile-has-stats-key() { + let $result := util:profile('1 + 1') + return map:contains($result, "stats") +}; + +declare + %test:assertEquals(2) +function prof:profile-result-correct() { + let $result := util:profile('1 + 1') + return $result?result +}; + +declare + %test:assertTrue +function prof:profile-plan-is-element() { + let $result := util:profile('for $x in 1 to 5 return $x') + return $result?plan instance of element() +}; + +declare + %test:assertTrue +function prof:profile-stats-is-element() { + let $result := util:profile('1 + 1') + return $result?stats instance of element() +}; + +(: === util:index-report tests === :) + +declare + %test:assertTrue +function prof:index-report-returns-element() { + let $result := util:index-report('1 + 1') + return $result instance of element() +}; + +declare + %test:assertTrue +function prof:index-report-with-path() { + let $result := util:index-report('//title') + return $result instance of element() +}; diff --git a/exist-core/src/test/xquery/wrongArityErrorTest.xql b/exist-core/src/test/xquery/wrongArityErrorTest.xql new file mode 100644 index 00000000000..11b6c71727e --- /dev/null +++ b/exist-core/src/test/xquery/wrongArityErrorTest.xql @@ -0,0 +1,76 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for improved error messages when calling functions with wrong arity. + : See https://github.com/eXist-db/exist/issues/1756 + : + : Since wrong-arity errors are static (compile-time), we use util:eval + : to compile the expressions dynamically so the test module itself can load. + :) +module namespace wat="http://exist-db.org/xquery/test/wrong-arity"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(:~ + : Calling a built-in function with too few arguments should raise XPST0017. + : util:wait expects 1 argument; calling with 0 should mention the correct arity. + :) +declare + %test:assertError("XPST0017") +function wat:builtin-too-few-args() { + util:eval("util:wait()") +}; + +(:~ + : Calling a built-in function with too many arguments should raise XPST0017. + : util:wait expects 1 argument; calling with 2 should mention the correct arity. + :) +declare + %test:assertError("XPST0017") +function wat:builtin-too-many-args() { + util:eval("util:wait(100, 200)") +}; + +(:~ + : Calling a truly undeclared function should still raise XPST0017. + :) +declare + %test:assertError("XPST0017") +function wat:undeclared-function() { + util:eval("wat:this-function-does-not-exist()") +}; + +(:~ + : Calling an imported module function with wrong arity should raise XPST0017 + : and mention the available signatures. + :) +declare + %test:assertError("XPST0017") +function wat:imported-module-wrong-arity() { + util:eval(" + import module namespace util='http://exist-db.org/xquery/util' + at 'java:org.exist.xquery.functions.util.UtilModule'; + util:wait() + ") +}; diff --git a/exist-core/src/test/xquery/xqsuite/custom-assertion.xqm b/exist-core/src/test/xquery/xqsuite/custom-assertion.xqm index a83aaa32c51..ed432fe8a75 100644 --- a/exist-core/src/test/xquery/xqsuite/custom-assertion.xqm +++ b/exist-core/src/test/xquery/xqsuite/custom-assertion.xqm @@ -88,28 +88,28 @@ function ca:map-assertion-missing-key() as item()* { }; declare - %test:assertEquals("Value mismatch for key 'b'", "{""b"":3,""a"":1}", "map-assertion-failure") + %test:assertTrue function ca:map-assertion-wrong-value() as item()* { try { - ca:map-assertion($ca:var, map {"a": 1, "b": 3}) + ca:map-assertion($ca:var, map {"a": 1, "b": 3}), + false() } catch test:failure { - $err:description, - fn:serialize($err:value?actual, map{"method":"json"}), - $err:value?type + starts-with($err:description, "Value mismatch for key 'b'") + and $err:value?type = "map-assertion-failure" } }; declare - %test:assertEquals("Additional keys found: (o, 23)", "{""a"":1,""o"":""o"",""23"":3}", "map-assertion-failure") + %test:assertTrue function ca:map-assertion-additional-key() as item()* { try { - ca:map-assertion($ca:var, map {"a": 1, 23: 3, "o": "o"}) + ca:map-assertion($ca:var, map {"a": 1, 23: 3, "o": "o"}), + false() } catch test:failure { - $err:description, - fn:serialize($err:value?actual, map{"method":"json"}), - $err:value?type + starts-with($err:description, "Additional keys found:") + and $err:value?type = "map-assertion-failure" } }; diff --git a/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql b/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql index 9ae03b138f0..c734ece8ce7 100644 --- a/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql +++ b/exist-core/src/test/xquery/xqsuite/xqsuite-tests.xql @@ -246,3 +246,12 @@ declare function t:args-assert-element($arg as element()) as element() { $arg }; + +(: https://github.com/eXist-db/exist/issues/4327 :) +declare + %test:assertEquals(' + Success! +') +function t:assertEquals-normalize-annotation-whitespace() as element(span) { + Success! +}; diff --git a/exist-core/src/test/xquery/xquery3/bindingConflict.xqm b/exist-core/src/test/xquery/xquery3/bindingConflict.xqm index b9133d010fc..b2ea5d7f8f7 100644 --- a/exist-core/src/test/xquery/xquery3/bindingConflict.xqm +++ b/exist-core/src/test/xquery/xquery3/bindingConflict.xqm @@ -29,6 +29,8 @@ declare namespace xmldb="http://exist-db.org/xquery/xmldb"; declare namespace myns="http://www.foo.com"; declare namespace myns2="http://www.foo.net"; +(: ===== Legacy update tests (persistent documents) ===== :) + (: insert node into a ns with a conflicting ns in parent tree :) declare %test:assertError("XUDY0023") function ut:insert-child-namespaced-attr-conflicted() { diff --git a/exist-core/src/test/xquery/xquery3/bindingConflictXQUF.xqm b/exist-core/src/test/xquery/xquery3/bindingConflictXQUF.xqm new file mode 100644 index 00000000000..097d19d180b --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/bindingConflictXQUF.xqm @@ -0,0 +1,90 @@ +(: + : 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 + :) +xquery version "3.1"; + +(: W3C XQUF 3.0 versions of the namespace binding conflict tests. + : These use copy/modify/return (in-memory) instead of the legacy + : update syntax (persistent). Separated into its own module because + : legacy and XQUF syntax cannot be mixed in the same module. + :) + +module namespace utx="http://exist-db.org/xquery/update/xquf-test"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare namespace myns="http://www.foo.com"; +declare namespace myns2="http://www.foo.net"; + +(: insert node into a ns with a conflicting ns in parent tree :) +declare %test:assertError("XUDY0023") +function utx:xquf-insert-child-namespaced-attr-conflicted() { + copy $data := + modify insert node into $data/z + return $data +}; + +(: insert attr into a ns, but nothing contradictory in the tree - should add ns node :) +declare %test:assertEquals("") +function utx:xquf-insert-child-namespaced-attr() { + copy $data := + modify insert node into $data/z + return $data/z +}; + +(: insert attr into a ns, but nothing contradictory in the tree - should add ns node :) +declare %test:assertEquals("") +function utx:xquf-insert-namespaced-child() { + copy $data := + modify insert node into $data/z + return $data/z +}; + +declare %test:assertEquals("") +function utx:xquf-insert-namespaced-child-deep() { + copy $data := + modify insert node into $data/z + return fn:serialize($data/z) +}; + +(: insert attr into a ns, but nothing contradictory in the tree - should add ns node :) +declare %test:assertError("XUDY0023") +function utx:xquf-insert-namespaced-child-conflicted() { + copy $data := + modify insert node into $data/z + return $data/z +}; + +(: insert attr into a ns with a conflicting ns in parent tree :) +declare %test:assertError("XUDY0023") +function utx:xquf-insert-namespaced-attr-conflicted() { + copy $data := + modify insert node attribute myns:baz { "qux" } into $data/z + return $data +}; + +(: insert attr into a ns, but nothing contradictory in the tree - should add ns node :) +declare %test:assertEquals("") +function utx:xquf-insert-namespaced-attr() { + copy $data := + modify insert node attribute myns:baz { "qux" } into $data/z + return $data/z +}; diff --git a/exist-core/src/test/xquery/xquery3/dateTimeOverflow.xqm b/exist-core/src/test/xquery/xquery3/dateTimeOverflow.xqm new file mode 100644 index 00000000000..357e8e5e55e --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/dateTimeOverflow.xqm @@ -0,0 +1,125 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ FODT0001 (date/time overflow) and FODT0002 (duration overflow) detection + : per XQuery 3.1 section 10.1.1, mirroring the QT3 misc-CombinedErrorCodes + : tests FODT0001-* and FODT0002-*. :) +module namespace dt="http://exist-db.org/xquery/test/datetime-overflow"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare %test:assertError("FODT0001") +function dt:adjust-datetime-overflow() { + fn:adjust-dateTime-to-timezone( + xs:dateTime("25252734927766555-07-28T23:59:59-14:00"), + xs:dayTimeDuration("PT14H")) +}; + +declare %test:assertError("FODT0001") +function dt:adjust-date-overflow() { + fn:adjust-date-to-timezone( + xs:date("25252734927766555-07-28-14:00"), + xs:dayTimeDuration("PT14H")) +}; + +declare %test:assertError("FODT0001") +function dt:datetime-plus-daytime-overflow() { + xs:dateTime("25252734927766555-07-28T23:59:59-14:00") + + xs:dayTimeDuration("PT14H") +}; + +declare %test:assertError("FODT0001") +function dt:date-plus-yearmonth-overflow() { + xs:date("25252734927766555-07-28-14:00") + + xs:yearMonthDuration("P1Y0M") +}; + +declare %test:assertError("FODT0001") +function dt:date-minus-date-overflow() { + xs:date("25252734927766555-07-28-14:00") + - xs:date("-25252734927766555-07-28-14:00") +}; + +declare %test:assertError("FODT0002") +function dt:daytime-plus-overflow() { + xs:dayTimeDuration("P5999999999999999999DT00H00M01S") + + xs:dayTimeDuration("P4999999999999999999DT00H00M01S") +}; + +declare %test:assertError("FODT0002") +function dt:daytime-mult-overflow() { + xs:dayTimeDuration("P5999999999999999999DT00H00M01S") * 2 +}; + +declare %test:assertError("FODT0002") +function dt:daytime-div-overflow() { + xs:dayTimeDuration("P5999999999999999999DT00H00M01S") div 0.5 +}; + +declare %test:assertError("FODT0002") +function dt:daytime-minus-overflow() { + xs:dayTimeDuration("P5999999999999999999DT00H00M01S") + - xs:dayTimeDuration("-P5999999999999999999DT00H00M01S") +}; + +(: ValidateExpr (XQuery 3.1 section 3.18.1) is rejected with XQST0075 since + : eXist does not implement the Schema Validation Feature. The expressions + : are wrapped in util:eval() so XQST0075 is observed dynamically by the + : test framework rather than at module-compile time. + : Either XQTY0030 or XQST0075 satisfy K-CombinedErrorCodes-9..12. :) +declare %test:assertError("XQST0075") +function dt:validate-default() { + util:eval('validate { 1 }') +}; + +declare %test:assertError("XQST0075") +function dt:validate-lax() { + util:eval('validate lax { 1 }') +}; + +declare %test:assertError("XQST0075") +function dt:validate-strict() { + util:eval('validate strict { 1 }') +}; + +(: Sanity: ordinary date arithmetic continues to work. :) +declare %test:assertEquals("2020-01-02") +function dt:date-plus-day-ok() { + xs:string(xs:date("2020-01-01") + xs:dayTimeDuration("P1D")) +}; + +declare %test:assertEquals("P1DT1S") +function dt:daytime-plus-ok() { + xs:string(xs:dayTimeDuration("PT24H") + xs:dayTimeDuration("PT1S")) +}; + +(: XQST0125: %public/%private annotations are forbidden on inline functions. :) +declare %test:assertError("XQST0125") +function dt:inline-public-annotation() { + util:eval('let $f := %public function($x as xs:integer) as xs:integer { $x + 1 } return $f(1)') +}; + +declare %test:assertError("XQST0125") +function dt:inline-private-annotation() { + util:eval('let $f := %private function($x as xs:integer) as xs:integer { $x + 1 } return $f(1)') +}; diff --git a/exist-core/src/test/xquery/xquery3/deep-equal-options-test.xq b/exist-core/src/test/xquery/xquery3/deep-equal-options-test.xq new file mode 100644 index 00000000000..0d01cba53e4 --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/deep-equal-options-test.xq @@ -0,0 +1,171 @@ +(: + : 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 + :) +xquery version "3.1"; + +module namespace det = "http://exist-db.org/test/deep-equal-options"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +(: === Whitespace options === :) + +declare + %test:assertTrue +function det:whitespace-strip-basic() { + deep-equal(parse-xml(' '), parse-xml(''), map { 'whitespace': 'strip' }) +}; + +declare + %test:assertFalse +function det:whitespace-strip-attr() { + (: attribute values should NOT be affected by whitespace:strip :) + deep-equal(parse-xml(' '), parse-xml(''), map { 'whitespace': 'strip' }) +}; + +declare + %test:assertTrue +function det:whitespace-normalize-basic() { + deep-equal('bedtime', ' bedtime ', map { 'whitespace': 'normalize' }) +}; + +declare + %test:assertTrue +function det:whitespace-normalize-attr() { + (: Attribute values are normalized, whitespace-only text nodes normalize to empty :) + deep-equal( + parse-xml(' '), + parse-xml(''), + map { 'whitespace': 'normalize' }) +}; + +declare + %test:assertFalse +function det:whitespace-normalize-attr-different() { + (: Attribute values differ after normalization :) + deep-equal( + parse-xml(' '), + parse-xml(''), + map { 'whitespace': 'normalize' }) +}; + +(: === Options validation === :) + +declare + %test:assertError("XPTY0004") +function det:options-invalid-key() { + deep-equal(1, 2, map { 'bifurcation': true() }) +}; + +declare + %test:assertTrue +function det:options-valid-booleans() { + deep-equal((), (), map { + 'base-uri': true(), 'comments': true(), 'debug': true(), + 'id-property': true(), 'idrefs-property': true(), + 'in-scope-namespaces': true(), 'namespace-prefixes': true(), 'nilled-property': true(), + 'processing-instructions': true(), 'timezones': true(), 'type-annotations': true(), + 'type-variety': true(), 'typed-values': true() + }) +}; + +(: === Ordered/unordered === :) + +declare + %test:assertTrue +function det:unordered-basic() { + deep-equal((1, 2), (2, 1), map { 'ordered': false() }) +}; + +declare + %test:assertFalse +function det:ordered-basic() { + deep-equal((1, 2), (2, 1), map { 'ordered': true() }) +}; + +(: === Comments option === :) + +declare + %test:assertTrue +function det:comments-default-ignored() { + (: By default comments are ignored :) + deep-equal( + parse-xml(''), + parse-xml('')) +}; + +declare + %test:assertFalse +function det:comments-true-compared() { + (: With comments:true, different comments matter :) + deep-equal( + parse-xml(''), + parse-xml(''), + map { 'comments': true() }) +}; + +(: === Processing instructions === :) + +declare + %test:assertTrue +function det:pi-default-ignored() { + deep-equal( + parse-xml(''), + parse-xml('')) +}; + +declare + %test:assertFalse +function det:pi-true-compared() { + deep-equal( + parse-xml(''), + parse-xml(''), + map { 'processing-instructions': true() }) +}; + +(: === Function item comparison === :) + +declare + %test:assertFalse +function det:function-items-different() { + deep-equal(true#0, false#0) +}; + +declare + %test:assertTrue +function det:function-items-same() { + let $f := true#0 + return deep-equal($f, $f) +}; + +declare + %test:assertTrue +function det:function-items-same-name() { + (: Two separate references to same named function should be equal :) + deep-equal(true#0, true#0) +}; + +declare function det:helper() { 42 }; + +declare + %test:assertTrue +function det:function-items-user-defined-same() { + deep-equal(det:helper#0, det:helper#0) +}; diff --git a/exist-core/src/test/xquery/xquery3/filterExprAM.xql b/exist-core/src/test/xquery/xquery3/filterExprAM.xql new file mode 100644 index 00000000000..6f6b5325a6d --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/filterExprAM.xql @@ -0,0 +1,133 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for XQuery 4.0 FilterExprAM (?[expr]) — array/map filter expression. + :) +module namespace fam = "http://exist-db.org/xquery/test/filter-expr-am"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +(: === Array filtering === :) + +declare + %test:assertEquals(2) +function fam:array-filter-size() { + let $result := [1, 2, 3, 4, 5]?[. > 3] + return array:size($result) +}; + +declare + %test:assertEquals(4, 5) +function fam:array-filter-values() { + let $result := [1, 2, 3, 4, 5]?[. > 3] + return ($result(1), $result(2)) +}; + +declare + %test:assertEquals(0) +function fam:array-filter-none-match() { + let $result := [1, 2, 3]?[. > 10] + return array:size($result) +}; + +declare + %test:assertEquals(3) +function fam:array-filter-all-match() { + let $result := [1, 2, 3]?[. > 0] + return array:size($result) +}; + +declare + %test:assertTrue +function fam:array-filter-returns-array() { + [1, 2, 3]?[. > 1] instance of array(*) +}; + +declare + %test:assertEquals(0) +function fam:array-filter-empty() { + let $result := []?[. > 0] + return array:size($result) +}; + +declare + %test:assertEquals(3) +function fam:array-filter-even() { + array:size([1, 2, 3, 4, 5, 6]?[. mod 2 = 0]) +}; + +declare + %test:assertEquals(3) +function fam:array-filter-strings() { + array:size(["apple", "banana", "avocado", "cherry", "apricot"]?[starts-with(., "a")]) +}; + +(: === Map filtering === :) + +declare + %test:assertTrue +function fam:map-filter-returns-map() { + map { "a": 1, "b": 2, "c": 3 }?[. > 1] instance of map(*) +}; + +declare + %test:assertEquals(2) +function fam:map-filter-size() { + map:size(map { "a": 1, "b": 2, "c": 3 }?[. > 1]) +}; + +declare + %test:assertEquals(0) +function fam:map-filter-empty-result() { + map:size(map { "a": 1, "b": 2 }?[. > 10]) +}; + +declare + %test:assertEquals(2) +function fam:map-filter-all-entries() { + map:size(map { "a": 1, "b": 2 }?[. > 0]) +}; + +(: === Chaining === :) + +declare + %test:assertEquals(3) +function fam:chain-filter-then-size() { + let $data := [10, 20, 30, 40, 50] + return array:size($data?[. >= 30]) +}; + +(: === Type error === :) + +declare + %test:assertError("XPTY0004") +function fam:type-error-on-string() { + "hello"?[. > 0] +}; + +declare + %test:assertError("XPTY0004") +function fam:type-error-on-integer() { + 42?[. > 0] +}; diff --git a/exist-core/src/test/xquery/xquery3/flwor.xql b/exist-core/src/test/xquery/xquery3/flwor.xql index 79dba0c5871..94d342898cb 100644 --- a/exist-core/src/test/xquery/xquery3/flwor.xql +++ b/exist-core/src/test/xquery/xquery3/flwor.xql @@ -186,6 +186,50 @@ function flwor:no-allow-empty($n as xs:integer) { return $x || ":" || $y }; +(: https://github.com/eXist-db/exist/issues/4252 :) +(: When a leading order-by key is the empty sequence, subsequent keys must still be applied. :) +declare + %test:assertEquals("a3", "a4", "b1", "c2") +function flwor:orderby-empty-ordering-spec-1st() { + let $xml := document { } + for $elem in $xml/root/* + order by + (), + $elem/name(), + $elem/@n + return + $elem/name() || $elem/@n +}; + +(: When a middle order-by key is the empty sequence, subsequent keys must still be applied. :) +declare + %test:assertEquals("a3", "a4", "b1", "c2") +function flwor:orderby-empty-ordering-spec-2nd() { + let $xml := document {
} + for $elem in $xml/root/* + order by + $elem/name(), + (), + $elem/@n + return + $elem/name() || $elem/@n +}; + +(: When the trailing order-by key is the empty sequence, earlier keys must still be applied. :) +declare + %test:assertEquals("a3", "a4", "b1", "c2") +function flwor:orderby-empty-ordering-spec-last() { + let $xml := document {
} + for $elem in $xml/root/* + order by + $elem/name(), + $elem/@n, + () + return + $elem/name() || $elem/@n +}; + + (:~ : Type declaration in for-binding should constrain the iteration variable, : not the return type. See https://github.com/eXist-db/exist/issues/3553 @@ -238,46 +282,3 @@ function flwor:for-as-string-binding() { for $x as xs:string in "foo" return true() }; - -(: https://github.com/eXist-db/exist/issues/4252 :) -(: When a leading order-by key is the empty sequence, subsequent keys must still be applied. :) -declare - %test:assertEquals("a3", "a4", "b1", "c2") -function flwor:orderby-empty-ordering-spec-1st() { - let $xml := document { } - for $elem in $xml/root/* - order by - (), - $elem/name(), - $elem/@n - return - $elem/name() || $elem/@n -}; - -(: When a middle order-by key is the empty sequence, subsequent keys must still be applied. :) -declare - %test:assertEquals("a3", "a4", "b1", "c2") -function flwor:orderby-empty-ordering-spec-2nd() { - let $xml := document { } - for $elem in $xml/root/* - order by - $elem/name(), - (), - $elem/@n - return - $elem/name() || $elem/@n -}; - -(: When the trailing order-by key is the empty sequence, earlier keys must still be applied. :) -declare - %test:assertEquals("a3", "a4", "b1", "c2") -function flwor:orderby-empty-ordering-spec-last() { - let $xml := document { } - for $elem in $xml/root/* - order by - $elem/name(), - $elem/@n, - () - return - $elem/name() || $elem/@n -}; diff --git a/exist-core/src/test/xquery/xquery3/fn.xql b/exist-core/src/test/xquery/xquery3/fn.xql index 75b8dcc6e0c..a36ee212561 100644 --- a/exist-core/src/test/xquery/xquery3/fn.xql +++ b/exist-core/src/test/xquery/xquery3/fn.xql @@ -177,3 +177,58 @@ declare function fnt:contains-token-tests-collation($input, $token, $collation) { contains-token($input, $token, $collation) }; + +(: --- fn:reverse lazy view tests --- :) + +declare + %test:assertEquals("3 2 1") +function fnt:reverse-range() { + string-join(for $i in reverse(1 to 3) return string($i), " ") +}; + +declare + %test:assertEquals("") +function fnt:reverse-empty() { + string-join(reverse(()), " ") +}; + +declare + %test:assertEquals("42") +function fnt:reverse-single() { + string(reverse(42)) +}; + +(: reverse(reverse(E)) -> E :) +declare + %test:assertEquals("1 2 3") +function fnt:reverse-reverse-roundtrip() { + string-join(for $i in reverse(reverse(1 to 3)) return string($i), " ") +}; + +(: All-but-last idiom: reverse(tail(reverse($seq))) :) +declare + %test:assertEquals("1 2") +function fnt:reverse-all-but-last() { + string-join(for $i in reverse(tail(reverse(1 to 3))) return string($i), " ") +}; + +(: Large-range reverse should be lazy: only the first item is materialized. :) +declare + %test:assertEquals(1000000000) +function fnt:reverse-large-range-lazy() { + reverse(1 to 1000000000)[1] +}; + +(: General sequence reverse via wrapper. :) +declare + %test:assertEquals("d c b a") +function fnt:reverse-sequence() { + string-join(reverse(("a", "b", "c", "d")), " ") +}; + +(: count() should be O(1) over a reversed range. :) +declare + %test:assertEquals(1000000) +function fnt:reverse-range-count() { + count(reverse(1 to 1000000)) +}; diff --git a/exist-core/src/test/xquery/xquery3/fnCollectionFileUri.xql b/exist-core/src/test/xquery/xquery3/fnCollectionFileUri.xql new file mode 100644 index 00000000000..2e6dfca36a8 --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/fnCollectionFileUri.xql @@ -0,0 +1,36 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for fn:collection() with file: URIs. + :) +module namespace cfu="http://exist-db.org/xquery/test/collection-file-uri"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +(: Non-existing directory should throw FODC0002 :) +declare + %test:assertError("FODC0002") +function cfu:collection-nonexistent-dir() { + collection("file:///nonexistent-dir-xyz-42-does-not-exist") +}; diff --git a/exist-core/src/test/xquery/xquery3/fnHigherOrderFunctions.xql b/exist-core/src/test/xquery/xquery3/fnHigherOrderFunctions.xql index 0cb73348bdb..0e9a681d797 100644 --- a/exist-core/src/test/xquery/xquery3/fnHigherOrderFunctions.xql +++ b/exist-core/src/test/xquery/xquery3/fnHigherOrderFunctions.xql @@ -200,3 +200,55 @@ function hofs:array-for-each-from-array () { )?* }; +(: https://github.com/eXist-db/exist/issues/3754 — HOF parameter types must be checked :) + +declare + %test:assertError("XPTY0004") +function hofs:for-each-wrong-param-type() { + for-each(1, function ($p as xs:string) { $p }) +}; + +declare + %test:assertError("XPTY0004") +function hofs:filter-wrong-param-type() { + filter(1, function ($p as xs:string) as xs:boolean { string-length($p) > 0 }) +}; + +declare + %test:assertError("XPTY0004") +function hofs:fold-left-wrong-param-type() { + fold-left(1, "", function ($acc as xs:string, $item as xs:string) { concat($acc, $item) }) +}; + +declare + %test:assertError("XPTY0004") +function hofs:fold-right-wrong-param-type() { + fold-right(1, "", function ($item as xs:string, $acc as xs:string) { concat($item, $acc) }) +}; + +declare + %test:assertError("XPTY0004") +function hofs:for-each-pair-wrong-param-type() { + for-each-pair(1, 2, function ($a as xs:string, $b as xs:string) { concat($a, $b) }) +}; + +declare + %test:assertError("XPTY0004") +function hofs:apply-wrong-param-type() { + apply(function ($p as xs:string) { $p }, [1]) +}; + +(: Verify that untyped parameters still work :) +declare + %test:assertEquals(1) +function hofs:for-each-untyped-param() { + for-each(1, function ($p) { $p }) +}; + +(: Verify that compatible subtypes work :) +declare + %test:assertEquals(1) +function hofs:for-each-compatible-subtype() { + for-each(1, function ($p as xs:decimal) { $p }) +}; + diff --git a/exist-core/src/test/xquery/xquery3/fnInvisibleXml.xqm b/exist-core/src/test/xquery/xquery3/fnInvisibleXml.xqm new file mode 100644 index 00000000000..f4776038779 --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/fnInvisibleXml.xqm @@ -0,0 +1,100 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for fn:invisible-xml(). + :) +module namespace ixml = "http://exist-db.org/xquery/test/invisible-xml"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +declare variable $ixml:date-grammar := "date = year, -'-', month, -'-', day. +year = digit, digit, digit, digit. +month = digit, digit. +day = digit, digit. +-digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'."; + +declare + %test:assertEquals('20231031') +function ixml:date-parse() { + let $parser := fn:invisible-xml($ixml:date-grammar, map {}) + return $parser("2023-10-31") +}; + +declare + %test:assertEquals('20240115') +function ixml:date-parse-different-input() { + let $parser := fn:invisible-xml($ixml:date-grammar, map {}) + return $parser("2024-01-15") +}; + +declare + %test:assertError("FOIX0001") +function ixml:invalid-grammar() { + fn:invisible-xml("this is not a valid grammar !!!", map {}) +}; + +declare + %test:assertError("FOIX0002") +function ixml:parse-error() { + let $parser := fn:invisible-xml($ixml:date-grammar, map {"fail-on-error": true()}) + return $parser("not-a-date") +}; + +declare + %test:assertEquals('') +function ixml:xml-grammar-element() { + let $grammar := parse-xml("")/* + return invisible-xml($grammar)("") +}; + +declare + %test:assertEquals('20231031') +function ixml:xml-grammar-complex() { + let $xml := concat( + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "") + let $grammar := parse-xml($xml)/* + return invisible-xml($grammar)("2023-10-31") +}; + +declare + %test:assertTrue +function ixml:reuse-parser() { + let $parser := fn:invisible-xml($ixml:date-grammar, map {}) + let $d1 := $parser("2023-10-31") + let $d2 := $parser("2024-01-15") + return $d1/date/year = "2023" and $d2/date/year = "2024" +}; diff --git a/exist-core/src/test/xquery/xquery3/fnLanguage.xqm b/exist-core/src/test/xquery/xquery3/fnLanguage.xqm index 8952c81ae3a..dda6529cc97 100644 --- a/exist-core/src/test/xquery/xquery3/fnLanguage.xqm +++ b/exist-core/src/test/xquery/xquery3/fnLanguage.xqm @@ -33,6 +33,6 @@ declare %test:assertExists function test-sort:default-language() { let $language := fn:default-language() - let $all := ("aa","ab","ae","af","ak","am","an","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de","dv","dz","ee","el","en","eo","es","et","eu","fa","ff","fi","fj","fo","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it","iu","iw","ja","ji","jv","ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky","la","lb","lg","li","ln","lo","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms","mt","my","na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt","qu","rm","rn","ro","ru","rw","sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh","zu") - return index-of($all, $language) + let $languages := ("aa","ab","ae","af","ak","am","an","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de","dv","dz","ee","el","en","eo","es","et","eu","fa","ff","fi","fj","fo","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it","iu","iw","ja","ji","jv","ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky","la","lb","lg","li","ln","lo","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms","mt","my","na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt","qu","rm","rn","ro","ru","rw","sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh","zu") + return index-of($languages, $language) }; diff --git a/exist-core/src/test/xquery/xquery3/fnSerializeCharacterMaps.xqm b/exist-core/src/test/xquery/xquery3/fnSerializeCharacterMaps.xqm index e971e7a5a93..64fd0d5267e 100644 --- a/exist-core/src/test/xquery/xquery3/fnSerializeCharacterMaps.xqm +++ b/exist-core/src/test/xquery/xquery3/fnSerializeCharacterMaps.xqm @@ -59,3 +59,62 @@ function testSerialize:use_character_maps-032-params-as-map() { let $result := serialize($testSerialize:atomic, $params) return contains($result, "foo:a$$name") }; + +(: JSON serialization with use-character-maps :) + +declare + %test:assertEquals('{"name":"hello ©orld"}') +function testSerialize:json_character_map_string() { + let $params := map { + "method": "json", + "use-character-maps": map { "w": "©" } + } + return serialize(map { "name": "hello world" }, $params) +}; + +declare + %test:assertEquals('{"price":"$100"}') +function testSerialize:json_character_map_special() { + (: Map # to $ in JSON string values :) + let $params := map { + "method": "json", + "use-character-maps": map { "#": "$" } + } + return serialize(map { "price": "#100" }, $params) +}; + +declare + %test:assertTrue +function testSerialize:json_character_map_raw_output() { + (: Character map replacements bypass JSON escaping — raw output :) + let $params := map { + "method": "json", + "use-character-maps": map { "*": "" } + } + let $result := serialize(map { "text": "hello *world*" }, $params) + (: The should appear raw, not escaped :) + return contains($result, "") +}; + +declare + %test:assertEquals('"(c) 2024"') +function testSerialize:json_character_map_copyright() { + (: Map © to (c) in JSON output :) + let $params := map { + "method": "json", + "use-character-maps": map { "©": "(c)" } + } + return serialize("© 2024", $params) +}; + +declare + %test:assertEquals('(c) symbol') +function testSerialize:xml_character_map_element_text() { + (: XML character maps in element text :) + let $params := map { + "method": "xml", + "omit-xml-declaration": true(), + "use-character-maps": map { "©": "(c)" } + } + return serialize(© symbol, $params) +}; diff --git a/exist-core/src/test/xquery/xquery3/fnXQuery40.xql b/exist-core/src/test/xquery/xquery3/fnXQuery40.xql new file mode 100644 index 00000000000..c010aeb9b9d --- /dev/null +++ b/exist-core/src/test/xquery/xquery3/fnXQuery40.xql @@ -0,0 +1,1204 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Tests for XQuery 4.0 functions implemented in eXist-db. + :) +module namespace t = "http://exist-db.org/xquery/test/fn-xquery40"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +(: fn:foot :) + +declare + %test:assertEquals(5) +function t:foot-sequence() { + foot(1 to 5) +}; + +declare + %test:assertEmpty +function t:foot-empty() { + foot(()) +}; + +declare + %test:assertEquals("c") +function t:foot-string-sequence() { + foot(("a", "b", "c")) +}; + +(: fn:trunk :) + +declare + %test:assertEquals(1, 2, 3, 4) +function t:trunk-sequence() { + trunk(1 to 5) +}; + +declare + %test:assertEmpty +function t:trunk-empty() { + trunk(()) +}; + +declare + %test:assertEmpty +function t:trunk-single() { + trunk("a") +}; + +declare + %test:assertEquals("a", "b") +function t:trunk-string-sequence() { + trunk(("a", "b", "c")) +}; + +(: fn:identity :) + +declare + %test:assertEquals(0) +function t:identity-zero() { + identity(0) +}; + +declare + %test:assertEmpty +function t:identity-empty() { + identity(()) +}; + +declare + %test:assertEquals(1, 2, 3) +function t:identity-sequence() { + identity((1, 2, 3)) +}; + +(: fn:void :) + +declare + %test:assertEmpty +function t:void-value() { + void(1 to 1000000) +}; + +declare + %test:assertEmpty +function t:void-no-args() { + void() +}; + +(: fn:is-NaN :) + +declare + %test:assertFalse +function t:isNaN-integer() { + is-NaN(23) +}; + +declare + %test:assertFalse +function t:isNaN-string() { + is-NaN("NaN") +}; + +declare + %test:assertTrue +function t:isNaN-number-invalid() { + is-NaN(number("twenty-three")) +}; + +(: fn:characters :) + +declare + %test:assertEquals("T", "h", "e") +function t:characters-basic() { + characters("The") +}; + +declare + %test:assertEmpty +function t:characters-empty-string() { + characters("") +}; + +declare + %test:assertEmpty +function t:characters-empty-sequence() { + characters(()) +}; + +(: fn:replicate :) + +declare + %test:assertEquals(0, 0, 0) +function t:replicate-basic() { + replicate(0, 3) +}; + +declare + %test:assertEmpty +function t:replicate-zero-count() { + replicate("A", 0) +}; + +declare + %test:assertEmpty +function t:replicate-empty-input() { + replicate((), 5) +}; + +(: fn:insert-separator :) + +declare + %test:assertEquals(1, "|", 2, "|", 3) +function t:insertSeparator-basic() { + insert-separator(1 to 3, "|") +}; + +declare + %test:assertEmpty +function t:insertSeparator-empty() { + insert-separator((), "|") +}; + +declare + %test:assertEquals("A") +function t:insertSeparator-single() { + insert-separator("A", "|") +}; + +(: fn:all-equal :) + +declare + %test:assertFalse +function t:allEqual-different() { + all-equal((1, 2, 3)) +}; + +declare + %test:assertTrue +function t:allEqual-same() { + all-equal((1, 1, 1)) +}; + +declare + %test:assertFalse +function t:allEqual-mixed-numeric-types() { + (: XQ4: decimal 1.2 and double 1.2 differ in exact mathematical value :) + all-equal((xs:decimal('1.2'), xs:double('1.2'))) +}; + +declare + %test:assertTrue +function t:allEqual-empty() { + all-equal(()) +}; + +declare + %test:assertTrue +function t:allEqual-single() { + all-equal("one") +}; + +(: fn:all-different :) + +declare + %test:assertTrue +function t:allDifferent-different() { + all-different((1, 2, 3)) +}; + +declare + %test:assertFalse +function t:allDifferent-duplicates() { + all-different((1, 2, 1)) +}; + +declare + %test:assertTrue +function t:allDifferent-empty() { + all-different(()) +}; + +(: fn:items-at :) + +declare + %test:assertEquals(14) +function t:itemsAt-single() { + items-at(11 to 20, 4) +}; + +declare + %test:assertEquals(17, 13) +function t:itemsAt-reorder() { + items-at(11 to 20, (7, 3)) +}; + +declare + %test:assertEmpty +function t:itemsAt-empty-input() { + items-at((), 832) +}; + +(: fn:index-where :) + +declare + %test:assertEquals(2, 3) +function t:indexWhere-basic() { + index-where((0, 4, 9), boolean#1) +}; + +declare + %test:assertEmpty +function t:indexWhere-empty() { + index-where((), boolean#1) +}; + +(: fn:take-while :) + +declare + %test:assertEquals(10, 11, 12) +function t:takeWhile-basic() { + take-while(10 to 20, function($x) { $x le 12 }) +}; + +declare + %test:assertEmpty +function t:takeWhile-empty() { + take-while((), boolean#1) +}; + +(: fn:slice :) + +declare + %test:assertEquals("b", "c", "d") +function t:slice-startEnd() { + let $in := ("a", "b", "c", "d", "e") + return slice($in, 2, 4) +}; + +declare + %test:assertEquals("e") +function t:slice-negative-start() { + let $in := ("a", "b", "c", "d", "e") + return slice($in, -1) +}; + +(: fn:duplicate-values :) + +declare + %test:assertEquals(1) +function t:duplicateValues-basic() { + duplicate-values((1, 2, 3, 1)) +}; + +declare + %test:assertEmpty +function t:duplicateValues-noDups() { + duplicate-values((1, 2, 3)) +}; + +(: fn:hash :) + +declare + %test:assertEquals("900150983CD24FB0D6963F7D28E17F72") +function t:hash-md5() { + string(hash("abc")) +}; + +declare + %test:assertEmpty +function t:hash-empty() { + hash(()) +}; + +(: fn:while-do :) + +declare + %test:assertEquals(16) +function t:whileDo-doubling() { + while-do(1, function($x) { $x lt 10 }, function($x) { $x * 2 }) +}; + +(: fn:do-until :) + +declare + %test:assertEquals(16) +function t:doUntil-doubling() { + do-until(1, function($x) { $x * 2 }, function($x) { $x ge 10 }) +}; + +(: fn:sort-with :) + +declare + %test:assertEquals(1, 1, 3, 4, 5) +function t:sortWith-ascending() { + sort-with((3, 1, 4, 1, 5), function($a, $b) { compare(string($a), string($b)) }) +}; + +(: fn:op :) + +declare + %test:assertEquals(7) +function t:op-add() { + op("+")(3, 4) +}; + +declare + %test:assertTrue +function t:op-lt() { + op("lt")(3, 4) +}; + +declare + %test:assertEquals(7) +function t:op-subtract() { + op("-")(10, 3) +}; + +(: fn:char :) + +declare + %test:assertEquals("A") +function t:char-codepoint() { + char(65) +}; + +declare + %test:assertEquals("&") +function t:char-name() { + char("amp") +}; + +(: fn:atomic-equal :) + +declare + %test:assertTrue +function t:atomicEqual-same() { + atomic-equal(1, 1) +}; + +declare + %test:assertFalse +function t:atomicEqual-different-type() { + atomic-equal("1", 1) +}; + +declare + %test:assertTrue +function t:atomicEqual-nan() { + atomic-equal(number("NaN"), number("NaN")) +}; + +(: fn:expanded-QName :) + +declare + %test:assertEquals("Q{}local") +function t:expandedQName-noNS() { + expanded-QName(QName("", "local")) +}; + +declare + %test:assertEquals("Q{http://example.com}test") +function t:expandedQName-withNS() { + expanded-QName(QName("http://example.com", "test")) +}; + +(: fn:highest / fn:lowest :) + +declare + %test:assertEquals(5) +function t:highest-basic() { + highest((3, 1, 5, 2, 4)) +}; + +declare + %test:assertEquals(1) +function t:lowest-basic() { + lowest((3, 1, 5, 2, 4)) +}; + +(: fn:partition :) + +declare + %test:assertEquals(3) +function t:partition-basic() { + count(partition(1 to 6, function($current, $next, $pos) { $pos mod 2 eq 1 })) +}; + +(: fn:parse-uri :) + +declare + %test:assertEquals("http") +function t:parseUri-scheme() { + parse-uri("http://example.com/path")?scheme +}; + +declare + %test:assertTrue +function t:parseUri-hierarchical() { + parse-uri("http://example.com/path")?hierarchical +}; + +declare + %test:assertEquals("example.com") +function t:parseUri-host() { + parse-uri("http://example.com/path")?host +}; + +declare + %test:assertEquals("/path") +function t:parseUri-path() { + parse-uri("http://example.com/path")?path +}; + +declare + %test:assertFalse +function t:parseUri-opaque() { + parse-uri("mailto:user@example.com")?hierarchical +}; + +(: fn:scan-left :) + +declare + %test:assertEquals(3) +function t:scanLeft-count() { + count(scan-left(1 to 2, 0, function($acc, $item) { $acc + $item })) +}; + +declare + %test:assertEquals(0, 1, 3) +function t:scanLeft-sums() { + for $arr in scan-left(1 to 2, 0, function($acc, $item) { $acc + $item }) + return $arr?1 +}; + +(: fn:scan-right :) + +declare + %test:assertEquals(3) +function t:scanRight-count() { + count(scan-right(1 to 2, 0, function($item, $acc) { $acc + $item })) +}; + +declare + %test:assertEquals(3, 2, 0) +function t:scanRight-sums() { + for $arr in scan-right(1 to 2, 0, function($item, $acc) { $acc + $item }) + return $arr?1 +}; + +(: fn:build-uri :) + +declare + %test:assertEquals("https://qt4cg.org/specifications/index.html") +function t:buildUri-basic() { + build-uri(map { + "scheme": "https", + "host": "qt4cg.org", + "path": "/specifications/index.html" + }) +}; + +(: fn:every :) + +declare + %test:assertTrue +function t:every-all-true() { + every((1, 2, 3), function($x) { $x gt 0 }) +}; + +declare + %test:assertFalse +function t:every-one-false() { + every((1, -1, 3), function($x) { $x gt 0 }) +}; + +declare + %test:assertTrue +function t:every-empty() { + every((), function($x) { $x gt 0 }) +}; + +declare + %test:assertTrue +function t:every-1arg-truthy() { + every((1, true(), "yes")) +}; + +declare + %test:assertFalse +function t:every-1arg-falsy() { + every((1, 0, "yes")) +}; + +(: fn:some :) + +declare + %test:assertTrue +function t:some-one-true() { + some((-1, 0, 3), function($x) { $x gt 0 }) +}; + +declare + %test:assertFalse +function t:some-none-true() { + some((-1, -2, -3), function($x) { $x gt 0 }) +}; + +declare + %test:assertFalse +function t:some-empty() { + some((), function($x) { $x gt 0 }) +}; + +declare + %test:assertTrue +function t:some-1arg-truthy() { + some((0, false(), 1)) +}; + +(: fn:sort-by :) + +declare + %test:assertEquals("a", "bb", "ccc") +function t:sortBy-stringLength() { + sort-by(("ccc", "a", "bb"), map { "key": string-length#1 }) +}; + +declare + %test:assertEquals("ccc", "bb", "a") +function t:sortBy-descending() { + sort-by(("a", "bb", "ccc"), map { "key": string-length#1, "order": "descending" }) +}; + +declare + %test:assertEmpty +function t:sortBy-empty() { + sort-by((), map { "key": string-length#1 }) +}; + +(: fn:contains-subsequence :) + +declare + %test:assertTrue +function t:containsSubseq-present() { + contains-subsequence((1, 2, 3, 4, 5), (2, 3, 4)) +}; + +declare + %test:assertFalse +function t:containsSubseq-absent() { + contains-subsequence((1, 2, 3, 4, 5), (2, 4)) +}; + +declare + %test:assertTrue +function t:containsSubseq-emptySubseq() { + contains-subsequence((1, 2, 3), ()) +}; + +(: fn:starts-with-subsequence :) + +declare + %test:assertTrue +function t:startsWithSubseq-true() { + starts-with-subsequence((1, 2, 3, 4), (1, 2)) +}; + +declare + %test:assertFalse +function t:startsWithSubseq-false() { + starts-with-subsequence((1, 2, 3, 4), (2, 3)) +}; + +declare + %test:assertTrue +function t:startsWithSubseq-empty() { + starts-with-subsequence((1, 2, 3), ()) +}; + +(: fn:ends-with-subsequence :) + +declare + %test:assertTrue +function t:endsWithSubseq-true() { + ends-with-subsequence((1, 2, 3, 4), (3, 4)) +}; + +declare + %test:assertFalse +function t:endsWithSubseq-false() { + ends-with-subsequence((1, 2, 3, 4), (2, 3)) +}; + +(: fn:decode-from-uri :) + +declare + %test:assertEquals("hello world") +function t:decodeFromUri-plus() { + decode-from-uri("hello+world") +}; + +declare + %test:assertEquals("a/b") +function t:decodeFromUri-percent() { + decode-from-uri("a%2Fb") +}; + +declare + %test:assertEquals("") +function t:decodeFromUri-empty() { + decode-from-uri(()) +}; + +(: fn:parse-integer :) + +declare + %test:assertEquals(42) +function t:parseInteger-decimal() { + parse-integer("42") +}; + +declare + %test:assertEquals(255) +function t:parseInteger-hex() { + parse-integer("FF", 16) +}; + +declare + %test:assertEquals(7) +function t:parseInteger-binary() { + parse-integer("111", 2) +}; + +declare + %test:assertEquals(1000) +function t:parseInteger-underscores() { + parse-integer("1_000") +}; + +declare + %test:assertEmpty +function t:parseInteger-empty() { + parse-integer(()) +}; + +(: fn:divide-decimals :) + +declare + %test:assertEquals(3) +function t:divideDecimals-quotient() { + divide-decimals(10, 3)?quotient +}; + +declare + %test:assertEquals(1) +function t:divideDecimals-remainder() { + divide-decimals(10, 3)?remainder +}; + +declare + %test:assertEquals(3.3) +function t:divideDecimals-precision() { + divide-decimals(10, 3, 1)?quotient +}; + +(: fn:distinct-ordered-nodes :) + +declare + %test:assertEquals(3) +function t:distinctOrderedNodes-basic() { + let $doc := + return count(distinct-ordered-nodes(($doc/a, $doc/c, $doc/b, $doc/a))) +}; + +(: fn:siblings :) + +declare + %test:assertEquals(3) +function t:siblings-count() { + let $doc := + return count(siblings($doc/b)) +}; + +declare + %test:assertEmpty +function t:siblings-empty() { + siblings(()) +}; + +(: fn:type-of :) + +declare + %test:assertEquals("xs:integer") +function t:typeOf-integer() { + type-of(42) +}; + +declare + %test:assertEquals("xs:string") +function t:typeOf-string() { + type-of("hello") +}; + +declare + %test:assertEquals("empty-sequence()") +function t:typeOf-empty() { + type-of(()) +}; + +declare + %test:assertEquals("element()") +function t:typeOf-element() { + type-of() +}; + +declare + %test:assertEquals("map(*)") +function t:typeOf-map() { + type-of(map { "a": 1 }) +}; + +(: fn:unix-dateTime :) + +declare + %test:assertEquals("1970-01-01T00:00:00Z") +function t:unixDateTime-epoch() { + string(unix-dateTime(0)) +}; + +declare + %test:assertEquals("1970-01-01T00:00:01Z") +function t:unixDateTime-oneSecond() { + string(unix-dateTime(1000)) +}; + +(: fn:message :) + +declare + %test:assertEmpty +function t:message-basic() { + message("test output") +}; + +declare + %test:assertEmpty +function t:message-withLabel() { + message("test output", "DEBUG") +}; + +(: fn:parse-QName :) + +declare + %test:assertEmpty +function t:parseQName-empty() { + parse-QName(()) +}; + +declare + %test:assertEquals("foo") +function t:parseQName-ncname() { + local-name-from-QName(parse-QName("foo")) +}; + +declare + %test:assertEquals("") +function t:parseQName-ncname-ns() { + namespace-uri-from-QName(parse-QName("foo")) +}; + +declare + %test:assertEquals("local") +function t:parseQName-uriQualified() { + local-name-from-QName(parse-QName("Q{http://example.com}local")) +}; + +declare + %test:assertEquals("http://example.com") +function t:parseQName-uriQualified-ns() { + namespace-uri-from-QName(parse-QName("Q{http://example.com}local")) +}; + +declare + %test:assertEquals("integer") +function t:parseQName-prefixed() { + local-name-from-QName(parse-QName("xs:integer")) +}; + +declare + %test:assertEquals("http://www.w3.org/2001/XMLSchema") +function t:parseQName-prefixed-ns() { + namespace-uri-from-QName(parse-QName("xs:integer")) +}; + +(: fn:atomic-type-annotation :) + +declare + %test:assertTrue +function t:atomicTypeAnnotation-integer-name() { + let $r := atomic-type-annotation(42) + return $r?name eq xs:QName("xs:integer") +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-integer-isSimple() { + atomic-type-annotation(42)?is-simple +}; + +declare + %test:assertEquals("atomic") +function t:atomicTypeAnnotation-integer-variety() { + atomic-type-annotation(42)?variety +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-string-name() { + let $r := atomic-type-annotation("hello") + return $r?name eq xs:QName("xs:string") +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-boolean-name() { + let $r := atomic-type-annotation(true()) + return $r?name eq xs:QName("xs:boolean") +}; + +(: fn:node-type-annotation :) + +declare + %test:assertTrue +function t:nodeTypeAnnotation-element() { + let $r := node-type-annotation() + return $r?name eq xs:QName("xs:untyped") +}; + +declare + %test:assertFalse +function t:nodeTypeAnnotation-element-isSimple() { + node-type-annotation()?is-simple +}; + +declare + %test:assertEquals("mixed") +function t:nodeTypeAnnotation-element-variety() { + node-type-annotation()?variety +}; + +declare + %test:assertTrue +function t:nodeTypeAnnotation-attribute() { + let $r := node-type-annotation(()/@a) + return $r?name eq xs:QName("xs:untypedAtomic") +}; + +declare + %test:assertTrue +function t:nodeTypeAnnotation-attribute-isSimple() { + node-type-annotation(()/@a)?is-simple +}; + +declare + %test:assertEquals("atomic") +function t:nodeTypeAnnotation-attribute-variety() { + node-type-annotation(()/@a)?variety +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-hasBaseType() { + let $r := atomic-type-annotation(true()) + return map:contains($r, "base-type") +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-hasMatches() { + let $r := atomic-type-annotation(true()) + return map:contains($r, "matches") +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-hasConstructor() { + let $r := atomic-type-annotation(true()) + return map:contains($r, "constructor") +}; + +declare + %test:assertTrue +function t:nodeTypeAnnotation-element-hasBaseType() { + let $r := node-type-annotation() + return map:contains($r, "base-type") +}; + +(: fn:atomic-type-annotation — base-type function :) + +declare + %test:assertTrue +function t:atomicTypeAnnotation-baseType-returns-parent() { + let $r := atomic-type-annotation(42) + let $base := $r?base-type() + return $base?name eq xs:QName("xs:decimal") +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-baseType-chain-to-anyType() { + (: Walk the chain: integer → decimal → anyAtomicType → anySimpleType → anyType :) + let $r := atomic-type-annotation(42) + let $decimal := $r?base-type() + let $atomic := $decimal?base-type() + let $simple := $atomic?base-type() + let $anyType := $simple?base-type() + return $anyType?name eq xs:QName("xs:anyType") +}; + +(: fn:atomic-type-annotation — primitive-type function :) + +declare + %test:assertTrue +function t:atomicTypeAnnotation-primitiveType-integer() { + (: primitive type of xs:integer is xs:decimal :) + let $r := atomic-type-annotation(42) + let $prim := $r?primitive-type() + return $prim?name eq xs:QName("xs:decimal") +}; + +declare + %test:assertTrue +function t:atomicTypeAnnotation-primitiveType-string-self() { + (: primitive type of xs:string is xs:string itself :) + let $r := atomic-type-annotation("hello") + let $prim := $r?primitive-type() + return $prim?name eq xs:QName("xs:string") +}; + +(: fn:atomic-type-annotation — matches function :) + +declare + %test:assertTrue +function t:atomicTypeAnnotation-matches-true() { + let $r := atomic-type-annotation(42) + return $r?matches(xs:integer(99)) +}; + +declare + %test:assertFalse +function t:atomicTypeAnnotation-matches-false() { + let $r := atomic-type-annotation(42) + return $r?matches("not an integer") +}; + +(: fn:atomic-type-annotation — constructor function :) + +declare + %test:assertEquals(42) +function t:atomicTypeAnnotation-constructor-cast() { + let $r := atomic-type-annotation(42) + return $r?constructor("42") +}; + +(: fn:atomic-type-annotation — variety for special types :) + +declare + %test:assertTrue +function t:atomicTypeAnnotation-anySimpleType-noVariety() { + (: xs:anySimpleType has no variety :) + let $r := atomic-type-annotation(42) + (: Walk to anySimpleType: integer → decimal → anyAtomicType → anySimpleType :) + let $simple := $r?base-type()?base-type()?base-type() + return not(map:contains($simple, "variety")) +}; + +declare + %test:assertEquals("mixed") +function t:nodeTypeAnnotation-anyType-variety() { + (: Walk: untyped → anyType :) + let $r := node-type-annotation() + let $anyType := $r?base-type() + return $anyType?variety +}; + +(: fn:civil-timezone :) + +declare + %test:assertEquals("PT1H") +function t:civilTimezone-paris-winter() { + string(civil-timezone(xs:dateTime("2024-11-05T12:00:00"), "Europe/Paris")) +}; + +declare + %test:assertEquals("PT2H") +function t:civilTimezone-paris-summer() { + string(civil-timezone(xs:dateTime("2024-05-05T12:00:00"), "Europe/Paris")) +}; + +declare + %test:assertEquals("PT5H30M") +function t:civilTimezone-india() { + string(civil-timezone(xs:dateTime("2024-06-15T12:00:00"), "Asia/Kolkata")) +}; + +declare + %test:assertEquals("-PT5H") +function t:civilTimezone-peru() { + string(civil-timezone(xs:dateTime("2024-06-15T12:00:00"), "America/Lima")) +}; + +declare + %test:assertError("FODT0004") +function t:civilTimezone-unknown-place() { + civil-timezone(xs:dateTime("2024-06-15T12:00:00"), "North/Pole") +}; + +(: fn:format-number with XQ4 map options and char:rendition :) + +declare + %test:assertEquals("12,56") +function t:formatNumber-map-decimalRendition() { + (: decimal-separator marker is . for picture, rendered as , in output :) + format-number(12.56, '#0.##', map { + 'decimal-separator': '.:,' + }) +}; + +declare + %test:assertEquals("1 234.56") +function t:formatNumber-map-groupingRendition() { + (: grouping-separator marker is , for picture, but space is rendered :) + format-number(1234.56, '#,##0.##', map { + 'grouping-separator': ',: ' + }) +}; + +declare + %test:assertEquals("14pc") +function t:formatNumber-map-percentRendition() { + (: percent marker is % in picture, but "pc" is rendered :) + format-number(0.14, '01%', map { + 'percent': '%:pc' + }) +}; + +declare + %test:assertEquals("1,234.56") +function t:formatNumber-map-noRendition() { + (: No rendition — marker used directly in output :) + format-number(1234.56, '#,##0.##', map { + 'decimal-separator': '.', + 'grouping-separator': ',' + }) +}; + +declare + %test:assertEquals("1.5EXP2") +function t:formatNumber-map-exponentRendition() { + (: exponent-separator marker is e for picture, "EXP" is rendered :) + format-number(150, '0.0e0', map { + 'exponent-separator': 'e:EXP' + }) +}; + +(: fn:function-annotations :) + +declare %private function t:annotated-fn() { 42 }; + +declare + %test:assertTrue +function t:functionAnnotations-private() { + (: %private annotation should be returned :) + let $anns := function-annotations(t:annotated-fn#0) + return some $m in $anns satisfies + map:keys($m) = xs:QName("fn:private") +}; + +declare + %test:assertTrue +function t:functionAnnotations-builtin-empty() { + (: Built-in functions have no annotations :) + empty(function-annotations(true#0)) +}; + +declare + %test:assertTrue +function t:functionAnnotations-returns-maps() { + (: Each annotation is a single-entry map :) + let $anns := function-annotations(t:annotated-fn#0) + return every $m in $anns satisfies ($m instance of map(*) and map:size($m) = 1) +}; + +(: fn:function-identity :) + +declare + %test:assertTrue +function t:functionIdentity-same() { + (: Same named function returns same identity :) + function-identity(true#0) eq function-identity(true#0) +}; + +declare + %test:assertFalse +function t:functionIdentity-different() { + (: Different functions return different identities :) + function-identity(true#0) eq function-identity(false#0) +}; + +declare + %test:assertTrue +function t:functionIdentity-isString() { + (: Returns a string :) + function-identity(true#0) instance of xs:string +}; + +declare + %test:assertTrue +function t:functionIdentity-map-self-equal() { + (: Same map variable has same identity :) + let $m := map { "a": 1 } + return function-identity($m) eq function-identity($m) +}; + +declare + %test:assertTrue +function t:functionIdentity-array-self-equal() { + (: Same array variable has same identity :) + let $a := [ 1, 2, 3 ] + return function-identity($a) eq function-identity($a) +}; + +(: ==================== fn:load-xquery-module content option ==================== :) + +declare + %test:assertEquals("world") +function t:load-xquery-module-content() { + let $src := "module namespace m = 'http://example.com/test'; + declare function m:hello() as xs:string { 'world' };" + let $mod := fn:load-xquery-module('http://example.com/test', map { 'content': $src }) + let $hello := $mod?functions(QName('http://example.com/test', 'hello')) + return $hello?0() +}; diff --git a/exist-core/src/test/xquery/xquery3/json-to-xml.xql b/exist-core/src/test/xquery/xquery3/json-to-xml.xql index c4249436e8f..e9756ba396d 100644 --- a/exist-core/src/test/xquery/xquery3/json-to-xml.xql +++ b/exist-core/src/test/xquery/xquery3/json-to-xml.xql @@ -43,7 +43,7 @@ function jsonxml:json-to-xml-2() { }; declare - %test:pending("not implemented yet") + %test:pending("escape option not yet implemented — Jackson does not expose raw string values") %test:assertEquals("\\%") function jsonxml:json-to-xml-3() { json-to-xml('{"x": "\\", "y": "\u0025"}', map{'escape': true()}) @@ -58,6 +58,7 @@ function jsonxml:json-to-xml-error-1() { declare + %test:pending("FOJS0005 validation for invalid option values not yet implemented") %test:assertError("err:FOJS0005") function jsonxml:json-to-xml-error-2() { json-to-xml('{"x": "\\", "y": "\u0025"}', map{'escape': 'invalid-value'}) diff --git a/exist-core/src/test/xquery/xquery3/replace.xqm b/exist-core/src/test/xquery/xquery3/replace.xqm index 05943a5437d..fbf323790c1 100644 --- a/exist-core/src/test/xquery/xquery3/replace.xqm +++ b/exist-core/src/test/xquery/xquery3/replace.xqm @@ -19,7 +19,7 @@ : License along with this library; if not, write to the Free Software : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA :) -xquery version "3.1"; +xquery version "4.0"; module namespace rt="http://exist-db.org/xquery/test/replace"; @@ -27,14 +27,15 @@ declare namespace test="http://exist-db.org/xquery/xqsuite"; declare %test:args("") - %test:assertError("err:FORX0003") + %test:assertEquals("") %test:args(".?") - %test:assertError("err:FORX0003") + %test:assertEquals("") %test:args(".*") - %test:assertError("err:FORX0003") + %test:assertEquals("") %test:args("(.*)") - %test:assertError("err:FORX0003") -function rt:empty-match-fails($p as xs:string) { + %test:assertEquals("") +function rt:empty-match-allowed($p as xs:string) { + (: XQ4: empty-matching regex no longer raises FORX0003 :) replace("",$p,"") }; @@ -78,3 +79,29 @@ declare function rt:invalid-flag($flag as xs:string) { replace("",".+","", $flag) }; + +(: XQ4: function replacement :) +declare + %test:assertEquals("C") +function rt:function-replacement-basic() { + replace("c", "c", function($k, $g) { upper-case($k) }) +}; + +declare + %test:assertEquals("") +function rt:function-replacement-empty() { + replace("b", "b", function($k, $g) { }) +}; + +declare + %test:assertEquals("ddee") +function rt:function-replacement-duplicate() { + replace("de", ".", function($k, $g) { $k || $k }) +}; + +(: XQ4: empty replacement arg :) +declare + %test:assertEquals("") +function rt:empty-replacement-arg() { + replace("abc", "abc", ()) +}; diff --git a/exist-core/src/test/xquery/xquery3/serialize.xql b/exist-core/src/test/xquery/xquery3/serialize.xql index bea438d425f..c5cd35d1f6c 100644 --- a/exist-core/src/test/xquery/xquery3/serialize.xql +++ b/exist-core/src/test/xquery/xquery3/serialize.xql @@ -847,7 +847,7 @@ function ser:serialize-xml-134() { }; declare - %test:assertEquals(' ') + %test:assertEquals('') function ser:serialize-html-5-boolean-attribute-names() { {``[There were < 10 green bottles]``} + let $expected := "There were " || codepoints-to-string(38) || "lt; 10 green bottles" + return string($result) eq $expected +}; + +declare + %test:assertTrue +function t:string-constructor-030-charref-in-element() { + let $result := {``[There were < 10 green bottles]``} + let $expected := "There were " || codepoints-to-string(38) || "#x003C; 10 green bottles" + return string($result) eq $expected +}; + +declare + %test:assertTrue +function t:string-constructor-031-entity-with-interp() { + let $result := {``[There were < `{10}` green bottles]``} + let $expected := "There were " || codepoints-to-string(38) || "lt; 10 green bottles" + return string($result) eq $expected +}; + +declare + %test:assertTrue +function t:string-constructor-032-charref-with-interp() { + let $result := {``[There were < `{10}` green bottles]``} + let $expected := "There were " || codepoints-to-string(38) || "#x003C; 10 green bottles" + return string($result) eq $expected +}; + +declare + %test:assertTrue +function t:string-constructor-033-entity-after-interp() { + let $result := {``[There were `{10}` < green bottles]``} + let $expected := "There were 10 " || codepoints-to-string(38) || "lt; green bottles" + return string($result) eq $expected +}; + +declare + %test:assertTrue +function t:string-constructor-034-charref-after-interp() { + let $result := {``[There were `{10}` < green bottles]``} + let $expected := "There were 10 " || codepoints-to-string(38) || "#x003C; green bottles" + return string($result) eq $expected +}; + +(: Focus functions :) + +declare + %test:assertEquals(2, 3, 4, 5, 6) +function t:focusFunction-filter() { + (1 to 5) ! (fn { . + 1 })(.) +}; + +declare + %test:assertEquals(2, 4, 6) +function t:focusFunction-forEach() { + for-each((1, 2, 3), fn { . * 2 }) +}; + +declare + %test:assertEquals(6) +function t:focusFunction-functionKeyword() { + (function { . * 3 })(2) +}; + +declare + %test:assertEquals(3) +function t:focusFunction-withHigherOrder() { + let $add1 := fn { . + 1 } + return $add1(2) +}; + +(: Keyword arguments — parser syntax test only, full dispatch tested with XQ4 functions :) + +(: XQ4 annotation literals :) + +declare + %test:assertTrue +function t:annotation-true-false-literals() { + (: XQ4 allows true(), false(), and negative literals in annotations :) + let $f := %Q{http://example.com/test}check(true(), false(), -42) function($x) { $x } + return $f(true()) +}; + +declare + %test:assertEquals(1) +function t:annotation-negative-numeric-literal() { + let $f := %Q{http://example.com/test}range(-1, -3.14, -2.5e3) fn { . } + return $f(1) +}; + +(: === try/catch/finally (XQ4) === :) + +declare + %test:assertEquals(42) +function t:try-finally-basic() { + (: XQ4: try with finally clause, no catch :) + try { 42 } finally {} +}; + +declare + %test:assertEquals(42) +function t:try-finally-empty-seq() { + try { 42 } finally {()} +}; + +declare + %test:assertEquals(42) +function t:try-catch-finally() { + (: XQ4: try with catch and finally :) + try { 42 } catch * { 97 } finally {()} +}; + +declare + %test:assertError("XQTY0153") +function t:try-finally-nonempty-error() { + (: XQ4: finally must produce empty sequence :) + try { 42 } finally { 99 } +}; + +declare + %test:assertError("FOAR0001") +function t:try-finally-error-replaces-result() { + (: XQ4: error in finally replaces try result :) + try { 42 } finally { 10 div 0 } +}; + +declare + %test:assertEquals(99) +function t:try-catch-finally-catch-fires() { + (: XQ4: catch fires, finally runs with empty result :) + try { 10 div 0 } + catch err:FOAR0001 { 99 } + finally {()} +}; + +declare + %test:assertError("FOAR0001") +function t:try-finally-error-not-caught-by-same() { + (: XQ4: finally error is NOT caught by same try/catch :) + try { 42 } + catch err:FOAR0001 { "wrong" } + finally { 10 div 0 } +}; + +(: ======================== XQ4 switch expression ======================== :) + +declare + %test:assertEquals("Oink") +function t:switch-sequence-case-operand() { + (: XQ4: case operand may be a sequence; match if any item equals comparand :) + let $in := 2 + return switch ($in) + case 1 return "Moo" + case 5 return "Meow" + case 3 return "Quack" + case ($in to 4) return "Oink" + default return "Baa" +}; + +declare + %test:assertEquals("Oink") +function t:switch-braced-syntax() { + (: XQ4: braced switch syntax :) + let $in := 2 + return switch ($in) { + case 1 return "Moo" + case 5 return "Meow" + case ($in to 4) return "Oink" + default return "Baa" + } +}; + +declare + %test:assertEquals("Meow") +function t:switch-boolean-mode-no-braces() { + (: XQ4: omitted comparand, without braces :) + let $animal := "Cat" + return switch () + case $animal eq "Cow" return "Moo" + case $animal eq "Cat" return "Meow" + case $animal eq "Duck" return "Quack" + default return "What's that odd noise?" +}; + +(: XQ4 focus constructors :) + +declare + %test:assertEquals(42) +function t:focus-constructor-integer() { + (: XQ4 focus constructor: xs:type() with context item :) + '42' ! xs:integer() +}; + +declare + %test:assertEquals(3.14) +function t:focus-constructor-double() { + '3.14' ! xs:double() +}; + +declare + %test:assertEquals("Woof") +function t:switch-nan-matches-nan() { + (: XQ3: NaN matches NaN in switch - reproduces switch-011 :) + let $in := xs:double('NaN') + return + { switch ($in) + case 42 return "Moo" + case 42 return "Meow" + case 42e0 return "Quack" + case "42e0" return "Oink" + case xs:float('NaN') return "Woof" + default return "Expletive deleted" }/string() +}; diff --git a/exist-core/src/test/xquery/xquery4/jnode-standalone-test.xq b/exist-core/src/test/xquery/xquery4/jnode-standalone-test.xq new file mode 100644 index 00000000000..fe0f8dd4378 --- /dev/null +++ b/exist-core/src/test/xquery/xquery4/jnode-standalone-test.xq @@ -0,0 +1,32 @@ +(: + : 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 + :) +xquery version "3.1"; + +(: Quick standalone test to verify fn:jtree exists :) +let $jnode := fn:jtree(map { "name": "Alice", "age": 30 }) +return + + {exists($jnode)} + {fn:jvalue($jnode) instance of map(*)} + {empty(fn:jkey($jnode))} + {fn:jposition($jnode)} + diff --git a/exist-core/src/test/xquery/xquery4/jnode.xql b/exist-core/src/test/xquery/xquery4/jnode.xql new file mode 100644 index 00000000000..ffc766c9833 --- /dev/null +++ b/exist-core/src/test/xquery/xquery4/jnode.xql @@ -0,0 +1,153 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : XQSuite tests for XQuery 4.0 JNode support. + :) +module namespace jn-test = "http://exist-db.org/xquery/test/jnode"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +(: ==================== fn:jtree ==================== :) + +declare + %test:assertTrue +function jn-test:jtree-map-returns-jnode() { + let $jnode := fn:jtree(map { "a": 1 }) + return exists($jnode) +}; + +declare + %test:assertTrue +function jn-test:jtree-array-returns-jnode() { + let $jnode := fn:jtree(array { 1, 2, 3 }) + return exists($jnode) +}; + +declare + %test:assertTrue +function jn-test:jtree-string-returns-jnode() { + let $jnode := fn:jtree("hello") + return exists($jnode) +}; + +(: ==================== fn:jvalue ==================== :) + +declare + %test:assertEquals("Alice") +function jn-test:jvalue-map-lookup() { + let $jnode := fn:jtree(map { "name": "Alice" }) + let $val := fn:jvalue($jnode) + return $val("name") +}; + +declare + %test:assertEquals(3) +function jn-test:jvalue-array-size() { + let $jnode := fn:jtree(array { 1, 2, 3 }) + let $val := fn:jvalue($jnode) + return array:size($val) +}; + +declare + %test:assertEquals("hello") +function jn-test:jvalue-string() { + let $jnode := fn:jtree("hello") + return fn:jvalue($jnode) +}; + +(: ==================== fn:jkey ==================== :) + +declare + %test:assertEmpty +function jn-test:jkey-root-is-empty() { + let $jnode := fn:jtree(map { "name": "Alice" }) + return fn:jkey($jnode) +}; + +(: ==================== fn:jposition ==================== :) + +declare + %test:assertEquals(0) +function jn-test:jposition-root-is-zero() { + let $jnode := fn:jtree(map { "name": "Alice" }) + return fn:jposition($jnode) +}; + +(: ==================== Round-trip ==================== :) + +declare + %test:assertTrue +function jn-test:jtree-preserves-map() { + let $original := map { "x": 1, "y": 2 } + let $jnode := fn:jtree($original) + let $val := fn:jvalue($jnode) + return $val instance of map(*) +}; + +declare + %test:assertTrue +function jn-test:jtree-preserves-array() { + let $original := array { "a", "b", "c" } + let $jnode := fn:jtree($original) + let $val := fn:jvalue($jnode) + return $val instance of array(*) +}; + +(: ==================== Map/Array Predicate Tests ==================== :) + +declare + %test:assertEquals(1) +function jn-test:map-navigation-with-predicate() { + map{"asdf":1}/asdf[. <= 1] +}; + +declare + %test:assertEquals(2) +function jn-test:map-wildcard-with-predicate() { + map{"a":1,"b":2}/*[. > 1] +}; + +declare + %test:assertEquals(2, 3) +function jn-test:array-wildcard-with-predicate() { + [1,2,3]/*[. > 1] +}; + +declare + %test:assertEquals(1) +function jn-test:parenthesized-map-with-predicate() { + (map{"asdf":1}/asdf)[. <= 1] +}; + +declare + %test:assertEquals(1) +function jn-test:nested-map-with-predicate() { + map{"x": map{"y": 1}}/x/y[. = 1] +}; + +declare + %test:assertEquals(2, 3) +function jn-test:map-sequence-value-with-predicate() { + map{"a": (1,2,3)}/a[. > 1] +}; diff --git a/exist-core/src/test/xquery/xquery4/xq4-axes.xql b/exist-core/src/test/xquery/xquery4/xq4-axes.xql new file mode 100644 index 00000000000..fc58918fcde --- /dev/null +++ b/exist-core/src/test/xquery/xquery4/xq4-axes.xql @@ -0,0 +1,104 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ Tests for XQuery 4.0 combined axes :) +module namespace axes="http://exist-db.org/xquery/test/xq4-axes"; + +declare namespace test="http://exist-db.org/xquery/xqsuite"; + +declare variable $axes:DATA := + + + + + + + + + + ; + +(: === following-or-self axis === :) + +declare + %test:assertEquals("3", "4", "5", "6") +function axes:following-or-self-from-c() { + $axes:DATA//c/following-or-self::*/@id/string() +}; + +declare + %test:assertEquals("1", "2", "3", "4", "5", "6") +function axes:following-or-self-from-root-child() { + $axes:DATA/a/following-or-self::*/@id/string() +}; + +(: === following-sibling-or-self axis === :) + +declare + %test:assertEquals("3", "5") +function axes:following-sibling-or-self-from-c() { + $axes:DATA/a/c/following-sibling-or-self::*/@id/string() +}; + +declare + %test:assertEquals("2", "3", "5") +function axes:following-sibling-or-self-from-b() { + $axes:DATA/a/b/following-sibling-or-self::*/@id/string() +}; + +(: === preceding-or-self axis === :) + +declare + %test:assertEquals("1", "2", "3") +function axes:preceding-or-self-from-c() { + $axes:DATA//c/preceding-or-self::*/@id/string() +}; + +(: === preceding-sibling-or-self axis === :) + +declare + %test:assertEquals("2", "3") +function axes:preceding-sibling-or-self-from-c() { + $axes:DATA/a/c/preceding-sibling-or-self::*/@id/string() +}; + +declare + %test:assertEquals("5") +function axes:preceding-sibling-or-self-from-e-only() { + (: e has no preceding siblings that are elements named 'e' :) + $axes:DATA/a/e/preceding-sibling-or-self::e/@id/string() +}; + +(: === node test with new axes === :) + +declare + %test:assertExists +function axes:following-or-self-node-test() { + $axes:DATA//c/following-or-self::node() +}; + +declare + %test:assertEquals("3") +function axes:following-sibling-or-self-name-test() { + $axes:DATA/a/c/following-sibling-or-self::c/@id/string() +}; diff --git a/exist-distribution/pom.xml b/exist-distribution/pom.xml index 2369cda5832..d6a7b9d742e 100644 --- a/exist-distribution/pom.xml +++ b/exist-distribution/pom.xml @@ -64,6 +64,12 @@ ${project.version} runtime + + ${project.groupId} + exist-services + ${project.version} + runtime + ${project.groupId} exist-start @@ -188,6 +194,12 @@ ${project.version} runtime + + ${project.groupId} + exist-expath-binary + ${project.version} + runtime + ${project.groupId} exist-counter @@ -213,6 +225,12 @@ ${project.version} runtime + + ${project.groupId} + exist-expath-file + ${project.version} + runtime + ${project.groupId} exist-file @@ -312,14 +330,14 @@ runtime - org.eclipse.jetty.websocket - websocket-jetty-server + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jetty-server runtime - org.eclipse.jetty.websocket - websocket-jakarta-server + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jakarta-server runtime @@ -354,12 +372,12 @@ src/main/config/** src/main/scripts/codesign-jansi-mac.sh src/main/xslt/configure_10_0.dtd - src/main/xslt/web-app_5_0.xsd + src/main/xslt/web-app_6_0.xsd src/main/xslt/jakartaee_web_services_client_2_0.xsd - src/main/xslt/jsp_3_0.xsd + src/main/xslt/jsp_3_1.xsd src/main/xslt/xml.xsd - src/main/xslt/web-common_5_0.xsd - src/main/xslt/jakartaee_9.xsd + src/main/xslt/web-common_6_0.xsd + src/main/xslt/jakartaee_10.xsd diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml index 6a9937c0e03..ed641ed5952 100644 --- a/exist-distribution/src/main/config/conf.xml +++ b/exist-distribution/src/main/config/conf.xml @@ -216,6 +216,12 @@ EXQuery RESTXQ trigger to load the RESTXQ Registry at startup time --> + + + @@ -996,9 +1004,7 @@ - @@ -1048,11 +1054,13 @@ + + @@ -1165,9 +1173,7 @@ omits model-path, the registry supplies path and dimension. Add models here to use custom or additional embedding models; they appear in vector:models(). --> - diff --git a/exist-distribution/src/main/config/log4j2-container.xml b/exist-distribution/src/main/config/log4j2-container.xml new file mode 100644 index 00000000000..273a571aef3 --- /dev/null +++ b/exist-distribution/src/main/config/log4j2-container.xml @@ -0,0 +1,69 @@ + + + + + + %d{ISO8601} [%t] %-5p %logger{36} - %m%n + ${env:EXIST_REQUEST_LOG_LEVEL:-info} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/exist-distribution/src/main/config/log4j2.xml b/exist-distribution/src/main/config/log4j2.xml index 4210dbe85a3..98c90e99260 100644 --- a/exist-distribution/src/main/config/log4j2.xml +++ b/exist-distribution/src/main/config/log4j2.xml @@ -3,10 +3,14 @@ ${log4j:configParentLocation}/../logs + ${logs}/jetty 10MB 14 %d{yyyyMMddHHmmss} %d [%t] %-5p (%F [%M]:%L) - %m %n + %d{ISO8601} [%t] %-5p %logger{36} - %m%n + + ${env:EXIST_REQUEST_LOG_LEVEL:-info} @@ -119,12 +123,22 @@ - + + - + + + + + + + + + + @@ -213,8 +227,15 @@ - - + + + + + + + diff --git a/exist-distribution/src/main/xslt/catalog.xml b/exist-distribution/src/main/xslt/catalog.xml index e61c7b15f71..fe614df02e1 100644 --- a/exist-distribution/src/main/xslt/catalog.xml +++ b/exist-distribution/src/main/xslt/catalog.xml @@ -24,6 +24,6 @@ - + diff --git a/exist-distribution/src/main/xslt/jakartaee_9.xsd b/exist-distribution/src/main/xslt/jakartaee_10.xsd similarity index 86% rename from exist-distribution/src/main/xslt/jakartaee_9.xsd rename to exist-distribution/src/main/xslt/jakartaee_10.xsd index 16136f9ff39..ca995082568 100644 --- a/exist-distribution/src/main/xslt/jakartaee_9.xsd +++ b/exist-distribution/src/main/xslt/jakartaee_10.xsd @@ -4,11 +4,11 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified" - version="8"> + version="10"> - Copyright (c) 2009, 2020 Oracle and/or its affiliates. All rights reserved. + Copyright (c) 2009, 2021 Oracle and/or its affiliates. All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse Public License v. 2.0, which is available at @@ -49,7 +49,7 @@ + schemaLocation="https://www.w3.org/2001/xml.xsd"/> @@ -156,6 +156,22 @@ type="jakartaee:administered-objectType" minOccurs="0" maxOccurs="unbounded"/> + + + + @@ -420,6 +436,129 @@ + + + + + + + Configuration of a ContextService. + + + + + + + + + Description of this ContextService. + + + + + + + + + JNDI name of the ContextService instance being defined. + The JNDI name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + + + + + + + + + Types of context to clear whenever a thread runs a + contextual task or action. The thread's previous context + is restored afterward. Context types that are defined by + the Jakarta EE Concurrency specification include: + Application, Security, Transaction, + and Remaining, which means all available thread context + types that are not specified elsewhere. You can also specify + vendor-specific context types. Absent other configuration, + cleared context defaults to Transaction. You can specify + a single cleared element with no value to indicate an + empty list of context types to clear. If neither + propagated nor unchanged specify (or default to) Remaining, + then Remaining is automatically appended to the list of + cleared context types. + + + + + + + + + Types of context to capture from the requesting thread + and propagate to a thread that runs a contextual task + or action. The captured context is re-established + when threads run the contextual task or action, + with the respective thread's previous context being + restored afterward. Context types that are defined by + the Jakarta EE Concurrency specification include: + Application, Security, + and Remaining, which means all available thread context + types that are not specified elsewhere. You can also specify + vendor-specific context types. Absent other configuration, + propagated context defaults to Remaining. You can specify + a single propagated element with no value to indicate that + no context types should be propagated. + + + + + + + + + Types of context that are left alone when a thread runs a + contextual task or action. Context types that are defined + by the Jakarta EE Concurrency specification include: + Application, Security, Transaction, + and Remaining, which means all available thread context + types that are not specified elsewhere. You can also specify + vendor-specific context types. Absent other configuration, + unchanged context defaults to empty. You can specify + a single unchanged element with no value to indicate that + no context types should be left unchanged. + + + + + + + + + Vendor-specific property. + + + + + + + + + @@ -1857,6 +1996,296 @@ + + + + + + + Configuration of a ManagedExecutorService. + + + + + + + + + Description of this ManagedExecutorService. + + + + + + + + + JNDI name of the ManagedExecutorService instance being defined. + The JNDI name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + + + + + + + + + Refers to the name of a ContextServiceDefinition or + context-service deployment descriptor element, + which determines how context is applied to tasks and actions + that run on this executor. + The name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + In the absence of a configured value, + java:comp/DefaultContextService is used. + + + + + + + + + Upper bound on contextual tasks and actions that this executor + will simultaneously execute asynchronously. This constraint does + not apply to tasks and actions that the executor runs inline, + such as when a thread requests CompletableFuture.join and the + action runs inline if it has not yet started. + The default is unbounded, although still subject to + resource constraints of the system. + + + + + + + + + The amount of time in milliseconds that a task or action + can execute before it is considered hung. + + + + + + + + + Vendor-specific property. + + + + + + + + + + + + + + + + Configuration of a ManagedScheduledExecutorService. + + + + + + + + + Description of this ManagedScheduledExecutorService. + + + + + + + + + JNDI name of the ManagedScheduledExecutorService instance + being defined. + The JNDI name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + + + + + + + + + Refers to the name of a ContextServiceDefinition or + context-service deployment descriptor element, + which determines how context is applied to tasks and actions + that run on this executor. + The name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + In the absence of a configured value, + java:comp/DefaultContextService is used. + + + + + + + + + Upper bound on contextual tasks and actions that this executor + will simultaneously execute asynchronously. This constraint does + not apply to tasks and actions that the executor runs inline, + such as when a thread requests CompletableFuture.join and the + action runs inline if it has not yet started. This constraint also + does not apply to tasks that are scheduled via the schedule methods. + The default is unbounded, although still subject to + resource constraints of the system. + + + + + + + + + The amount of time in milliseconds that a task or action + can execute before it is considered hung. + + + + + + + + + Vendor-specific property. + + + + + + + + + + + + + + + + Configuration of a ManagedThreadFactory. + + + + + + + + + Description of this ManagedThreadFactory. + + + + + + + + + JNDI name of the ManagedThreadFactory instance being defined. + The JNDI name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + + + + + + + + + Refers to the name of a ContextServiceDefinition or + context-service deployment descriptor element, + which determines how context is applied to threads + from this thread factory. + The name must be in a valid Jakarta EE namespace, + such as java:comp, java:module, java:app, or java:global. + In the absence of a configured value, + java:comp/DefaultContextService is used. + + + + + + + + + Priority for threads created by this thread factory. + The default is 5 (java.lang.Thread.NORM_PRIORITY). + + + + + + + + + Vendor-specific property. + + + + + + + + + @@ -2093,6 +2522,25 @@ + + + + + + + Specifies a thread priority value in the range of + 1 (java.lang.Thread.MIN_PRIORITY) to 10 (java.lang.Thread.MAX_PRIORITY). + + + + + + + + + + + diff --git a/exist-distribution/src/main/xslt/jetty-deploy.xslt b/exist-distribution/src/main/xslt/jetty-deploy.xslt index 3e13367023b..0e01b5f44a2 100644 --- a/exist-distribution/src/main/xslt/jetty-deploy.xslt +++ b/exist-distribution/src/main/xslt/jetty-deploy.xslt @@ -31,9 +31,12 @@ /etc/jetty/webdefault.xml - + /etc/ + + /etc/jetty/webapps/portal + diff --git a/exist-distribution/src/main/xslt/jsp_3_0.xsd b/exist-distribution/src/main/xslt/jsp_3_1.xsd similarity index 94% rename from exist-distribution/src/main/xslt/jsp_3_0.xsd rename to exist-distribution/src/main/xslt/jsp_3_1.xsd index bb059b06616..e3c2e461c1c 100644 --- a/exist-distribution/src/main/xslt/jsp_3_0.xsd +++ b/exist-distribution/src/main/xslt/jsp_3_1.xsd @@ -5,11 +5,11 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified" - version="3.0"> + version="3.1"> - Copyright (c) 2009, 2020 Oracle and/or its affiliates. All rights reserved. + Copyright (c) 2009, 2021 Oracle and/or its affiliates. All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse Public License v. 2.0, which is available at @@ -29,12 +29,12 @@ - This is the XML Schema for the JSP 3.0 deployment descriptor - types. The JSP 3.0 schema contains all the special + This is the XML Schema for the JSP 3.1 deployment descriptor + types. The JSP 3.1 schema contains all the special structures and datatypes that are necessary to use JSP files from a web application. - The contents of this schema is used by the web-common_5_0.xsd + The contents of this schema is used by the web-common_6_0.xsd file to define JSP specific content. @@ -58,7 +58,7 @@ - + @@ -152,6 +152,19 @@ + + + + + Can be used to easily set the errorOnELNotFound + property of a group of JSP pages. By default, this + property is false. + + + + diff --git a/exist-distribution/src/main/xslt/web-app_5_0.xsd b/exist-distribution/src/main/xslt/web-app_6_0.xsd similarity index 97% rename from exist-distribution/src/main/xslt/web-app_5_0.xsd rename to exist-distribution/src/main/xslt/web-app_6_0.xsd index 55d11dbdc4a..a2683739084 100644 --- a/exist-distribution/src/main/xslt/web-app_5_0.xsd +++ b/exist-distribution/src/main/xslt/web-app_6_0.xsd @@ -5,11 +5,11 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified" - version="5.0"> + version="6.0"> - Copyright (c) 2009, 2020 Oracle and/or its affiliates. All rights reserved. + Copyright (c) 2009, 2021 Oracle and/or its affiliates. All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse Public License v. 2.0, which is available at @@ -29,7 +29,7 @@ + version="6.0"> ... @@ -51,7 +51,7 @@ the schema using the xsi:schemaLocation attribute for Jakarta EE namespace with the following location: - https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd + https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd ]]> @@ -75,7 +75,7 @@ - + diff --git a/exist-distribution/src/main/xslt/web-common_5_0.xsd b/exist-distribution/src/main/xslt/web-common_6_0.xsd similarity index 95% rename from exist-distribution/src/main/xslt/web-common_5_0.xsd rename to exist-distribution/src/main/xslt/web-common_6_0.xsd index ee12bea82e7..20b3132cff5 100644 --- a/exist-distribution/src/main/xslt/web-common_5_0.xsd +++ b/exist-distribution/src/main/xslt/web-common_6_0.xsd @@ -5,11 +5,11 @@ xmlns:xsd="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified" - version="5.0"> + version="6.0"> - Copyright (c) 2009, 2020 Oracle and/or its affiliates. All rights reserved. + Copyright (c) 2009, 2021 Oracle and/or its affiliates. All rights reserved. This program and the accompanying materials are made available under the terms of the Eclipse Public License v. 2.0, which is available at @@ -29,7 +29,7 @@ + version="6.0"> ... @@ -51,7 +51,7 @@ the schema using the xsi:schemaLocation attribute for Jakarta EE namespace with the following location: - https://jakarta.ee/xml/ns/jakartaee/web-common_5_0.xsd + https://jakarta.ee/xml/ns/jakartaee/web-common_6_0.xsd ]]> @@ -75,9 +75,9 @@ - + - + @@ -165,6 +165,50 @@ + + + + + + + This type is a general type that can be used to declare + attribute/value lists. + + + + + + + + + + The attribute-name element contains the name of an + attribute. + + + + + + + + + The attribute-value element contains the value of a + attribute. + + + + + + + + + @@ -985,6 +1029,19 @@ + + + + + The attribute-param element contains a name/value pair to + be added as an attribute to every session cookie. + + + + @@ -1181,7 +1238,7 @@ - + diff --git a/exist-docker/src/main/resources-filtered/Dockerfile b/exist-docker/src/main/resources-filtered/Dockerfile index fe7ef162e4e..a5b87491743 100644 --- a/exist-docker/src/main/resources-filtered/Dockerfile +++ b/exist-docker/src/main/resources-filtered/Dockerfile @@ -57,7 +57,7 @@ ENV JAVA_TOOL_OPTIONS="\ -Djava.awt.headless=true \ -Dorg.exist.db-connection.cacheSize=${CACHE_MEM:-256}M \ -Dorg.exist.db-connection.pool.max=${MAX_BROKER:-20} \ - -Dlog4j.configurationFile=/exist/etc/log4j2.xml \ + -Dlog4j.configurationFile=/exist/etc/log4j2-container.xml \ -Dexist.home=/exist \ -Dexist.configurationFile=/exist/etc/conf.xml \ -Djetty.home=/exist \ diff --git a/exist-docker/src/main/resources-filtered/Dockerfile-DEBUG b/exist-docker/src/main/resources-filtered/Dockerfile-DEBUG index e58f8baad8f..c8e1c679a19 100644 --- a/exist-docker/src/main/resources-filtered/Dockerfile-DEBUG +++ b/exist-docker/src/main/resources-filtered/Dockerfile-DEBUG @@ -62,7 +62,7 @@ ENV JAVA_TOOL_OPTIONS="\ -Djava.awt.headless=true \ -Dorg.exist.db-connection.cacheSize=${CACHE_MEM:-256}M \ -Dorg.exist.db-connection.pool.max=${MAX_BROKER:-20} \ - -Dlog4j.configurationFile=/exist/etc/log4j2.xml \ + -Dlog4j.configurationFile=/exist/etc/log4j2-container.xml \ -Dexist.home=/exist \ -Dexist.configurationFile=/exist/etc/conf.xml \ -Djetty.home=/exist \ diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-annotations.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-annotations.xml index 94c9786921e..c2d64377967 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-annotations.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-annotations.xml @@ -5,16 +5,8 @@ - + - - + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml index 9897e17b70e..8e2edeb9596 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-deploy.xml @@ -2,61 +2,49 @@ - - - - - + + - - - - - - - - org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern - .*/[^/]*servlet-api-[^/]*\.jar$|.*/jakarta.servlet.jsp.jstl-.*\.jar$|.*/org.apache.taglibs.taglibs-standard-impl-.*\.jar$ - + - - - - - - - /etc/ - /etc/webdefault.xml - - - - - - + + + + + /exist + /../../../webapp/ + /etc/webdefault.xml + + + + + Test JAAS Realm + JAASLoginService - - - - - + + + org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern + .*/[^/]*servlet-api-[^/]*\.jar$|.*/jakarta.servlet.jsp.jstl-.*\.jar$|.*/org.apache.taglibs.taglibs-standard-impl-.*\.jar$|.*/content/.*\.jar$ + + + + + + + + + + / + /../../../exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/ + /etc/webdefault.xml + + + + + + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-gzip.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-gzip.xml index 9a0632df3d7..f95e3dfb3c9 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-gzip.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-gzip.xml @@ -14,46 +14,11 @@ - - - - - - - - diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-jmx.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-jmx.xml index 24c0e2031d4..d37744ee954 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-jmx.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-jmx.xml @@ -22,11 +22,4 @@ - - - - - - - diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-logging.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-logging.xml deleted file mode 100644 index 620f17ea09c..00000000000 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-logging.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - jetty.logging.dir - jetty.logs - /yyyy_mm_dd.stderrout.log - - - - - - - - - - - - - Redirecting stderr/stdout to - - - - - - - - diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-requestlog.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-requestlog.xml index 70b8ee19d34..b23e2df069f 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-requestlog.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-requestlog.xml @@ -3,14 +3,22 @@ + + + + + - - - - - + + + + org.eclipse.jetty.server.RequestLog + + + + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-ssl.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-ssl.xml index 447a15a21b6..bfc2dd1cf4f 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-ssl.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty-ssl.xml @@ -49,10 +49,10 @@ - - - - + + + + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty.xml index 6c67a0a9882..b4518e3d416 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/jetty.xml @@ -24,7 +24,7 @@ - + @@ -73,9 +73,9 @@ - + - + @@ -90,8 +90,8 @@ - - + + @@ -100,7 +100,7 @@ - + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml index 05432c62cfc..5c6439e3b2d 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-deploy.xml @@ -2,61 +2,35 @@ - - - - - + + - - - - - - - - org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern - .*/[^/]*servlet-api-[^/]*\.jar$|.*/jakarta.servlet.jsp.jstl-.*\.jar$|.*/org.apache.taglibs.taglibs-standard-impl-.*\.jar$ - - - - - - - - - /etc/ - /etc/webdefault.xml - - - - - - + + + + + / + /../../../standalone-webapp/ + /etc/webdefault.xml + + + + + Test JAAS Realm + JAASLoginService - - - - - + + + org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern + .*/[^/]*servlet-api-[^/]*\.jar$|.*/jakarta.servlet.jsp.jstl-.*\.jar$|.*/org.apache.taglibs.taglibs-standard-impl-.*\.jar$ + + + + + + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-ssl.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-ssl.xml index bcb1448f465..b04b50206ce 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-ssl.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty-ssl.xml @@ -49,10 +49,10 @@ - - - - + + + + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty.xml index ce3b380b7e7..40a8c4454dd 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-jetty.xml @@ -24,7 +24,7 @@ - + @@ -73,9 +73,9 @@ - + - + @@ -90,8 +90,8 @@ - - + + @@ -100,7 +100,7 @@ - + diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-webapps/exist-webapp-context.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-webapps/exist-webapp-context.xml index 05ef5bcda5c..ed0aaa0bde1 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-webapps/exist-webapp-context.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone-webapps/exist-webapp-context.xml @@ -7,9 +7,9 @@ /../../../standalone-webapp/ /etc/webdefault.xml - + - + Test JAAS Realm JAASLoginService diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone.enabled-jetty-configs b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone.enabled-jetty-configs index bd2a4da7c7c..a4b80eb8e3f 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone.enabled-jetty-configs +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standalone.enabled-jetty-configs @@ -8,8 +8,9 @@ -### Jetty log redirection -# jetty-logging.xml # enable to redirect stdout and stderr to jetty.home/logs +### Logging +# Jetty internal logging and request logs are routed through SLF4J/log4j2. +# See etc/log4j2.xml (loggers: org.eclipse.jetty, org.eclipse.jetty.server.RequestLog). ### Main Server Config @@ -41,9 +42,9 @@ jetty-ssl-context.xml jetty-https.xml -### Webapp deployment -standalone-jetty-deploy.xml +### Annotation support +jetty-annotations.xml -### Annotation support -jetty-annotations.xml +### Webapp deployment +standalone-jetty-deploy.xml diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standard.enabled-jetty-configs b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standard.enabled-jetty-configs index 4a64fb0402b..a39f9e912fa 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standard.enabled-jetty-configs +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/standard.enabled-jetty-configs @@ -8,8 +8,9 @@ -### Jetty log redirection -# jetty-logging.xml # enable to redirect stdout and stderr to jetty.home/logs +### Logging +# Jetty internal logging and request logs are routed through SLF4J/log4j2. +# See etc/log4j2.xml (loggers: org.eclipse.jetty, org.eclipse.jetty.server.RequestLog). ### Main Server Config diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/exist-webapp-context.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/exist-webapp-context.xml index 73c5b7e633a..6fc2206fb17 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/exist-webapp-context.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/exist-webapp-context.xml @@ -7,9 +7,9 @@ /../../../webapp/ /etc/webdefault.xml - + - + Test JAAS Realm JAASLoginService diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml index cdb3382bbba..dd4688fe4f6 100755 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/jetty-web.xml @@ -1,6 +1,6 @@ - + / /etc/webdefault.xml diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/web.xml index aee0c88368f..3ec6b4e59a7 100755 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/web.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/WEB-INF/web.xml @@ -2,9 +2,9 @@ + version="6.0"> eXist-db portal Provides an entry point for the / root context diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/favicon.ico b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/favicon.ico new file mode 100644 index 00000000000..0fbccf9377d Binary files /dev/null and b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal/favicon.ico differ diff --git a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webdefault.xml b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webdefault.xml index a3653bda8f8..d01a7abf169 100644 --- a/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webdefault.xml +++ b/exist-jetty-config/src/main/resources/org/exist/jetty/etc/webdefault.xml @@ -2,9 +2,9 @@ + version="6.0"> @@ -30,19 +30,14 @@ - - - - - org.eclipse.jetty.servlet.listener.ELContextCleaner - - + + - + - org.eclipse.jetty.servlet.listener.IntrospectorCleaner + org.eclipse.jetty.ee10.servlet.listener.IntrospectorCleaner @@ -159,7 +154,7 @@ default - org.eclipse.jetty.servlet.DefaultServlet + org.eclipse.jetty.ee10.servlet.DefaultServlet acceptRanges true diff --git a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml index cffb8bf1e86..7b4b3f839d4 100644 --- a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml +++ b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml @@ -18,6 +18,9 @@ + + + diff --git a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml index 9b075a19685..dd33e81a13b 100644 --- a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml +++ b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml @@ -9,9 +9,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml index 4bb83a9f548..e80a2f5db18 100644 --- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml +++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml @@ -28,6 +28,12 @@ --> + + + + + + diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml index dc9885872ca..828eac6c7a3 100644 --- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml +++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml @@ -8,9 +8,9 @@ + version="6.0"> eXist Open Source Native XML Database eXist XML Database @@ -179,6 +179,38 @@ 4 + + + PackageManagementServlet + org.exist.http.servlets.PackageManagementServlet + + use-default-user + false + + + 268435456 + 268435456 + + 3 + + + + + OpenApiServlet + org.exist.http.openapi.OpenApiServlet + + use-default-user + false + + 3 + + diff --git a/exist-jetty-config/src/main/resources/webapp/favicon.ico b/exist-jetty-config/src/main/resources/webapp/favicon.ico new file mode 100644 index 00000000000..0fbccf9377d Binary files /dev/null and b/exist-jetty-config/src/main/resources/webapp/favicon.ico differ diff --git a/exist-parent/pom.xml b/exist-parent/pom.xml index 92793b0cc0e..71cfe0dd57e 100644 --- a/exist-parent/pom.xml +++ b/exist-parent/pom.xml @@ -102,7 +102,7 @@ info@exist-db.org - 1.10.17 + 1.10.16 4.5.14 4.4.16 5.0.0 @@ -115,13 +115,14 @@ 4.0.5 4.0.2 2.0.3 - 11.0.25 + 12.0.16 2.25.4 - 10.4.0 + 10.3.0 1.8.1.3 - 1.8.1.3-jakarta5 + 1.8.1.3-jakarta-ee10 2.1.3 - 9.9.1-8 + 1.4.16 + 12.5 6.0.21 2.11.0 4.13.2 @@ -157,7 +158,7 @@ jakarta.servlet jakarta.servlet-api - 5.0.0 + 6.0.0 @@ -321,85 +322,91 @@ ${apache.httpcomponents.version} + org.eclipse.jetty - jetty-annotations + jetty-server ${jetty.version} - - - org.eclipse.jetty.toolchain - jetty-jakarta-servlet-api - - org.eclipse.jetty - jetty-deploy + jetty-xml ${jetty.version} org.eclipse.jetty - jetty-jmx + jetty-util ${jetty.version} org.eclipse.jetty - jetty-jndi + jetty-jmx ${jetty.version} org.eclipse.jetty - jetty-plus + jetty-security ${jetty.version} org.eclipse.jetty - jetty-server + jetty-http ${jetty.version} - - - org.eclipse.jetty.toolchain - jetty-jakarta-servlet-api - - + - org.eclipse.jetty - jetty-servlet + org.eclipse.jetty.ee10 + jetty-ee10-annotations ${jetty.version} org.eclipse.jetty - jetty-util + jetty-deploy ${jetty.version} - org.eclipse.jetty - jetty-webapp + org.eclipse.jetty.ee10 + jetty-ee10-jndi ${jetty.version} - org.eclipse.jetty.websocket - websocket-jetty-server + org.eclipse.jetty.ee10 + jetty-ee10-plus ${jetty.version} - - - org.eclipse.jetty.toolchain - jetty-jakarta-servlet-api - - - - org.eclipse.jetty.websocket - websocket-jakarta-server + org.eclipse.jetty.ee10 + jetty-ee10-servlet ${jetty.version} - org.eclipse.jetty - jetty-xml + org.eclipse.jetty.ee10 + jetty-ee10-webapp + ${jetty.version} + + + + + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jetty-server + ${jetty.version} + + + + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jakarta-server ${jetty.version} + + jakarta.websocket + jakarta.websocket-client-api + 2.1.0 + + + jakarta.websocket + jakarta.websocket-api + 2.1.0 + net.sf.xmldb-org @@ -492,11 +499,18 @@ - org.exist-db.thirdparty.com.ettrema + com.evolvedbinary.thirdparty.com.ettrema milton-servlet ${milton.servlet.version} + + + org.exist-db.thirdparty.org.apache.jackrabbit + jackrabbit-webdav + 2.22.3-jakarta-ee10 + + it.unimi.dsi fastutil @@ -597,6 +611,20 @@ ${objenesis.version} test + + + + de.bottlecaps + markup-blitz + 1.10 + + + + + nu.validator + htmlparser + ${htmlparser.version} + @@ -622,7 +650,7 @@ org.owasp dependency-check-maven - 12.2.1 + 12.2.0 ${user.home}/.dependency-check-data ${env.NVD_API_KEY} diff --git a/exist-services/pom.xml b/exist-services/pom.xml new file mode 100644 index 00000000000..7c57ba550ce --- /dev/null +++ b/exist-services/pom.xml @@ -0,0 +1,81 @@ + + + + 4.0.0 + + + org.exist-db + exist-parent + 7.0.0-SNAPSHOT + ../exist-parent + + + exist-services + jar + + eXist-db Platform Services + + Built-in HTTP services for the eXist-db platform: package management REST API + and OpenAPI-based routing. These services are compiled into the platform and + require no XAR package installation. + + + + + org.exist-db + exist-core + ${project.version} + + + + jakarta.servlet + jakarta.servlet-api + + + + com.fasterxml.jackson.core + jackson-core + 2.21.2 + + + + org.expath.packaging + pkg-java + + + + com.google.code.findbugs + jsr305 + + + + + org.apache.logging.log4j + log4j-api + + + diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiServiceRegistry.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServiceRegistry.java new file mode 100644 index 00000000000..5241c7466e1 --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServiceRegistry.java @@ -0,0 +1,246 @@ +/* + * 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.http.openapi; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.annotation.Nullable; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * In-memory registry of OpenAPI routes discovered from api.json and controller.json + * files stored in the database. Routes are scoped by collection path. + *

+ * Thread-safe: uses ConcurrentHashMap for route storage. + *

+ */ +public class OpenApiServiceRegistry { + + private static final Logger LOG = LogManager.getLogger(OpenApiServiceRegistry.class); + + private static volatile OpenApiServiceRegistry instance; + + /** All registered routes, keyed by collection path */ + private final ConcurrentHashMap> routesByCollection = new ConcurrentHashMap<>(); + + public static OpenApiServiceRegistry getInstance() { + if (instance == null) { + synchronized (OpenApiServiceRegistry.class) { + if (instance == null) { + instance = new OpenApiServiceRegistry(); + } + } + } + return instance; + } + + /** + * Register routes from an OpenAPI spec for a given collection. + * + * @param collectionPath the database collection path (e.g., "/db/apps/myapp") + * @param routes the routes parsed from the spec + */ + public void registerRoutes(final String collectionPath, final List routes) { + routesByCollection.put(collectionPath, new ArrayList<>(routes)); + LOG.info("Registered {} OpenAPI routes for collection {}", routes.size(), collectionPath); + } + + /** + * Deregister all routes for a collection. + */ + public void deregisterRoutes(final String collectionPath) { + final List removed = routesByCollection.remove(collectionPath); + if (removed != null) { + LOG.info("Deregistered {} OpenAPI routes for collection {}", removed.size(), collectionPath); + } + } + + /** + * Find a matching route for the given HTTP method and path. + * The path should be relative to /exist/apps/ (e.g., "myapp/api/users/123"). + * + * @param method HTTP method (GET, POST, etc.) + * @param path request path relative to /exist/apps/ + * @return matched route with extracted path parameters, or null if no match + */ + @Nullable + public RouteMatch findRoute(final String method, final String path) { + // Extract app name from path (first segment) + final int slashIdx = path.indexOf('/'); + final String appName = slashIdx > 0 ? path.substring(0, slashIdx) : path; + final String remainingPath = slashIdx > 0 ? path.substring(slashIdx) : "/"; + + // Search for matching collection + for (final Map.Entry> entry : routesByCollection.entrySet()) { + final String collectionPath = entry.getKey(); + // Match by app name (last segment of collection path) + final String collAppName = collectionPath.substring(collectionPath.lastIndexOf('/') + 1); + if (!collAppName.equals(appName)) { + continue; + } + + // Search routes in this collection + RouteMatch bestMatch = null; + for (final Route route : entry.getValue()) { + if (!route.method().equalsIgnoreCase(method)) { + continue; + } + final Map params = matchPath(route.pathPattern(), route.pathRegex(), remainingPath); + if (params != null) { + final RouteMatch match = new RouteMatch(route, collectionPath, params); + // Prefer more specific routes (longer pattern without variables) + if (bestMatch == null || match.specificity() > bestMatch.specificity()) { + bestMatch = match; + } + } + } + if (bestMatch != null) { + return bestMatch; + } + } + return null; + } + + /** + * Check if any routes are registered. + */ + public boolean hasRoutes() { + return !routesByCollection.isEmpty(); + } + + /** + * Get all registered collection paths. + */ + public Set getRegisteredCollections() { + return routesByCollection.keySet(); + } + + // --- Path matching --- + + /** + * Match a request path against a route's compiled regex. + * Returns parameter map if matched, null otherwise. + */ + @Nullable + private Map matchPath(final String pattern, final Pattern regex, final String path) { + final Matcher matcher = regex.matcher(path); + if (!matcher.matches()) { + return null; + } + final Map params = new LinkedHashMap<>(); + // Extract named groups from the pattern + final List paramNames = extractParamNames(pattern); + for (int i = 0; i < paramNames.size(); i++) { + if (i + 1 <= matcher.groupCount()) { + params.put(paramNames.get(i), matcher.group(i + 1)); + } + } + return params; + } + + // --- Static helpers for route compilation --- + + /** + * Compile an OpenAPI path pattern (e.g., "/users/{id}") into a regex. + */ + public static Pattern compilePathPattern(final String pattern) { + final StringBuilder regex = new StringBuilder("^"); + int i = 0; + while (i < pattern.length()) { + final char c = pattern.charAt(i); + if (c == '{') { + final int end = pattern.indexOf('}', i); + if (end > 0) { + regex.append("([^/]+)"); + i = end + 1; + continue; + } + } + // Escape regex special characters + if (".+*?^$[]()\\|".indexOf(c) >= 0) { + regex.append('\\'); + } + regex.append(c); + i++; + } + regex.append("$"); + return Pattern.compile(regex.toString()); + } + + /** + * Extract parameter names from an OpenAPI path pattern. + */ + public static List extractParamNames(final String pattern) { + final List names = new ArrayList<>(); + int i = 0; + while (i < pattern.length()) { + if (pattern.charAt(i) == '{') { + final int end = pattern.indexOf('}', i); + if (end > 0) { + names.add(pattern.substring(i + 1, end)); + i = end + 1; + continue; + } + } + i++; + } + return names; + } + + // --- Inner types --- + + /** + * A registered route from an OpenAPI spec. + */ + public record Route( + String method, // HTTP method (GET, POST, etc.) + String pathPattern, // original pattern, e.g. "/api/users/{id}" + Pattern pathRegex, // compiled regex + String operationId, // e.g. "users:get" + Map spec // the full operation spec from api.json + ) { + /** + * Specificity score: how specific is this route? Higher = more specific. + * Literal segments score higher than parameterized ones. + */ + public int specificity() { + return pathPattern.replaceAll("\\{[^}]+}", "").length(); + } + } + + /** + * A matched route with extracted path parameters. + */ + public record RouteMatch( + Route route, + String collectionPath, + Map pathParams + ) { + public int specificity() { + return route.specificity(); + } + } +} diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiServlet.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServlet.java new file mode 100644 index 00000000000..d85bc40acda --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServlet.java @@ -0,0 +1,382 @@ +/* + * 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.http.openapi; + +import com.fasterxml.jackson.core.JsonGenerator; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.http.servlets.AbstractExistHttpServlet; +import org.exist.security.Subject; +import org.exist.source.StringSource; +import org.exist.storage.DBBroker; +import org.exist.storage.XQueryPool; +import org.exist.util.serializer.XQuerySerializer; +import org.exist.xmldb.XmldbURI; +import org.exist.xquery.*; +import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.value.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Built-in OpenAPI routing servlet. When a collection contains a {@code controller.json} + * that declares OpenAPI spec files, this servlet handles routing requests to XQuery + * handler functions based on the OpenAPI spec's operationId mapping. + *

+ * This replaces the Roaster XQuery library for apps that opt in via controller.json. + * Apps without controller.json continue to use XQueryURLRewrite + controller.xql. + *

+ *

+ * For a request to {@code /exist/apps/myapp/api/users/123}: + *

    + *
  1. Extract app name "myapp" and remaining path "/api/users/123"
  2. + *
  3. Look up matching route in OpenApiServiceRegistry
  4. + *
  5. If found: build request map, compile handler XQuery, execute, serialize result
  6. + *
  7. If not found: return false to let the filter chain continue (falls through to XQueryURLRewrite)
  8. + *
+ *

+ */ +public class OpenApiServlet extends AbstractExistHttpServlet { + + private static final Logger LOG = LogManager.getLogger(OpenApiServlet.class); + + @Override + public Logger getLog() { + return LOG; + } + + @Override + protected void service(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final OpenApiServiceRegistry registry = OpenApiServiceRegistry.getInstance(); + if (!registry.hasRoutes()) { + // No routes registered, pass through + super.service(request, response); + return; + } + + // Extract the path relative to /exist/openapi/ + final String requestURI = request.getRequestURI(); + final String openApiPrefix = request.getContextPath() + "/openapi/"; + if (!requestURI.startsWith(openApiPrefix)) { + super.service(request, response); + return; + } + final String relativePath = requestURI.substring(openApiPrefix.length()); + final String method = request.getMethod(); + + // Handle CORS preflight + if ("OPTIONS".equalsIgnoreCase(method)) { + handleOptions(request, response); + return; + } + + // Try to match a route + final OpenApiServiceRegistry.RouteMatch match = registry.findRoute(method, relativePath); + if (match == null) { + // No matching route — fall through to default handler (XQueryURLRewrite) + super.service(request, response); + return; + } + + // Authenticate + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + + // Dispatch to XQuery handler + try (final DBBroker broker = getPool().get(Optional.of(user))) { + dispatchToHandler(broker, request, response, match); + } catch (final EXistException e) { + LOG.error("Database error dispatching OpenAPI request", e); + writeJsonError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + } catch (final XPathException e) { + LOG.error("XQuery error dispatching OpenAPI request", e); + writeJsonError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); + } catch (final org.exist.security.PermissionDeniedException e) { + LOG.error("Permission denied dispatching OpenAPI request", e); + writeJsonError(response, HttpServletResponse.SC_FORBIDDEN, e.getMessage()); + } + } + + /** + * Dispatch a matched route to its XQuery handler function. + */ + private void dispatchToHandler(final DBBroker broker, final HttpServletRequest request, + final HttpServletResponse response, + final OpenApiServiceRegistry.RouteMatch match) + throws XPathException, IOException, EXistException, org.exist.security.PermissionDeniedException { + + final OpenApiServiceRegistry.Route route = match.route(); + final String operationId = route.operationId(); + final String collectionPath = match.collectionPath(); + + // Build the $request map (compatible with Roaster's format) + final MapType requestMap = buildRequestMap(broker, request, match); + + // Build XQuery that resolves the operationId and calls the function + // The operationId is expected to be in prefix:local-name format + final String xquery = buildHandlerXQuery(operationId, collectionPath); + + // Compile and execute + final XQuery xqueryService = broker.getBrokerPool().getXQueryService(); + final XQueryContext context = new XQueryContext(broker.getBrokerPool()); + context.setModuleLoadPath(XmldbURI.EMBEDDED_SERVER_URI_PREFIX + collectionPath + "/modules"); + + // Declare the $request external variable + context.declareVariable("request", requestMap); + + final StringSource source = new StringSource(xquery); + final CompiledXQuery compiled; + try { + compiled = xqueryService.compile(context, source); + } catch (final IOException e) { + throw new XPathException((Expression) null, "Failed to compile handler for " + operationId + ": " + e.getMessage()); + } + + final Properties outputProperties = new Properties(); + final Sequence result; + try { + result = xqueryService.execute(broker, compiled, null, outputProperties); + } finally { + context.runCleanupTasks(); + } + + // Serialize the result + serializeResult(broker, response, result, route, outputProperties); + } + + /** + * Build the XQuery source that resolves an operationId to a function and calls it. + * The operationId format is "prefix:local-name" (e.g., "hello:greet"). + * The handler module is expected to be in the collection's modules/ directory. + */ + private String buildHandlerXQuery(final String operationId, final String collectionPath) { + // Parse prefix:local-name + final int colonIdx = operationId.indexOf(':'); + if (colonIdx < 0) { + // No prefix — treat as a local function name + return "declare variable $request external;\n" + + "let $fn := function-lookup(xs:QName('" + operationId + "'), 1)\n" + + "return if (exists($fn)) then $fn($request) else error(xs:QName('err:HANDLER'), 'Handler not found: " + operationId + "')"; + } + + final String prefix = operationId.substring(0, colonIdx); + final String localName = operationId.substring(colonIdx + 1); + + // Scan for XQuery modules in the collection to find the right namespace + // For the PoC, we use a convention: the module file is named {prefix}.xqm + // and stored in the modules/ subdirectory + return "xquery version \"3.1\";\n" + + "import module namespace " + prefix + " = \"http://exist-db.org/apps/" + prefix + "\" " + + "at \"" + prefix + ".xqm\";\n" + + "declare variable $request external;\n" + + prefix + ":" + localName + "($request)"; + } + + /** + * Build the request map passed to handler functions. + * Shape is compatible with Roaster's $request map. + */ + private MapType buildRequestMap(final DBBroker broker, final HttpServletRequest request, + final OpenApiServiceRegistry.RouteMatch match) + throws XPathException { + + final XQueryContext tempContext = new XQueryContext(broker.getBrokerPool()); + final MapType requestMap = new MapType(tempContext); + + // Method + requestMap.add(new StringValue("method"), new StringValue(request.getMethod().toLowerCase())); + + // Path + requestMap.add(new StringValue("path"), new StringValue(request.getRequestURI())); + + // Pattern + requestMap.add(new StringValue("pattern"), new StringValue(match.route().pathPattern())); + + // Path parameters + final MapType paramsMap = new MapType(tempContext); + for (final Map.Entry param : match.pathParams().entrySet()) { + paramsMap.add(new StringValue(param.getKey()), new StringValue(param.getValue())); + } + // Also add query parameters + for (final Map.Entry param : request.getParameterMap().entrySet()) { + if (param.getValue().length > 0) { + paramsMap.add(new StringValue(param.getKey()), new StringValue(param.getValue()[0])); + } + } + requestMap.add(new StringValue("parameters"), paramsMap); + + // Request body (for POST/PUT/PATCH) + final String contentType = request.getContentType(); + if (contentType != null && request.getContentLength() > 0) { + try { + final String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (contentType.contains("json")) { + // Store raw JSON string — handler can parse with parse-json() + requestMap.add(new StringValue("body"), new StringValue(body)); + } else { + requestMap.add(new StringValue("body"), new StringValue(body)); + } + } catch (final IOException e) { + LOG.debug("Failed to read request body", e); + } + } + + return requestMap; + } + + /** + * Serialize the XQuery result to the HTTP response. + */ + private void serializeResult(final DBBroker broker, final HttpServletResponse response, + final Sequence result, + final OpenApiServiceRegistry.Route route, + final Properties outputProperties) throws IOException { + + if (result.isEmpty()) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + return; + } + + // Check if the result is a Roaster-style response map (with "code" and/or "body" keys) + if (result.getItemCount() == 1 && result.itemAt(0).getType() == Type.MAP_ITEM) { + try { + final MapType responseMap = (MapType) result.itemAt(0); + // Only treat as response map if it has "code" or "body" keys + final Sequence codeSeq = responseMap.get(new StringValue("code")); + final Sequence bodySeq = responseMap.get(new StringValue("body")); + if ((codeSeq != null && !codeSeq.isEmpty()) || (bodySeq != null && !bodySeq.isEmpty())) { + serializeResponseMap(broker, response, responseMap, outputProperties); + return; + } + } catch (final XPathException e) { + LOG.debug("Failed to interpret result as response map, falling back to default serialization", e); + } + } + + // Default: serialize as JSON using the adaptive/JSON serializer + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + + outputProperties.setProperty("method", "json"); + outputProperties.setProperty("media-type", "application/json"); + + try (final OutputStream os = response.getOutputStream(); + final OutputStreamWriter writer = new OutputStreamWriter(os, StandardCharsets.UTF_8); + final PrintWriter pw = new PrintWriter(writer)) { + final XQuerySerializer serializer = new XQuerySerializer(broker, outputProperties, pw); + serializer.serialize(result); + pw.flush(); + } catch (final Exception e) { + LOG.error("Failed to serialize result", e); + writeJsonError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Serialization error: " + e.getMessage()); + } + } + + /** + * Handle a response map returned by the handler. + * The map may contain keys: "code" (integer), "type" (media-type string), + * "body" (the response body), "headers" (map of header name → value). + * This is compatible with Roaster's roaster:response() format. + */ + private void serializeResponseMap(final DBBroker broker, final HttpServletResponse response, + final MapType responseMap, final Properties outputProperties) + throws XPathException, IOException { + + // Status code + int statusCode = HttpServletResponse.SC_OK; + final Sequence codeSeq = responseMap.get(new StringValue("code")); + if (codeSeq != null && !codeSeq.isEmpty()) { + statusCode = ((IntegerValue) codeSeq.itemAt(0)).getInt(); + } + + // Content type + String contentType = "application/json"; + final Sequence typeSeq = responseMap.get(new StringValue("type")); + if (typeSeq != null && !typeSeq.isEmpty()) { + contentType = typeSeq.itemAt(0).getStringValue(); + } + + // Headers (simplified for PoC — skip custom headers from response map) + + // Body + final Sequence bodySeq = responseMap.get(new StringValue("body")); + + response.setContentType(contentType); + response.setCharacterEncoding("UTF-8"); + response.setStatus(statusCode); + + if (bodySeq != null && !bodySeq.isEmpty()) { + try (final OutputStream os = response.getOutputStream(); + final OutputStreamWriter writer = new OutputStreamWriter(os, StandardCharsets.UTF_8); + final PrintWriter pw = new PrintWriter(writer)) { + if (contentType.contains("json")) { + outputProperties.setProperty("method", "json"); + outputProperties.setProperty("media-type", "application/json"); + } else if (contentType.contains("xml")) { + outputProperties.setProperty("method", "xml"); + } else if (contentType.contains("html")) { + outputProperties.setProperty("method", "html5"); + } + final XQuerySerializer serializer = new XQuerySerializer(broker, outputProperties, pw); + serializer.serialize(bodySeq); + } catch (final Exception e) { + LOG.error("Failed to serialize response body", e); + } + } + } + + private void handleOptions(final HttpServletRequest request, final HttpServletResponse response) { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setStatus(HttpServletResponse.SC_OK); + } + + private void writeJsonError(final HttpServletResponse response, final int status, final String message) + throws IOException { + response.setContentType("application/json"); + response.setStatus(status); + try (final OutputStream os = response.getOutputStream()) { + final com.fasterxml.jackson.core.JsonFactory factory = new com.fasterxml.jackson.core.JsonFactory(); + try (final JsonGenerator gen = factory.createGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("message", message); + gen.writeEndObject(); + } + } + } +} diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiStartupTrigger.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiStartupTrigger.java new file mode 100644 index 00000000000..b467d1e3ed0 --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiStartupTrigger.java @@ -0,0 +1,146 @@ +/* + * 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.http.openapi; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.collections.Collection; +import org.exist.dom.persistent.BinaryDocument; +import org.exist.dom.persistent.DocumentImpl; +import org.exist.storage.DBBroker; +import org.exist.storage.StartupTrigger; +import org.exist.storage.txn.Txn; +import org.exist.xmldb.XmldbURI; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Startup trigger that scans {@code /db/apps/} for collections containing + * {@code controller.json} or {@code api.json} files and registers their + * OpenAPI routes with the {@link OpenApiServiceRegistry}. + *

+ * Configure in conf.xml: + *

+ * <trigger class="org.exist.http.openapi.OpenApiStartupTrigger"/>
+ * 
+ *

+ */ +public class OpenApiStartupTrigger implements StartupTrigger { + + private static final Logger LOG = LogManager.getLogger(OpenApiStartupTrigger.class); + + @Override + public void execute(final DBBroker sysBroker, final Txn transaction, + final Map> params) { + LOG.info("OpenAPI startup trigger: scanning /db/apps/ for controller.json files..."); + + try { + final XmldbURI appsUri = XmldbURI.create("/db/apps"); + final Collection appsCollection = sysBroker.getCollection(appsUri); + if (appsCollection == null) { + LOG.info("No /db/apps/ collection found, skipping OpenAPI scan"); + return; + } + + int routeCount = 0; + for (final Iterator it = appsCollection.collectionIterator(sysBroker); it.hasNext(); ) { + final XmldbURI childName = it.next(); + final XmldbURI childUri = appsUri.append(childName); + final Collection childColl = sysBroker.getCollection(childUri); + if (childColl == null) { + continue; + } + + // Check for controller.json + final DocumentImpl controllerDoc = childColl.getDocument(sysBroker, + XmldbURI.create("controller.json")); + if (controllerDoc != null && controllerDoc instanceof BinaryDocument) { + try { + final String json = readBinaryDocument(sysBroker, (BinaryDocument) controllerDoc); + final List> apis = OpenApiTrigger.parseApisArray(json); + + final List allRoutes = new ArrayList<>(); + for (final Map api : apis) { + final String specPath = api.get("spec"); + if (specPath == null) continue; + final String prefix = api.get("prefix"); + + // Resolve spec relative to app collection + final XmldbURI specUri = childUri.append(specPath); + final DocumentImpl specDoc = sysBroker.getResource(specUri, org.exist.security.Permission.READ); + if (specDoc == null) { + LOG.warn("OpenAPI spec not found: {}", specUri); + continue; + } + final String specJson = readBinaryDocument(sysBroker, (BinaryDocument) specDoc); + allRoutes.addAll(OpenApiTrigger.parseOpenApiSpec(specJson, prefix)); + } + + if (!allRoutes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes( + childUri.getCollectionPath(), allRoutes); + routeCount += allRoutes.size(); + } + } catch (final Exception e) { + LOG.error("Failed to process controller.json in {}: {}", + childUri, e.getMessage(), e); + } + continue; + } + + // Check for standalone api.json + final DocumentImpl apiDoc = childColl.getDocument(sysBroker, + XmldbURI.create("api.json")); + if (apiDoc != null && apiDoc instanceof BinaryDocument) { + try { + final String json = readBinaryDocument(sysBroker, (BinaryDocument) apiDoc); + final List routes = + OpenApiTrigger.parseOpenApiSpec(json, null); + if (!routes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes( + childUri.getCollectionPath(), routes); + routeCount += routes.size(); + } + } catch (final Exception e) { + LOG.error("Failed to process api.json in {}: {}", + childUri, e.getMessage(), e); + } + } + } + + LOG.info("OpenAPI startup trigger: registered {} routes", routeCount); + + } catch (final Exception e) { + LOG.error("OpenAPI startup trigger failed: {}", e.getMessage(), e); + } + } + + private String readBinaryDocument(final DBBroker broker, final BinaryDocument document) + throws IOException { + try (final InputStream is = broker.getBinaryResource(document)) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiTrigger.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiTrigger.java new file mode 100644 index 00000000000..e86c678e44c --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiTrigger.java @@ -0,0 +1,332 @@ +/* + * 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.http.openapi; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.collections.triggers.SAXTrigger; +import org.exist.collections.triggers.TriggerException; +import org.exist.dom.persistent.BinaryDocument; +import org.exist.dom.persistent.DocumentImpl; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.exist.xmldb.XmldbURI; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Collection trigger that watches for {@code controller.json} and {@code api.json} + * files being stored in the database. When found, parses the OpenAPI spec(s) and + * registers routes with the {@link OpenApiServiceRegistry}. + *

+ * Configure in conf.xml: + *

+ * <trigger class="org.exist.http.openapi.OpenApiTrigger"/>
+ * 
+ *

+ */ +public class OpenApiTrigger extends SAXTrigger { + + private static final Logger LOG = LogManager.getLogger(OpenApiTrigger.class); + + @Override + public void beforeCreateDocument(final DBBroker broker, final Txn transaction, final XmldbURI uri) throws TriggerException { + } + + @Override + public void afterCreateDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeUpdateDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + } + + @Override + public void afterUpdateDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeCopyDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI newUri) throws TriggerException { + } + + @Override + public void afterCopyDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI oldUri) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeMoveDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI newUri) throws TriggerException { + } + + @Override + public void afterMoveDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI oldUri) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeDeleteDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + final String fileName = document.getFileURI().lastSegment().toString(); + if ("controller.json".equals(fileName) || fileName.endsWith("api.json")) { + final String collectionPath = document.getCollection().getURI().getCollectionPath(); + OpenApiServiceRegistry.getInstance().deregisterRoutes(collectionPath); + } + } + + @Override + public void afterDeleteDocument(final DBBroker broker, final Txn transaction, final XmldbURI uri) throws TriggerException { + } + + @Override + public void beforeUpdateDocumentMetadata(final DBBroker broker, final Txn txn, final DocumentImpl document) throws TriggerException { + } + + @Override + public void afterUpdateDocumentMetadata(final DBBroker broker, final Txn txn, final DocumentImpl document) throws TriggerException { + } + + /** + * Process a document that was just stored/updated. If it's a controller.json + * or api.json, parse and register routes. + */ + private void processDocument(final DBBroker broker, final DocumentImpl document) { + final String fileName = document.getFileURI().lastSegment().toString(); + if (!"controller.json".equals(fileName) && !fileName.endsWith("api.json")) { + return; + } + + if (!(document instanceof BinaryDocument)) { + LOG.debug("Ignoring non-binary document: {}", document.getURI()); + return; + } + + final String collectionPath = document.getCollection().getURI().getCollectionPath(); + + try { + if ("controller.json".equals(fileName)) { + processControllerJson(broker, (BinaryDocument) document, collectionPath); + } else { + // Direct api.json — register with default settings + processApiJson(broker, (BinaryDocument) document, collectionPath, null); + } + } catch (final Exception e) { + LOG.error("Failed to process OpenAPI document {}: {}", document.getURI(), e.getMessage(), e); + } + } + + /** + * Process a controller.json file. Reads the "apis" array and processes each spec. + */ + private void processControllerJson(final DBBroker broker, final BinaryDocument document, + final String collectionPath) throws IOException, org.exist.security.PermissionDeniedException { + final String json = readBinaryDocument(broker, document); + LOG.info("Processing controller.json for collection {}", collectionPath); + + // Simple JSON parsing for the "apis" array + // Extract spec paths from: {"apis": [{"spec": "modules/api.json", ...}]} + final List> apis = parseApisArray(json); + + if (apis.isEmpty()) { + LOG.warn("No 'apis' found in controller.json for {}", collectionPath); + return; + } + + final List allRoutes = new ArrayList<>(); + for (final Map api : apis) { + final String specPath = api.get("spec"); + if (specPath == null) { + continue; + } + final String prefix = api.get("prefix"); + + // Resolve spec path relative to collection + final XmldbURI specUri = XmldbURI.create(collectionPath).append(specPath); + final DocumentImpl specDoc = broker.getResource(specUri, org.exist.security.Permission.READ); + if (specDoc == null) { + LOG.warn("OpenAPI spec not found: {}", specUri); + continue; + } + + final String specJson = readBinaryDocument(broker, (BinaryDocument) specDoc); + final List routes = parseOpenApiSpec(specJson, prefix); + allRoutes.addAll(routes); + } + + if (!allRoutes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes(collectionPath, allRoutes); + } + } + + /** + * Process a standalone api.json file (no controller.json). + */ + private void processApiJson(final DBBroker broker, final BinaryDocument document, + final String collectionPath, final String prefix) throws IOException { + final String json = readBinaryDocument(broker, document); + LOG.info("Processing api.json for collection {}", collectionPath); + + final List routes = parseOpenApiSpec(json, prefix); + if (!routes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes(collectionPath, routes); + } + } + + /** + * Parse an OpenAPI 3.0 spec and extract routes. + * Uses Jackson streaming API for efficient parsing. + */ + static List parseOpenApiSpec(final String json, final String prefix) throws IOException { + final List routes = new ArrayList<>(); + final JsonFactory factory = new JsonFactory(); + + try (final JsonParser parser = factory.createParser(json)) { + // Navigate to "paths" object + if (!advanceTo(parser, "paths")) { + return routes; + } + + // parser is now at START_OBJECT for "paths" + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.FIELD_NAME) { + String pathPattern = parser.currentName(); + + // Apply prefix filter if specified + if (prefix != null && !pathPattern.startsWith(prefix)) { + parser.nextToken(); // skip value + parser.skipChildren(); + continue; + } + + parser.nextToken(); // START_OBJECT for this path + if (parser.currentToken() != JsonToken.START_OBJECT) { + continue; + } + + // Parse methods under this path + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.FIELD_NAME) { + final String method = parser.currentName().toUpperCase(); + if (!Set.of("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD").contains(method)) { + parser.nextToken(); + parser.skipChildren(); + continue; + } + + parser.nextToken(); // START_OBJECT for this operation + String operationId = null; + final Map spec = new HashMap<>(); + + // Find operationId in this operation object + int depth = 1; + while (depth > 0) { + final JsonToken token = parser.nextToken(); + if (token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) { + depth++; + } else if (token == JsonToken.END_OBJECT || token == JsonToken.END_ARRAY) { + depth--; + } else if (token == JsonToken.FIELD_NAME && depth == 1) { + if ("operationId".equals(parser.currentName())) { + parser.nextToken(); + operationId = parser.getValueAsString(); + } + } + } + + if (operationId != null) { + final Pattern regex = OpenApiServiceRegistry.compilePathPattern(pathPattern); + routes.add(new OpenApiServiceRegistry.Route( + method, pathPattern, regex, operationId, spec)); + LOG.debug("Parsed route: {} {} → {}", method, pathPattern, operationId); + } + } + } + } + } + } + + return routes; + } + + /** + * Parse the "apis" array from a controller.json string. + */ + static List> parseApisArray(final String json) throws IOException { + final List> apis = new ArrayList<>(); + final JsonFactory factory = new JsonFactory(); + + try (final JsonParser parser = factory.createParser(json)) { + if (!advanceTo(parser, "apis")) { + return apis; + } + + // parser is now at START_ARRAY for "apis" + while (parser.nextToken() != JsonToken.END_ARRAY) { + if (parser.currentToken() == JsonToken.START_OBJECT) { + final Map api = new HashMap<>(); + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.FIELD_NAME) { + final String key = parser.currentName(); + parser.nextToken(); + api.put(key, parser.getValueAsString()); + } + } + apis.add(api); + } + } + } + + return apis; + } + + /** + * Advance the parser to the value of a top-level field with the given name. + * Returns true if found, false if not. + */ + private static boolean advanceTo(final JsonParser parser, final String fieldName) throws IOException { + while (parser.nextToken() != null) { + if (parser.currentToken() == JsonToken.FIELD_NAME && fieldName.equals(parser.currentName())) { + parser.nextToken(); // advance to value + return true; + } + if (parser.currentToken() == JsonToken.START_OBJECT || parser.currentToken() == JsonToken.START_ARRAY) { + if (parser.currentToken() == JsonToken.START_OBJECT && parser.currentName() != null && !fieldName.equals(parser.currentName())) { + parser.skipChildren(); + } + } + } + return false; + } + + private String readBinaryDocument(final DBBroker broker, final BinaryDocument document) throws IOException { + try (final InputStream is = broker.getBinaryResource(document)) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java new file mode 100644 index 00000000000..bcd40f75857 --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java @@ -0,0 +1,432 @@ +/* + * 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.http.servlets; + +import com.fasterxml.jackson.core.JsonGenerator; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Part; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.repo.PackageService; +import org.exist.security.Subject; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.expath.pkg.repo.PackageException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Built-in REST API for package management. + *

+ * This servlet provides package CRUD operations (list, get, install, remove, + * update-check) as a built-in Java endpoint that does not depend on any XAR + * package. This ensures package management remains available even during + * package upgrades, solving the self-upgrade problem that affects XQuery-based + * package management implementations. + *

+ *

+ * Endpoints: + *

    + *
  • GET /api/packages — list all installed packages
  • + *
  • GET /api/packages/{name} — get single package details
  • + *
  • GET /api/packages/{name}/icon — get package icon
  • + *
  • POST /api/packages/install — install from registry (JSON) or upload (multipart)
  • + *
  • POST /api/packages/update-check — check for available updates
  • + *
  • DELETE /api/packages/{name} — remove package
  • + *
+ *

+ */ +public class PackageManagementServlet extends AbstractExistHttpServlet { + + private static final Logger LOG = LogManager.getLogger(PackageManagementServlet.class); + + private final PackageService packageService = new PackageService(); + + @Override + public Logger getLog() { + return LOG; + } + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + + try { + if (pathInfo == null || pathInfo.isEmpty() || pathInfo.equals("/")) { + // GET /api/packages — list all + handleListPackages(response); + } else if (pathInfo.endsWith("/icon")) { + // GET /api/packages/{name}/icon + final String name = pathInfo.substring(1, pathInfo.length() - "/icon".length()); + handleGetIcon(response, name); + } else { + // GET /api/packages/{name} + final String name = pathInfo.substring(1); + handleGetPackage(response, name); + } + } catch (final PackageException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + if (!user.hasDbaRole()) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "FORBIDDEN", "DBA role required for package installation"); + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + + try { + if ("/install".equals(pathInfo)) { + handleInstall(request, response, user); + } else if ("/update-check".equals(pathInfo)) { + handleUpdateCheck(request, response); + } else { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Unknown POST endpoint: " + pathInfo); + } + } catch (final PackageException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } catch (final EXistException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", e.getMessage()); + } + } + + @Override + protected void doDelete(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + if (!user.hasDbaRole()) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "FORBIDDEN", "DBA role required for package removal"); + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + if (pathInfo == null || pathInfo.length() <= 1) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Package name required"); + return; + } + + final String nameOrAbbrev = pathInfo.substring(1); + + try { + // Check for dependents first + final List dependents = packageService.findDependents(getPool(), nameOrAbbrev); + final boolean force = "true".equals(request.getParameter("force")); + if (!dependents.isEmpty() && !force) { + response.setStatus(HttpServletResponse.SC_CONFLICT); + response.setContentType("application/json"); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("code", "HAS_DEPENDENTS"); + gen.writeStringField("message", "Cannot remove: other packages depend on " + nameOrAbbrev); + gen.writeArrayFieldStart("dependents"); + for (final String dep : dependents) { + gen.writeString(dep); + } + gen.writeEndArray(); + gen.writeEndObject(); + } + return; + } + + try (final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.removePackage(broker, transaction, nameOrAbbrev); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } catch (final EXistException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", e.getMessage()); + } + } catch (final PackageException e) { + if (e.getMessage() != null && e.getMessage().contains("not found")) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "PACKAGE_NOT_FOUND", e.getMessage()); + } else { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } + } + } + + @Override + protected void doOptions(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setStatus(HttpServletResponse.SC_OK); + } + + // --- Handler methods --- + + private void handleListPackages(final HttpServletResponse response) + throws IOException, PackageException { + final List> packages = packageService.listPackages(getPool()); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartArray(); + for (final Map pkg : packages) { + writeMapAsJson(gen, pkg); + } + gen.writeEndArray(); + } + } + + private void handleGetPackage(final HttpServletResponse response, final String nameOrAbbrev) + throws IOException, PackageException { + final Map pkg = packageService.getPackage(getPool(), nameOrAbbrev); + if (pkg == null) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "PACKAGE_NOT_FOUND", "Package not found: " + nameOrAbbrev); + return; + } + writeJsonMap(response, HttpServletResponse.SC_OK, pkg); + } + + private void handleGetIcon(final HttpServletResponse response, final String nameOrAbbrev) + throws IOException, PackageException { + final Object[] icon = packageService.getPackageIcon(getPool(), nameOrAbbrev); + if (icon == null) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "ICON_NOT_FOUND", "No icon found for package: " + nameOrAbbrev); + return; + } + response.setContentType((String) icon[1]); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream()) { + os.write((byte[]) icon[0]); + } + } + + private void handleInstall(final HttpServletRequest request, final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + final String contentType = request.getContentType(); + + if (contentType != null && contentType.startsWith("multipart/")) { + // Upload a XAR file + handleInstallUpload(request, response, user); + } else { + // Install from registry (JSON body) + handleInstallFromRegistry(request, response, user); + } + } + + private void handleInstallFromRegistry(final HttpServletRequest request, + final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + // Parse JSON body manually (avoid adding a JSON parsing dependency) + final String body; + try (final InputStream is = request.getInputStream()) { + body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + final String name = PackageService.extractJsonStringValue(body, "name"); + final String url = PackageService.extractJsonStringValue(body, "url"); + final String version = PackageService.extractJsonStringValue(body, "version"); + + if (name == null || name.isEmpty() || url == null || url.isEmpty()) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Missing required fields: name, url"); + return; + } + + try (final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.installFromRegistry( + broker, transaction, name, url, version); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } + } + + private void handleInstallUpload(final HttpServletRequest request, + final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + try { + final Part xarPart = request.getPart("xar"); + if (xarPart == null) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Missing 'xar' file part in multipart upload"); + return; + } + try (final InputStream is = xarPart.getInputStream(); + final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.installFromUpload( + broker, transaction, is); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } + } catch (final ServletException e) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Failed to process multipart upload: " + e.getMessage()); + } + } + + private void handleUpdateCheck(final HttpServletRequest request, + final HttpServletResponse response) + throws IOException, PackageException { + // Parse optional JSON body for registry URL + String registryUrl = "https://exist-db.org/exist/apps/public-repo"; + final String contentType = request.getContentType(); + if (contentType != null && contentType.contains("json")) { + final String body; + try (final InputStream is = request.getInputStream()) { + body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + final String url = PackageService.extractJsonStringValue(body, "registry"); + if (url != null && !url.isEmpty()) { + registryUrl = url; + } + } + + final List> updates = packageService.checkUpdates(getPool(), registryUrl); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("registry", registryUrl); + gen.writeArrayFieldStart("updates"); + for (final Map update : updates) { + writeMapAsJson(gen, update); + } + gen.writeEndArray(); + gen.writeEndObject(); + } + } + + // --- JSON helpers --- + + private JsonGenerator createJsonGenerator(final OutputStream os) throws IOException { + final com.fasterxml.jackson.core.JsonFactory factory = new com.fasterxml.jackson.core.JsonFactory(); + final JsonGenerator gen = factory.createGenerator(os); + gen.useDefaultPrettyPrinter(); + return gen; + } + + private void writeError(final HttpServletResponse response, final int statusCode, + final String code, final String message) throws IOException { + response.setContentType("application/json"); + response.setStatus(statusCode); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("code", code); + gen.writeStringField("message", message); + gen.writeEndObject(); + } + } + + private void writeJsonMap(final HttpServletResponse response, final int statusCode, + final Map data) throws IOException { + response.setContentType("application/json"); + response.setStatus(statusCode); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + writeMapAsJson(gen, data); + } + } + + @SuppressWarnings("unchecked") + private void writeMapAsJson(final JsonGenerator gen, final Map map) throws IOException { + gen.writeStartObject(); + for (final Map.Entry entry : map.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (value == null) { + gen.writeNullField(key); + } else if (value instanceof String s) { + gen.writeStringField(key, s); + } else if (value instanceof Boolean b) { + gen.writeBooleanField(key, b); + } else if (value instanceof Number n) { + gen.writeNumberField(key, n.longValue()); + } else if (value instanceof List list) { + gen.writeArrayFieldStart(key); + for (final Object item : list) { + if (item instanceof Map itemMap) { + writeMapAsJson(gen, (Map) itemMap); + } else if (item instanceof String s) { + gen.writeString(s); + } else { + gen.writeString(String.valueOf(item)); + } + } + gen.writeEndArray(); + } else if (value instanceof Map nested) { + gen.writeFieldName(key); + writeMapAsJson(gen, (Map) nested); + } else { + gen.writeStringField(key, String.valueOf(value)); + } + } + gen.writeEndObject(); + } + + private String normalizePath(final String pathInfo) { + if (pathInfo == null) { + return null; + } + // URL-decode the path + return java.net.URLDecoder.decode(pathInfo, java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/exist-services/src/main/java/org/exist/repo/PackageService.java b/exist-services/src/main/java/org/exist/repo/PackageService.java new file mode 100644 index 00000000000..94be8e20ac8 --- /dev/null +++ b/exist-services/src/main/java/org/exist/repo/PackageService.java @@ -0,0 +1,470 @@ +/* + * 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.repo; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.exist.util.io.TemporaryFileManager; +import org.expath.pkg.repo.*; +import org.expath.pkg.repo.Package; +import org.expath.pkg.repo.tui.BatchUserInteraction; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.annotation.Nullable; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.*; + +/** + * Business logic for package management operations. + * Used by {@link org.exist.http.servlets.PackageManagementServlet} to provide + * a built-in REST API for package CRUD that does not depend on any XAR package. + */ +public class PackageService { + + private static final Logger LOG = LogManager.getLogger(PackageService.class); + + private static final String PKG_NAMESPACE = "http://expath.org/ns/pkg"; + private static final String REPO_NAMESPACE = "http://exist-db.org/xquery/repo"; + + /** + * List all installed packages with metadata. + */ + public List> listPackages(final BrokerPool pool) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final List> result = new ArrayList<>(); + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + result.add(buildPackageInfo(pkg)); + } + return result; + } + + /** + * Get metadata for a single package by name (URI) or abbreviation. + */ + @Nullable + public Map getPackage(final BrokerPool pool, final String nameOrAbbrev) throws PackageException { + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + return null; + } + return buildPackageInfo(pkg); + } + + /** + * Get a package icon file. + * Returns a two-element array: [byte[], contentType] or null if no icon found. + */ + @Nullable + public Object[] getPackageIcon(final BrokerPool pool, final String nameOrAbbrev) throws PackageException, IOException { + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + return null; + } + final Path pkgDir = getPackageDir(pkg); + // Try common icon filenames + for (final String iconName : new String[]{"icon.png", "icon.svg", "icon.jpg", "icon.gif"}) { + final Path iconFile = pkgDir.resolve(iconName); + if (Files.isReadable(iconFile)) { + final String contentType; + if (iconName.endsWith(".svg")) { + contentType = "image/svg+xml"; + } else if (iconName.endsWith(".png")) { + contentType = "image/png"; + } else if (iconName.endsWith(".jpg")) { + contentType = "image/jpeg"; + } else { + contentType = "image/gif"; + } + return new Object[]{Files.readAllBytes(iconFile), contentType}; + } + } + return null; + } + + /** + * Install a package from a remote registry. + */ + public Map installFromRegistry(final DBBroker broker, final Txn transaction, + final String name, final String registryUrl, + @Nullable final String version) throws PackageException, IOException { + final RepoPackageLoader loader = new RepoPackageLoader(registryUrl); + // Build version constraint + PackageLoader.Version pkgVersion = null; + if (version != null && !version.isEmpty()) { + pkgVersion = new PackageLoader.Version(version, true); + } + + // Download the XAR from the registry + final XarSource xar = loader.load(name, pkgVersion); + if (xar == null) { + throw new PackageException("Package not found in registry: " + name); + } + + // Install and deploy + final Deployment deployment = new Deployment(); + final Optional target = deployment.installAndDeploy(broker, transaction, xar, loader); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", name); + target.ifPresent(t -> result.put("target", t)); + return result; + } + + /** + * Install a package from an uploaded XAR file. + */ + public Map installFromUpload(final DBBroker broker, final Txn transaction, + final InputStream xarStream) throws PackageException, IOException { + // Save to temporary file + final TemporaryFileManager tempManager = TemporaryFileManager.getInstance(); + final Path tempFile = tempManager.getTemporaryFile(); + Files.copy(xarStream, tempFile, StandardCopyOption.REPLACE_EXISTING); + + final XarFileSource xarSource = new XarFileSource(tempFile); + final Deployment deployment = new Deployment(); + final Optional target = deployment.installAndDeploy(broker, transaction, xarSource, null); + + // Read package name from the XAR descriptor + final Optional descriptor = deployment.getDescriptor(broker, xarSource); + final String name = descriptor.map(d -> d.getDocumentElement().getAttribute("name")).orElse("unknown"); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", name); + target.ifPresent(t -> result.put("target", t)); + return result; + } + + /** + * Remove (undeploy + delete) a package. + */ + public Map removePackage(final DBBroker broker, final Txn transaction, + final String nameOrAbbrev) throws PackageException { + final BrokerPool pool = broker.getBrokerPool(); + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + throw new PackageException("Package not found: " + nameOrAbbrev); + } + final String pkgName = pkg.getName(); + + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + throw new PackageException("EXPath repository not available"); + } + + // Undeploy (remove database resources) + final Deployment deployment = new Deployment(); + deployment.undeploy(broker, transaction, pkgName, maybeRepo); + + // Remove from repository + final Repository parentRepo = maybeRepo.get().getParentRepo(); + parentRepo.removePackage(pkgName, false, new BatchUserInteraction()); + maybeRepo.get().reportAction(ExistRepository.Action.UNINSTALL, pkgName); + + // Clear XQuery cache + pool.getXQueryPool().clear(); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", pkgName); + return result; + } + + /** + * Find packages that depend on the given package. + */ + public List findDependents(final BrokerPool pool, final String packageName) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final List dependents = new ArrayList<>(); + + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + if (pkg.getName().equals(packageName)) { + continue; + } + // Read expath-pkg.xml to check dependencies + final Path pkgDir = getPackageDir(pkg); + final Path expathFile = pkgDir.resolve("expath-pkg.xml"); + if (Files.isReadable(expathFile)) { + try { + final Document doc = parseXml(expathFile); + final NodeList deps = doc.getElementsByTagNameNS(PKG_NAMESPACE, "dependency"); + for (int i = 0; i < deps.getLength(); i++) { + final Element dep = (Element) deps.item(i); + if (packageName.equals(dep.getAttribute("package"))) { + dependents.add(pkg.getName()); + break; + } + } + } catch (final Exception e) { + LOG.warn("Failed to parse expath-pkg.xml for package {}", pkg.getName(), e); + } + } + } + return dependents; + } + + /** + * Check a remote registry for available updates. + */ + public List> checkUpdates(final BrokerPool pool, final String registryUrl) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final String findUrl = registryUrl + "/find"; + final String processorVersion = org.exist.SystemProperties.getInstance() + .getSystemProperty("product-version", "7.0.0"); + + final List> updates = new ArrayList<>(); + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + final String abbrev = pkg.getAbbrev(); + final String installed = pkg.getVersion(); + if (abbrev == null || installed == null) { + continue; + } + try { + final String queryUrl = findUrl + + "?abbrev=" + URLEncoder.encode(abbrev, StandardCharsets.UTF_8) + + "&processor=http://exist-db.org" + + "&info=true" + + "&processorVersion=" + URLEncoder.encode(processorVersion, StandardCharsets.UTF_8); + + final HttpURLConnection conn = (HttpURLConnection) URI.create(queryUrl).toURL().openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Accept", "application/json"); + conn.connect(); + + if (conn.getResponseCode() == 200) { + // Parse the simple JSON response to extract version + // The response format is: {"version": "x.y.z", ...} + final String body; + try (final InputStream is = conn.getInputStream()) { + body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + final String available = extractJsonStringValue(body, "version"); + if (available != null && !available.equals(installed)) { + final Map update = new LinkedHashMap<>(); + update.put("name", pkg.getName()); + update.put("abbrev", abbrev); + update.put("installed", installed); + update.put("available", available); + updates.add(update); + } + } + } catch (final IOException e) { + LOG.debug("Failed to check updates for package {}: {}", abbrev, e.getMessage()); + } + } + return updates; + } + + // --- Private helpers --- + + @Nullable + private Package resolvePackage(final BrokerPool pool, final String nameOrAbbrev) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return null; + } + final Repository repo = maybeRepo.get().getParentRepo(); + + // Try as package name (URI) first + final Packages byName = repo.getPackages(nameOrAbbrev); + if (byName != null) { + return byName.latest(); + } + + // Try as abbreviation + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + if (nameOrAbbrev.equals(pkg.getAbbrev())) { + return pkg; + } + } + return null; + } + + private Path getPackageDir(final Package pkg) { + final FileSystemStorage.FileSystemResolver resolver = + (FileSystemStorage.FileSystemResolver) pkg.getResolver(); + return resolver.resolveResourceAsFile(""); + } + + private Map buildPackageInfo(final Package pkg) { + final Map info = new LinkedHashMap<>(); + info.put("name", pkg.getName()); + info.put("abbrev", pkg.getAbbrev()); + info.put("version", pkg.getVersion()); + + // Read additional metadata from descriptors on the filesystem + final Path pkgDir = getPackageDir(pkg); + readExpathMetadata(pkgDir, info); + readRepoMetadata(pkgDir, info); + return info; + } + + private void readExpathMetadata(final Path pkgDir, final Map info) { + final Path expathFile = pkgDir.resolve("expath-pkg.xml"); + if (!Files.isReadable(expathFile)) { + return; + } + try { + final Document doc = parseXml(expathFile); + final Element root = doc.getDocumentElement(); + // Title + final NodeList titles = root.getElementsByTagNameNS(PKG_NAMESPACE, "title"); + if (titles.getLength() > 0) { + info.put("title", titles.item(0).getTextContent()); + } + // Dependencies + final NodeList deps = root.getElementsByTagNameNS(PKG_NAMESPACE, "dependency"); + final List> depList = new ArrayList<>(); + for (int i = 0; i < deps.getLength(); i++) { + final Element dep = (Element) deps.item(i); + final Map depInfo = new LinkedHashMap<>(); + if (!dep.getAttribute("package").isEmpty()) { + depInfo.put("package", dep.getAttribute("package")); + } + if (!dep.getAttribute("processor").isEmpty()) { + depInfo.put("processor", dep.getAttribute("processor")); + } + if (!dep.getAttribute("semver-min").isEmpty()) { + depInfo.put("semverMin", dep.getAttribute("semver-min")); + } + if (!dep.getAttribute("semver-max").isEmpty()) { + depInfo.put("semverMax", dep.getAttribute("semver-max")); + } + if (!dep.getAttribute("semver").isEmpty()) { + depInfo.put("semver", dep.getAttribute("semver")); + } + if (!dep.getAttribute("version").isEmpty()) { + depInfo.put("version", dep.getAttribute("version")); + } + depList.add(depInfo); + } + info.put("dependencies", depList); + } catch (final Exception e) { + LOG.debug("Failed to read expath-pkg.xml from {}", pkgDir, e); + } + } + + private void readRepoMetadata(final Path pkgDir, final Map info) { + final Path repoFile = pkgDir.resolve("repo.xml"); + if (!Files.isReadable(repoFile)) { + return; + } + try { + final Document doc = parseXml(repoFile); + final Element root = doc.getDocumentElement(); + setTextElement(root, REPO_NAMESPACE, "description", "description", info); + setTextElement(root, REPO_NAMESPACE, "website", "website", info); + setTextElement(root, REPO_NAMESPACE, "license", "license", info); + setTextElement(root, REPO_NAMESPACE, "type", "type", info); + setTextElement(root, REPO_NAMESPACE, "target", "target", info); + setTextElement(root, REPO_NAMESPACE, "deployed", "deployed", info); + // Authors + final NodeList authors = root.getElementsByTagNameNS(REPO_NAMESPACE, "author"); + if (authors.getLength() > 0) { + final List authorList = new ArrayList<>(); + for (int i = 0; i < authors.getLength(); i++) { + authorList.add(authors.item(i).getTextContent().trim()); + } + info.put("authors", authorList); + } + } catch (final Exception e) { + LOG.debug("Failed to read repo.xml from {}", pkgDir, e); + } + } + + private void setTextElement(final Element root, final String ns, final String localName, + final String key, final Map info) { + final NodeList nodes = root.getElementsByTagNameNS(ns, localName); + if (nodes.getLength() > 0) { + final String value = nodes.item(0).getTextContent().trim(); + if (!value.isEmpty()) { + info.put(key, value); + } + } + } + + private Document parseXml(final Path file) throws Exception { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + final DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(file.toFile()); + } + + /** + * Simple extraction of a string value from a JSON object. + * Avoids pulling in a JSON parsing library for this single use case. + */ + @Nullable + public static String extractJsonStringValue(final String json, final String key) { + final String search = "\"" + key + "\""; + final int keyIdx = json.indexOf(search); + if (keyIdx < 0) { + return null; + } + final int colonIdx = json.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) { + return null; + } + final int startQuote = json.indexOf('"', colonIdx + 1); + if (startQuote < 0) { + return null; + } + final int endQuote = json.indexOf('"', startQuote + 1); + if (endQuote < 0) { + return null; + } + return json.substring(startQuote + 1, endQuote); + } +} diff --git a/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java b/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java new file mode 100644 index 00000000000..0ce8dd4641e --- /dev/null +++ b/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java @@ -0,0 +1,88 @@ +/* + * 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.repo; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.SystemProperties; +import org.exist.util.io.TemporaryFileManager; +import org.expath.pkg.repo.XarFileSource; +import org.expath.pkg.repo.XarSource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Loads packages from a remote public repository (e.g. https://exist-db.org/exist/apps/public-repo). + * Used by {@link Deployment} to resolve package dependencies during installation, + * and by {@link PackageService} for direct package installation from a registry. + */ +public record RepoPackageLoader(String repoURL) implements PackageLoader { + + private static final Logger LOG = LogManager.getLogger(RepoPackageLoader.class); + + private static final int CONNECT_TIMEOUT = 15_000; + private static final int READ_TIMEOUT = 15_000; + + @Override + public XarSource load(final String name, final Version version) throws IOException { + String pkgURL = repoURL + "?name=" + URLEncoder.encode(name, StandardCharsets.UTF_8) + + "&processor=" + SystemProperties.getInstance().getSystemProperty("product-version", "2.2.0"); + if (version != null) { + if (version.getMin() != null) { + pkgURL += "&semver-min=" + version.getMin(); + } + if (version.getMax() != null) { + pkgURL += "&semver-max=" + version.getMax(); + } + if (version.getSemVer() != null) { + pkgURL += "&semver=" + version.getSemVer(); + } + if (version.getVersion() != null) { + pkgURL += "&version=" + URLEncoder.encode(version.getVersion(), StandardCharsets.UTF_8); + } + } + LOG.info("Retrieving package from {}", pkgURL); + final HttpURLConnection connection = (HttpURLConnection) URI.create(pkgURL).toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", "eXist-db Package Manager"); + connection.connect(); + + try (final InputStream is = connection.getInputStream()) { + final TemporaryFileManager temporaryFileManager = TemporaryFileManager.getInstance(); + final Path outFile = temporaryFileManager.getTemporaryFile(); + Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING); + return new XarFileSource(outFile); + } catch (final IOException e) { + throw new IOException("Failed to download package from " + pkgURL + ": " + e.getMessage(), e); + } + } +} diff --git a/extensions/contentextraction/src/test/resources-filtered/conf.xml b/extensions/contentextraction/src/test/resources-filtered/conf.xml index 1311e06f555..5521efe64b0 100644 --- a/extensions/contentextraction/src/test/resources-filtered/conf.xml +++ b/extensions/contentextraction/src/test/resources-filtered/conf.xml @@ -757,6 +757,8 @@
+ + diff --git a/extensions/debuggee/src/test/resources-filtered/conf.xml b/extensions/debuggee/src/test/resources-filtered/conf.xml index 5dc0efc380a..afa5e53bbb0 100644 --- a/extensions/debuggee/src/test/resources-filtered/conf.xml +++ b/extensions/debuggee/src/test/resources-filtered/conf.xml @@ -743,6 +743,8 @@ + + diff --git a/extensions/debuggee/src/test/resources/standalone-webapp/WEB-INF/web.xml b/extensions/debuggee/src/test/resources/standalone-webapp/WEB-INF/web.xml index 4722b24716c..6359e6f427c 100644 --- a/extensions/debuggee/src/test/resources/standalone-webapp/WEB-INF/web.xml +++ b/extensions/debuggee/src/test/resources/standalone-webapp/WEB-INF/web.xml @@ -25,9 +25,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database diff --git a/extensions/expath/src/test/resources-filtered/conf.xml b/extensions/expath/src/test/resources-filtered/conf.xml index a0e02a2c06d..d834683599b 100644 --- a/extensions/expath/src/test/resources-filtered/conf.xml +++ b/extensions/expath/src/test/resources-filtered/conf.xml @@ -757,6 +757,8 @@ + + diff --git a/extensions/exquery/restxq/src/main/java/org/exist/extensions/exquery/restxq/impl/adapters/HttpServletResponseAdapter.java b/extensions/exquery/restxq/src/main/java/org/exist/extensions/exquery/restxq/impl/adapters/HttpServletResponseAdapter.java index 332f9666fa8..4940d69d2be 100644 --- a/extensions/exquery/restxq/src/main/java/org/exist/extensions/exquery/restxq/impl/adapters/HttpServletResponseAdapter.java +++ b/extensions/exquery/restxq/src/main/java/org/exist/extensions/exquery/restxq/impl/adapters/HttpServletResponseAdapter.java @@ -29,6 +29,8 @@ import java.io.IOException; import java.io.OutputStream; import jakarta.servlet.http.HttpServletResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.exquery.http.HttpResponse; import org.exquery.http.HttpStatus; @@ -37,7 +39,9 @@ * @author Adam Retter */ public class HttpServletResponseAdapter implements HttpResponse { - + + private static final Logger LOG = LogManager.getLogger(HttpServletResponseAdapter.class); + private final HttpServletResponse response; public HttpServletResponseAdapter(final HttpServletResponse response) { @@ -51,7 +55,13 @@ public void setHeader(final String name, final String value) { @Override public void setStatus(final HttpStatus status, final String reason) { - response.setStatus(status.getStatus(), reason); + // Cannot use sendError(int, String) here: it commits the response and triggers + // error page handling, which would prevent RESTXQ from writing its response body. + // Servlet 6.0 dropped the reason parameter; log it for diagnostics. + if (reason != null && !reason.isEmpty()) { + LOG.debug("HTTP {} — reason (not sent per Servlet 6.0): {}", status.getStatus(), reason); + } + response.setStatus(status.getStatus()); } @Override diff --git a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml index 697afdbf11b..2f0ca321b0a 100644 --- a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml +++ b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml @@ -738,6 +738,8 @@ + + diff --git a/extensions/exquery/restxq/src/test/resources/standalone-webapp/WEB-INF/web.xml b/extensions/exquery/restxq/src/test/resources/standalone-webapp/WEB-INF/web.xml index cda73403067..30904d4ba01 100644 --- a/extensions/exquery/restxq/src/test/resources/standalone-webapp/WEB-INF/web.xml +++ b/extensions/exquery/restxq/src/test/resources/standalone-webapp/WEB-INF/web.xml @@ -30,9 +30,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database diff --git a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml index 2aae0f7d207..74c0ead6c4a 100644 --- a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml @@ -906,6 +906,8 @@ + + diff --git a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml index 4eaa2642bde..48051f60d05 100644 --- a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml @@ -905,6 +905,8 @@ + + diff --git a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml index 7b290c22429..68db58f84fa 100644 --- a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml @@ -903,6 +903,8 @@ + + diff --git a/extensions/indexes/range/src/main/java/org/exist/indexing/range/ComplexRangeIndexConfigElement.java b/extensions/indexes/range/src/main/java/org/exist/indexing/range/ComplexRangeIndexConfigElement.java index 3fa4778270a..d01463cba2a 100644 --- a/extensions/indexes/range/src/main/java/org/exist/indexing/range/ComplexRangeIndexConfigElement.java +++ b/extensions/indexes/range/src/main/java/org/exist/indexing/range/ComplexRangeIndexConfigElement.java @@ -69,7 +69,7 @@ public ComplexRangeIndexConfigElement(final Element node, final NodeList childre fields.put(field.getName(), field); } case CONDITION_ELEMENT -> - conditions.add(new RangeIndexConfigAttributeCondition((Element) child, path)); + conditions.add(new RangeIndexConfigAttributeCondition((Element) child, path, namespaces)); case FILTER_ELEMENT -> analyzer.addFilter((Element) child); case null, default -> LOG.warn("Invalid element encountered for range index configuration: {}", child.getLocalName()); diff --git a/extensions/indexes/range/src/main/java/org/exist/indexing/range/RangeIndexConfigAttributeCondition.java b/extensions/indexes/range/src/main/java/org/exist/indexing/range/RangeIndexConfigAttributeCondition.java index 92cf6ab21c1..46630b5339b 100644 --- a/extensions/indexes/range/src/main/java/org/exist/indexing/range/RangeIndexConfigAttributeCondition.java +++ b/extensions/indexes/range/src/main/java/org/exist/indexing/range/RangeIndexConfigAttributeCondition.java @@ -37,6 +37,7 @@ import org.w3c.dom.Node; import javax.xml.XMLConstants; +import java.util.Map; import java.util.function.BiPredicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,7 +64,7 @@ public class RangeIndexConfigAttributeCondition extends RangeIndexConfigConditio private String lowercaseValue = null; private Pattern pattern = null; - public RangeIndexConfigAttributeCondition(final Element elem, final NodePath parentPath) throws DatabaseConfigurationException { + public RangeIndexConfigAttributeCondition(final Element elem, final NodePath parentPath, final Map namespaces) throws DatabaseConfigurationException { if (parentPath.getLastComponent().getNameType() == ElementValue.ATTRIBUTE) { throw new DatabaseConfigurationException( @@ -76,10 +77,20 @@ public RangeIndexConfigAttributeCondition(final Element elem, final NodePath par } try { - attribute = new QName(QName.extractLocalName(attributeName), XMLConstants.NULL_NS_URI, - QName.extractPrefix(attributeName), ElementValue.ATTRIBUTE); + final String prefix = QName.extractPrefix(attributeName); + final String localName = QName.extractLocalName(attributeName); + String namespaceURI = XMLConstants.NULL_NS_URI; + if (prefix != null) { + namespaceURI = namespaces.get(prefix); + if (namespaceURI == null) { + throw new DatabaseConfigurationException( + "Range index module: No namespace defined for prefix: " + prefix + + " in condition attribute '" + attributeName + "'"); + } + } + attribute = new QName(localName, namespaceURI, prefix, ElementValue.ATTRIBUTE); } catch (final QName.IllegalQNameException e) { - throw new DatabaseConfigurationException("Rand index module error: " + e.getMessage(), e); + throw new DatabaseConfigurationException("Range index module error: " + e.getMessage(), e); } value = elem.getAttribute("value"); @@ -139,8 +150,18 @@ private String getLowercaseValue() { @Override public boolean matches(final Node node) { - return node.getNodeType() == Node.ELEMENT_NODE - && matchValue(((Element) node).getAttribute(attributeName)); + if (node.getNodeType() != Node.ELEMENT_NODE) { + return false; + } + final Element element = (Element) node; + final String ns = attribute.getNamespaceURI(); + final String testValue; + if (XMLConstants.NULL_NS_URI.equals(ns)) { + testValue = element.getAttribute(attribute.getLocalPart()); + } else { + testValue = element.getAttributeNS(ns, attribute.getLocalPart()); + } + return matchValue(testValue); } private boolean matchValue(final String testValue) { diff --git a/extensions/indexes/range/src/main/java/org/exist/xquery/modules/range/RangeQueryRewriter.java b/extensions/indexes/range/src/main/java/org/exist/xquery/modules/range/RangeQueryRewriter.java index 2f3c052e885..aff745f048e 100644 --- a/extensions/indexes/range/src/main/java/org/exist/xquery/modules/range/RangeQueryRewriter.java +++ b/extensions/indexes/range/src/main/java/org/exist/xquery/modules/range/RangeQueryRewriter.java @@ -27,7 +27,9 @@ import org.exist.storage.NodePath; import org.exist.xquery.*; import org.exist.xquery.Constants.Comparison; +import org.exist.xquery.pragmas.Optimize; import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.Type; import javax.annotation.Nullable; @@ -51,6 +53,266 @@ public RangeQueryRewriter(XQueryContext context) { super(context); } + /** + * Distribute predicates over a Union of LocationSteps so the existing + * rewriteLocationStep pass can pick them up. Addresses issue #2363: + * {@code ($c//foo | $c//bar)[pred]} fails to use indexes that + * {@code $c//foo[pred] | $c//bar[pred]} would. + * + *

The rewrite is sound only when no predicate has positional + * dependency. Since {@link RangeQueryRewriter} only handles + * {@link GeneralComparison} and {@code fn:matches} predicates — neither + * of which is positional — the structural check that we recognise the + * predicate is also our positional-safety check. + * + *

If the rewrite applies, the FilteredExpression is replaced with + * the modified Union directly in the parent and we return null: + * the rewritten LocationSteps are then visited via this rewriter's + * {@link #rewriteLocationStep} from inside this method to apply the + * range Lookup substitution, since the surrounding optimizer's visitor + * has already walked them once before the predicates were attached. + */ + @Override + public Pragma rewriteFilteredExpression(final FilteredExpression filtered) throws XPathException { + if (!(filtered.getExpression() instanceof final Union union)) { + return null; + } + + final List branches = new ArrayList<>(); + if (!collectUnionLocationSteps(union, branches) || branches.size() < 2) { + return null; + } + + final List preds = filtered.getPredicates(); + if (preds == null || preds.isEmpty()) { + return null; + } + for (final Predicate pred : preds) { + if (pred.getLength() != 1) { + return null; + } + if (!isDistributablePredicate(pred.getExpression(0))) { + return null; + } + } + + for (final LocationStep step : branches) { + final int axis = step.getAxis(); + if (!(axis == Constants.CHILD_AXIS || axis == Constants.DESCENDANT_AXIS || + axis == Constants.DESCENDANT_SELF_AXIS || axis == Constants.ATTRIBUTE_AXIS || + axis == Constants.DESCENDANT_ATTRIBUTE_AXIS || axis == Constants.SELF_AXIS)) { + return null; + } + } + + // Idempotency: visitFilteredExpr can fire more than once on the same + // node (e.g. via a re-analyze-after-optimize pass). If we already + // distributed these predicates, the first branch will hold a + // Lookup-shaped predicate from a previous run — bail rather than + // double-add. + if (alreadyDistributed(branches.get(0), preds.size())) { + return null; + } + + // Distribute the predicates onto each branch's trailing LocationStep, + // cloning the Predicate wrapper so each branch gets its own Lookup + // with the correct context path; sharing a single Predicate object + // would let the first rewrite freeze the contextPath that the + // second branch sees. + // + // After attaching, re-analyze each clone with the branch as the + // contextStep so the inner GeneralComparison.axis is recomputed for + // the new predicate position. Without this re-analyze, the axis was + // computed against the FilteredExpression (not a LocationStep) and + // stayed UNKNOWN, causing rewriteLocationStep below to skip the + // predicate. + // + // We do NOT replace the FilteredExpression in its parent: doing so + // requires walking past type-check wrappers that aren't + // RewritableExpression (DynamicCardinalityCheck inside a function + // call argument, for one). Leaving the FilteredExpression in place + // means its own predicates re-evaluate at runtime, but on the + // already-pre-selected, much smaller node set the cost is + // negligible compared to the index speedup on each branch. + for (final LocationStep branch : branches) { + // Stage 1: attach cloned predicates and analyze them so the + // GeneralComparison.axis is recomputed for the new position. + for (final Predicate pred : preds) { + final Predicate clone = clonePredicate(pred); + branch.addPredicate(clone); + analyzeInBranchContext(branch, clone); + } + // Stage 2: let rewriteLocationStep substitute Lookup for the + // analyzed comparison. + final Pragma branchPragma = rewriteLocationStep(branch); + // Stage 3: re-analyze each predicate so the new Lookup picks up + // contextQName from its enclosing branch step. Without this, + // Lookup.canOptimizeSequence returns the empty sequence and the + // index is never consulted. + final Predicate[] now = branch.getPredicates(); + if (now != null) { + for (final Predicate p : now) { + analyzeInBranchContext(branch, p); + } + } + // Stage 4: wrap the branch in an exist:optimize ExtensionExpression + // so the runtime Optimize pragma calls canOptimizeSequence on the + // Lookup. Until that hook fires, Lookup.eval falls through to its + // non-indexed fallback. + wrapBranchInOptimize(branch, branchPragma); + } + + return null; + } + + private static void analyzeInBranchContext(final LocationStep branch, final Predicate pred) + throws XPathException { + final AnalyzeContextInfo info = new AnalyzeContextInfo(); + info.setParent(branch); + info.setContextStep(branch); + info.setStaticType(branch.getAxis() == Constants.SELF_AXIS + ? Type.ITEM : Type.NODE); + pred.analyze(info); + } + + /** + * Wrap a LocationStep in an exist:optimize ExtensionExpression so the + * runtime Optimize pragma calls {@link Optimizable#canOptimizeSequence} + * on the rewritten predicate's Lookup function. Without this, Lookup + * delegates to its non-indexed fallback. + */ + private void wrapBranchInOptimize(final LocationStep branch, final Pragma additionalPragma) + throws XPathException { + final Expression branchParent = branch.getParentExpression(); + if (!(branchParent instanceof final RewritableExpression branchPath)) { + return; + } + if (!hasOptimizableInPredicates(branch)) { + return; + } + final ExtensionExpression extension = new ExtensionExpression(getContext()); + if (additionalPragma != null) { + extension.addPragma(additionalPragma); + } + extension.addPragma(new Optimize(extension, getContext(), Optimize.OPTIMIZE_PRAGMA, null, false)); + extension.setExpression(branch); + branchPath.replace(branch, extension); + } + + private static boolean hasOptimizableInPredicates(final LocationStep step) { + final Predicate[] preds = step.getPredicates(); + if (preds == null) { + return false; + } + for (final Predicate pred : preds) { + if (pred.getLength() != 1) { + continue; + } + final Expression inner = pred.getExpression(0); + if (inner instanceof Optimizable) { + return true; + } + if (inner instanceof final InternalFunctionCall fcall + && fcall.getFunction() instanceof Optimizable) { + return true; + } + } + return false; + } + + /** + * Heuristic: a branch is "already distributed" if it has at least + * {@code expectedPredCount} predicates whose inner expression is + * something this rewriter can recognise as already-rewritten or + * still-rewritable. Used solely to short-circuit a second visit of + * the same FilteredExpression. + */ + private static boolean alreadyDistributed(final LocationStep step, final int expectedPredCount) { + final Predicate[] existing = step.getPredicates(); + if (existing == null || existing.length < expectedPredCount) { + return false; + } + // If the trailing N predicates each carry a Lookup-wrapped or + // distributable comparison, treat it as already rewritten. + int matched = 0; + for (int i = existing.length - expectedPredCount; i < existing.length; i++) { + final Predicate p = existing[i]; + if (p.getLength() != 1) { + return false; + } + final Expression inner = p.getExpression(0); + if (inner instanceof final InternalFunctionCall fcall + && fcall.getFunction() instanceof Lookup) { + matched++; + } else if (isDistributablePredicate(inner)) { + matched++; + } + } + return matched == expectedPredCount; + } + + private Predicate clonePredicate(final Predicate original) { + final Predicate clone = new Predicate(getContext()); + clone.setLocation(original.getLine(), original.getColumn()); + for (int i = 0; i < original.getLength(); i++) { + clone.add(original.getExpression(i)); + } + return clone; + } + + /** + * Collect the LocationStep that should receive a distributed predicate + * for each branch of a Union, descending recursively into nested unions. + * For multi-step paths like {@code //address/name}, the predicate applies + * to the trailing step (name), so we return that. Returns false if any + * branch is not a path ending in a LocationStep. + */ + private static boolean collectUnionLocationSteps(final Expression expr, final List out) { + Expression e = expr; + if (e instanceof final Union nested) { + return collectUnionLocationSteps(nested.getLeft(), out) + && collectUnionLocationSteps(nested.getRight(), out); + } + if (e instanceof final PathExpr path) { + if (path.getLength() == 0) { + return false; + } + final Expression last = path.getExpression(path.getLength() - 1); + if (last instanceof final LocationStep step) { + out.add(step); + return true; + } + // Single-step path wrapping a Union (rare, but possible). + if (path.getLength() == 1 && path.getExpression(0) instanceof Union) { + return collectUnionLocationSteps(path.getExpression(0), out); + } + return false; + } + if (e instanceof final LocationStep step) { + out.add(step); + return true; + } + return false; + } + + /** + * True when an expression appearing as the inner of a single-element + * predicate is one this rewriter knows how to translate via + * {@link #rewriteLocationStep}: a {@link GeneralComparison} or an + * {@code fn:matches} call wrapped in {@link InternalFunctionCall}. + * Both are non-positional, so distributing a predicate containing one + * does not change the set of nodes selected. + */ + private static boolean isDistributablePredicate(final Expression e) { + if (e instanceof GeneralComparison) { + return true; + } + if (e instanceof final InternalFunctionCall fcall) { + return fcall.getFunction() instanceof org.exist.xquery.functions.fn.FunMatches; + } + return false; + } + @Override public Pragma rewriteLocationStep(final LocationStep locationStep) throws XPathException { int axis = locationStep.getAxis(); diff --git a/extensions/indexes/range/src/test/resources-filtered/conf.xml b/extensions/indexes/range/src/test/resources-filtered/conf.xml index a22d440f625..b0bdd84d476 100644 --- a/extensions/indexes/range/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/range/src/test/resources-filtered/conf.xml @@ -908,6 +908,8 @@ + + diff --git a/extensions/indexes/range/src/test/xquery/range/optimizer.xql b/extensions/indexes/range/src/test/xquery/range/optimizer.xql index e407f36e194..83ceae15c63 100644 --- a/extensions/indexes/range/src/test/xquery/range/optimizer.xql +++ b/extensions/indexes/range/src/test/xquery/range/optimizer.xql @@ -945,4 +945,31 @@ declare %test:assertTrue function ot:issue4942-test-direct-without-pragma() { count(collection($ot:COLLECTION)//root/a[@ID = "123"]) eq 1 +}; + +(: GitHub #2363: range index ignored on (A | B)[pred] union pattern. + The QueryRewriter expansion distributes the predicate onto the trailing + LocationStep of each branch and substitutes range:eq for the + GeneralComparison. The structural rewrite is enough to keep results + correct on every branch with an applicable index config. Lifting the + runtime path to the OPTIMIZED level (so canOptimizeSequence fires on + each Lookup) is a follow-up — it requires the surrounding optimizer + pass to be re-run after distribution, which the rewriter cannot trigger + from inside its own hook. :) +declare + %test:args("Rudi Rüssel") + %test:assertEquals(2) +function ot:issue2363-union-predicate-result($name as xs:string) { + count((collection($ot:COLLECTION)//address/name | collection($ot:COLLECTION)//address/name2)[. = $name]) +}; + +(: Returning the matched names directly (not a count) checks that the + distributed predicate filters each branch correctly, not just that the + total count happens to be right. :) +declare + %test:args("Albert Amsel") + %test:assertEquals("Albert Amsel", "Albert Amsel") +function ot:issue2363-union-predicate-values($name as xs:string) { + for $n in (collection($ot:COLLECTION)//address/name | collection($ot:COLLECTION)//address/name2)[. = $name] + return string($n) }; \ No newline at end of file diff --git a/extensions/indexes/range/src/test/xquery/range/prefixed-conditions.xql b/extensions/indexes/range/src/test/xquery/range/prefixed-conditions.xql new file mode 100644 index 00000000000..cae7d1d4ab8 --- /dev/null +++ b/extensions/indexes/range/src/test/xquery/range/prefixed-conditions.xql @@ -0,0 +1,139 @@ +(: + : 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 + :) +xquery version "3.1"; + +(:~ + : Test prefixed attribute names in range index conditions. + : + : @see https://github.com/eXist-db/exist/issues/5189 + :) +module namespace pc="http://exist-db.org/xquery/range/test/prefixed-conditions"; + +import module namespace range="http://exist-db.org/xquery/range" at "java:org.exist.xquery.modules.range.RangeIndexModule"; +import module namespace test="http://exist-db.org/xquery/xqsuite" at "resource:org/exist/xquery/lib/xqsuite/xqsuite.xql"; + +declare namespace tei="http://www.tei-c.org/ns/1.0"; +declare namespace wwp="http://www.wwp.northeastern.edu/ns/textbase"; +declare namespace stats="http://exist-db.org/xquery/profiling"; + +declare variable $pc:COLLECTION_CONFIG := + + + + + + + + + + + + + + + + ; + +declare variable $pc:DATA := + + + + A Peep at the Pilgrims, 1824 + A Peep at the Pilgrims + + + Northeastern University Women Writers Project + + + + A peep at the pilgrims in sixteen hundred thirty-six. + + + + ; + +declare variable $pc:COLLECTION_NAME := "prefixed-conditions-test"; +declare variable $pc:COLLECTION := "/db/" || $pc:COLLECTION_NAME; + +declare +%test:setUp +function pc:setup() { + (xmldb:create-collection("/db/system", "config"), xmldb:create-collection("/db/system/config", "db")), + xmldb:create-collection("/db/system/config/db", $pc:COLLECTION_NAME), + xmldb:store("/db/system/config/db/" || $pc:COLLECTION_NAME, "collection.xconf", $pc:COLLECTION_CONFIG), + xmldb:create-collection("/db", $pc:COLLECTION_NAME), + xmldb:store($pc:COLLECTION, "data.xml", $pc:DATA) +}; + +declare +%test:tearDown +function pc:cleanup() { + xmldb:remove($pc:COLLECTION), + xmldb:remove("/db/system/config/db/" || $pc:COLLECTION_NAME) +}; + +(: Prefixed attribute condition should index matching elements :) +declare +%test:assertEquals(2) +function pc:prefixed-attribute-indexed() { + count(range:index-keys-for-field("wwp-attr", function($k, $n) { $k }, 10)) +}; + +(: Unprefixed attribute condition should also index matching elements :) +declare +%test:assertEquals(2) +function pc:ncname-attribute-indexed() { + count(range:index-keys-for-field("ncname-attr", function($k, $n) { $k }, 10)) +}; + +(: Prefixed attribute condition should return correct values :) +declare +%test:assertXPath("$result = 'A Peep at the Pilgrims' and $result = 'A peep at the pilgrims in sixteen hundred thirty-six.'") +function pc:prefixed-attribute-values() { + range:index-keys-for-field("wwp-attr", function($k, $n) { $k }, 10) +}; + +(: Unprefixed attribute condition should return correct values :) +declare +%test:assertXPath("$result = 'A Peep at the Pilgrims' and $result = 'A peep at the pilgrims in sixteen hundred thirty-six.'") +function pc:ncname-attribute-values() { + range:index-keys-for-field("ncname-attr", function($k, $n) { $k }, 10) +}; + +(: Optimizer should rewrite predicate on prefixed attribute :) +declare +%test:stats +%test:assertXPath("$result//stats:index[@type eq 'new-range'][@optimization-level eq 'OPTIMIZED']") +function pc:optimize-prefixed-attribute() { + collection($pc:COLLECTION)//tei:title[@wwp:field eq "title"][. = "A Peep at the Pilgrims"] +}; + +(: Optimizer should rewrite predicate on unprefixed attribute :) +declare +%test:stats +%test:assertXPath("$result//stats:index[@type eq 'new-range'][@optimization-level eq 'OPTIMIZED']") +function pc:optimize-ncname-attribute() { + collection($pc:COLLECTION)//tei:title[@field eq "title"][. = "A Peep at the Pilgrims"] +}; diff --git a/extensions/indexes/sort/src/test/resources-filtered/conf.xml b/extensions/indexes/sort/src/test/resources-filtered/conf.xml index e6d70cea684..3fef484b305 100644 --- a/extensions/indexes/sort/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/sort/src/test/resources-filtered/conf.xml @@ -903,6 +903,8 @@ + + diff --git a/extensions/indexes/spatial/src/main/java/org/exist/indexing/spatial/AbstractGMLJDBCIndexWorker.java b/extensions/indexes/spatial/src/main/java/org/exist/indexing/spatial/AbstractGMLJDBCIndexWorker.java index 7228a15aca4..6cccd9d7ad0 100644 --- a/extensions/indexes/spatial/src/main/java/org/exist/indexing/spatial/AbstractGMLJDBCIndexWorker.java +++ b/extensions/indexes/spatial/src/main/java/org/exist/indexing/spatial/AbstractGMLJDBCIndexWorker.java @@ -632,7 +632,21 @@ public Element streamGeometryToElement(final Geometry geometry, final String srs //2) gmlPrefix //3) other stuff... //This will possibly require some changes in GeometryTransformer - gmlString = gmlTransformer.transform(geometry); + // Force the JDK's built-in TransformerFactory for GeoTools. + // Saxon 12's IdentityTransformer rejects SAXSources whose XMLReader + // does not support the lexical-handler property, which GeoTools' reader doesn't. + final String savedFactory = System.getProperty("javax.xml.transform.TransformerFactory"); + try { + System.setProperty("javax.xml.transform.TransformerFactory", + "com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl"); + gmlString = gmlTransformer.transform(geometry); + } finally { + if (savedFactory != null) { + System.setProperty("javax.xml.transform.TransformerFactory", savedFactory); + } else { + System.clearProperty("javax.xml.transform.TransformerFactory"); + } + } } catch (final TransformerException e) { throw new SpatialIndexException(e); } diff --git a/extensions/indexes/spatial/src/test/resources-filtered/conf.xml b/extensions/indexes/spatial/src/test/resources-filtered/conf.xml index b3ea3200f72..d645864b3c6 100644 --- a/extensions/indexes/spatial/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/spatial/src/test/resources-filtered/conf.xml @@ -889,6 +889,8 @@ + + diff --git a/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml b/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml index 2c516c836c9..6c8a08fd714 100644 --- a/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/vector-it/src/test/resources-filtered/conf.xml @@ -74,6 +74,8 @@ + + diff --git a/extensions/modules/cache/src/test/resources-filtered/conf.xml b/extensions/modules/cache/src/test/resources-filtered/conf.xml index af9663be608..b33bf9f2621 100644 --- a/extensions/modules/cache/src/test/resources-filtered/conf.xml +++ b/extensions/modules/cache/src/test/resources-filtered/conf.xml @@ -760,6 +760,8 @@ + + diff --git a/extensions/modules/compression/src/test/resources-filtered/conf.xml b/extensions/modules/compression/src/test/resources-filtered/conf.xml index 0bdebfee2d6..c73b3781ed9 100644 --- a/extensions/modules/compression/src/test/resources-filtered/conf.xml +++ b/extensions/modules/compression/src/test/resources-filtered/conf.xml @@ -757,6 +757,8 @@ + + diff --git a/extensions/modules/compression/src/test/xquery/modules/compression/unzip-tests.xql b/extensions/modules/compression/src/test/xquery/modules/compression/unzip-tests.xql index d403dd6c706..ff66b5e9487 100644 --- a/extensions/modules/compression/src/test/xquery/modules/compression/unzip-tests.xql +++ b/extensions/modules/compression/src/test/xquery/modules/compression/unzip-tests.xql @@ -44,7 +44,7 @@ declare variable $uz:myStaticCP437ContentBase64 := xs:base64Binary("UEsDBBQACAAI (: declare helper functions :) -declare function local:entry-data($path as xs:anyURI, $type as xs:string, $data as item()?, $param as item()*) as item()? +declare function uz:entry-data($path as xs:anyURI, $type as xs:string, $data as item()?, $param as item()*) as item()? { {$path} @@ -54,7 +54,7 @@ declare function local:entry-data($path as xs:anyURI, $type as xs:string, $data }; (: Process every Zip Collections and Resources :) -declare function local:entry-filter($path as xs:anyURI, $type as xs:string, $param as item()*) as xs:boolean +declare function uz:entry-filter($path as xs:anyURI, $type as xs:string, $param as item()*) as xs:boolean { true() }; @@ -63,14 +63,14 @@ declare %test:user("guest", "guest") %test:assertEquals("!#$%()*+,-.:;=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{}~ ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿αßΓπΣσµτΦΘΩδ∞φε.xmlresource") function uz:fnUzipUtf8Content() { - compression:unzip($uz:myStaticUTF8ContentBase64, util:function(xs:QName("local:entry-filter"), 3), (), util:function(xs:QName("local:entry-data"), 4), (), "UTF8") + compression:unzip($uz:myStaticUTF8ContentBase64, util:function(xs:QName("uz:entry-filter"), 3), (), util:function(xs:QName("uz:entry-data"), 4), (), "UTF8") }; declare %test:user("guest", "guest") %test:assertEquals("!#$%()*+,-.:;=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{}~ ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿αßΓπΣσµτΦΘΩδ∞φε.xmlresource") function uz:fnUzipCp437Content() { - compression:unzip($uz:myStaticCP437ContentBase64, util:function(xs:QName("local:entry-filter"), 3), (), util:function(xs:QName("local:entry-data"), 4), (), "Cp437") + compression:unzip($uz:myStaticCP437ContentBase64, util:function(xs:QName("uz:entry-filter"), 3), (), util:function(xs:QName("uz:entry-data"), 4), (), "Cp437") }; declare @@ -78,7 +78,7 @@ declare %test:assertEquals("!#$%()*+,-.:;=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{}~ ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿αßΓπΣσµτΦΘΩδ∞φε.xmlresource") function uz:fnUzipUtf8ContentWrongEncoding() { (: This case is working due to the selected cp437 character in the filename :) - compression:unzip($uz:myStaticUTF8ContentBase64, util:function(xs:QName("local:entry-filter"), 3), (), util:function(xs:QName("local:entry-data"), 4), (), "Cp437") + compression:unzip($uz:myStaticUTF8ContentBase64, util:function(xs:QName("uz:entry-filter"), 3), (), util:function(xs:QName("uz:entry-data"), 4), (), "Cp437") }; declare @@ -86,5 +86,5 @@ declare %test:assertError("(?:MALFORMED|malformed|invalid LOC header)") function uz:fnUzipCp437ContentWrongEncoding() { (: This case is not working because the Unicode extended filename table is not present in non unicode encoded Zip :) - compression:unzip($uz:myStaticCP437ContentBase64, util:function(xs:QName("local:entry-filter"), 3), (), util:function(xs:QName("local:entry-data"), 4), (), "UTF8") + compression:unzip($uz:myStaticCP437ContentBase64, util:function(xs:QName("uz:entry-filter"), 3), (), util:function(xs:QName("uz:entry-data"), 4), (), "UTF8") }; diff --git a/extensions/modules/counter/src/test/resources-filtered/conf.xml b/extensions/modules/counter/src/test/resources-filtered/conf.xml index 1a31ae00a0e..c9829ffbe40 100644 --- a/extensions/modules/counter/src/test/resources-filtered/conf.xml +++ b/extensions/modules/counter/src/test/resources-filtered/conf.xml @@ -746,6 +746,8 @@ + + diff --git a/extensions/modules/expath-binary/pom.xml b/extensions/modules/expath-binary/pom.xml new file mode 100644 index 00000000000..6f0723e05de --- /dev/null +++ b/extensions/modules/expath-binary/pom.xml @@ -0,0 +1,67 @@ + + + + 4.0.0 + + + org.exist-db + exist-parent + 7.0.0-SNAPSHOT + ../../../exist-parent + + + exist-expath-binary + jar + + eXist-db EXPath Binary Module + EXPath Binary Module 4.0 for eXist-db (http://expath.org/ns/binary) + + + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + HEAD + + + + + org.exist-db + exist-core + ${project.version} + + + + commons-io + commons-io + + + + com.google.code.findbugs + jsr305 + + + + + diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryBasicFunctions.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryBasicFunctions.java new file mode 100644 index 00000000000..f78789e2f06 --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryBasicFunctions.java @@ -0,0 +1,326 @@ +/* + * 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.modules.binary; + +import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.Type; + +import java.io.IOException; +import java.util.Arrays; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * EXPath Binary Module 4.0 — Basic Operations (Section 5). + * + *

    + *
  • bin:length
  • + *
  • bin:part
  • + *
  • bin:join
  • + *
  • bin:insert-before
  • + *
  • bin:pad-left
  • + *
  • bin:pad-right
  • + *
  • bin:find
  • + *
+ * + * @see EXPath Binary Module 4.0 §5 + */ +public class BinaryBasicFunctions extends BasicFunction { + + private static final QName QN_LENGTH = new QName("length", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_PART = new QName("part", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_JOIN = new QName("join", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_INSERT_BEFORE = new QName("insert-before", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_PAD_LEFT = new QName("pad-left", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_PAD_RIGHT = new QName("pad-right", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_FIND = new QName("find", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + + static final FunctionSignature FS_LENGTH = functionSignature( + QN_LENGTH, + "Returns the size of binary data in octets.", + returns(Type.INTEGER), + param("value", Type.BASE64_BINARY, "The binary data") + ); + + static final FunctionSignature[] FS_PART = functionSignatures( + QN_PART, + "Returns a specified part of binary data.", + returnsOpt(Type.BASE64_BINARY), + arities( + arity( + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based offset") + ), + arity( + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based offset"), + param("size", Type.INTEGER, "The number of octets to return") + ) + ) + ); + + static final FunctionSignature FS_JOIN = functionSignature( + QN_JOIN, + "Returns the concatenation of binary data.", + returns(Type.BASE64_BINARY), + optManyParam("values", Type.BASE64_BINARY, "The binary data items to join") + ); + + static final FunctionSignature FS_INSERT_BEFORE = functionSignature( + QN_INSERT_BEFORE, + "Inserts additional binary data at a given point in other binary data.", + returnsOpt(Type.BASE64_BINARY), + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based offset for insertion"), + optParam("extra", Type.BASE64_BINARY, "The binary data to insert") + ); + + static final FunctionSignature[] FS_PAD_LEFT = functionSignatures( + QN_PAD_LEFT, + "Pads binary data on the left to a specified size.", + returnsOpt(Type.BASE64_BINARY), + arities( + arity( + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("count", Type.INTEGER, "The number of octets to pad") + ), + arity( + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("count", Type.INTEGER, "The number of octets to pad"), + param("octet", Type.INTEGER, "The octet value to use for padding (0-255)") + ) + ) + ); + + static final FunctionSignature[] FS_PAD_RIGHT = functionSignatures( + QN_PAD_RIGHT, + "Pads binary data on the right to a specified size.", + returnsOpt(Type.BASE64_BINARY), + arities( + arity( + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("count", Type.INTEGER, "The number of octets to pad") + ), + arity( + optParam("value", Type.BASE64_BINARY, "The binary data"), + param("count", Type.INTEGER, "The number of octets to pad"), + param("octet", Type.INTEGER, "The octet value to use for padding (0-255)") + ) + ) + ); + + static final FunctionSignature FS_FIND = functionSignature( + QN_FIND, + "Returns the first location of a binary search sequence within binary data.", + returnsOpt(Type.INTEGER), + optParam("value", Type.BASE64_BINARY, "The binary data to search"), + param("offset", Type.INTEGER, "The zero-based offset to start searching from"), + param("search", Type.BASE64_BINARY, "The binary data to search for") + ); + + public BinaryBasicFunctions(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + if (isCalledAs("length")) { + return length(args); + } else if (isCalledAs("part")) { + return part(args); + } else if (isCalledAs("join")) { + return join(args); + } else if (isCalledAs("insert-before")) { + return insertBefore(args); + } else if (isCalledAs("pad-left")) { + return padLeft(args); + } else if (isCalledAs("pad-right")) { + return padRight(args); + } else { + return find(args); + } + } + + private Sequence length(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + throw new XPathException(this, org.exist.xquery.ErrorCodes.XPTY0004, + "Empty sequence is not allowed as argument to bin:length()"); + } + return new IntegerValue(this, data.length); + } + + private Sequence part(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + if (offset < 0 || offset > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " is out of range for binary data of length " + data.length); + } + + final int size; + if (args.length > 2 && !args[2].isEmpty()) { + size = ((IntegerValue) args[2].itemAt(0)).getInt(); + if (size < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Size must not be negative: " + size); + } + if (offset + size > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " + size " + size + " exceeds binary data length " + data.length); + } + } else { + size = data.length - offset; + } + + final byte[] result = Arrays.copyOfRange(data, offset, offset + size); + return BinaryModuleHelper.createBinaryResult(context, this, result); + } + + private Sequence join(final Sequence[] args) throws XPathException { + if (args[0].isEmpty()) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { + for (int i = 0; i < args[0].getItemCount(); i++) { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0].itemAt(i).toSequence()); + if (data != null) { + os.write(data); + } + } + return BinaryModuleHelper.createBinaryResult(context, this, os.toByteArray()); + } catch (final IOException e) { + throw new XPathException(this, "Failed to join binary data: " + e.getMessage(), e); + } + } + + private Sequence insertBefore(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + if (offset < 0 || offset > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " is out of range for binary data of length " + data.length); + } + + final byte[] extra = BinaryModuleHelper.getBinaryData(args[2]); + if (extra == null || extra.length == 0) { + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + final byte[] result = new byte[data.length + extra.length]; + System.arraycopy(data, 0, result, 0, offset); + System.arraycopy(extra, 0, result, offset, extra.length); + System.arraycopy(data, offset, result, offset + extra.length, data.length - offset); + return BinaryModuleHelper.createBinaryResult(context, this, result); + } + + private Sequence padLeft(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final int count = ((IntegerValue) args[1].itemAt(0)).getInt(); + if (count < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Pad count must not be negative: " + count); + } + + final byte octet = (args.length > 2 && !args[2].isEmpty()) + ? (byte) ((IntegerValue) args[2].itemAt(0)).getInt() + : 0; + + final byte[] result = new byte[data.length + count]; + Arrays.fill(result, 0, count, octet); + System.arraycopy(data, 0, result, count, data.length); + return BinaryModuleHelper.createBinaryResult(context, this, result); + } + + private Sequence padRight(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final int count = ((IntegerValue) args[1].itemAt(0)).getInt(); + if (count < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Pad count must not be negative: " + count); + } + + final byte octet = (args.length > 2 && !args[2].isEmpty()) + ? (byte) ((IntegerValue) args[2].itemAt(0)).getInt() + : 0; + + final byte[] result = new byte[data.length + count]; + System.arraycopy(data, 0, result, 0, data.length); + Arrays.fill(result, data.length, result.length, octet); + return BinaryModuleHelper.createBinaryResult(context, this, result); + } + + private Sequence find(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + if (offset < 0 || offset > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " is out of range for binary data of length " + data.length); + } + + final byte[] search = BinaryModuleHelper.getBinaryData(args[2]); + if (search == null || search.length == 0) { + return new IntegerValue(this, offset); + } + + // Naive byte subsequence search + outer: + for (int i = offset; i <= data.length - search.length; i++) { + for (int j = 0; j < search.length; j++) { + if (data[i + j] != search[j]) { + continue outer; + } + } + return new IntegerValue(this, i); + } + + return Sequence.EMPTY_SEQUENCE; + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryBitwiseFunctions.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryBitwiseFunctions.java new file mode 100644 index 00000000000..ab13dc209ec --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryBitwiseFunctions.java @@ -0,0 +1,199 @@ +/* + * 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.modules.binary; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.Type; + +import java.math.BigInteger; +import java.util.Arrays; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * EXPath Binary Module 4.0 — Bitwise Operations (Section 8). + * + *
    + *
  • bin:or
  • + *
  • bin:xor
  • + *
  • bin:and
  • + *
  • bin:not
  • + *
  • bin:shift
  • + *
+ * + * @see EXPath Binary Module 4.0 §8 + */ +public class BinaryBitwiseFunctions extends BasicFunction { + + private static final QName QN_OR = new QName("or", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_XOR = new QName("xor", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_AND = new QName("and", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_NOT = new QName("not", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_SHIFT = new QName("shift", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + + static final FunctionSignature FS_OR = functionSignature( + QN_OR, + "Returns the bitwise OR of two binary values.", + returnsOpt(Type.BASE64_BINARY), + optParam("value1", Type.BASE64_BINARY, "The first binary value"), + optParam("value2", Type.BASE64_BINARY, "The second binary value") + ); + + static final FunctionSignature FS_XOR = functionSignature( + QN_XOR, + "Returns the bitwise XOR of two binary values.", + returnsOpt(Type.BASE64_BINARY), + optParam("value1", Type.BASE64_BINARY, "The first binary value"), + optParam("value2", Type.BASE64_BINARY, "The second binary value") + ); + + static final FunctionSignature FS_AND = functionSignature( + QN_AND, + "Returns the bitwise AND of two binary values.", + returnsOpt(Type.BASE64_BINARY), + optParam("value1", Type.BASE64_BINARY, "The first binary value"), + optParam("value2", Type.BASE64_BINARY, "The second binary value") + ); + + static final FunctionSignature FS_NOT = functionSignature( + QN_NOT, + "Returns the bitwise NOT of a binary value.", + returnsOpt(Type.BASE64_BINARY), + optParam("value", Type.BASE64_BINARY, "The binary value") + ); + + static final FunctionSignature FS_SHIFT = functionSignature( + QN_SHIFT, + "Shifts bits in binary data. Positive shifts left, negative shifts right.", + returnsOpt(Type.BASE64_BINARY), + optParam("value", Type.BASE64_BINARY, "The binary value"), + param("by", Type.INTEGER, "The number of bits to shift (positive = left, negative = right)") + ); + + public BinaryBitwiseFunctions(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + if (isCalledAs("or")) { + return bitwiseOp(args, BitwiseOp.OR); + } else if (isCalledAs("xor")) { + return bitwiseOp(args, BitwiseOp.XOR); + } else if (isCalledAs("and")) { + return bitwiseOp(args, BitwiseOp.AND); + } else if (isCalledAs("not")) { + return bitwiseNot(args); + } else { + return bitwiseShift(args); + } + } + + private enum BitwiseOp { OR, XOR, AND } + + private Sequence bitwiseOp(final Sequence[] args, final BitwiseOp op) throws XPathException { + final byte[] data1 = BinaryModuleHelper.getBinaryData(args[0]); + final byte[] data2 = BinaryModuleHelper.getBinaryData(args[1]); + + if (data1 == null || data2 == null) { + return Sequence.EMPTY_SEQUENCE; + } + + if (data1.length != data2.length) { + throw new XPathException(this, BinaryModuleErrorCode.DIFFERING_LENGTH_ARGUMENTS, + "Arguments to bin:" + op.name().toLowerCase() + "() must have equal length, but got " + + data1.length + " and " + data2.length); + } + + final byte[] result = new byte[data1.length]; + for (int i = 0; i < data1.length; i++) { + switch (op) { + case OR: + result[i] = (byte) (data1[i] | data2[i]); + break; + case XOR: + result[i] = (byte) (data1[i] ^ data2[i]); + break; + case AND: + result[i] = (byte) (data1[i] & data2[i]); + break; + } + } + return BinaryModuleHelper.createBinaryResult(context, this, result); + } + + private Sequence bitwiseNot(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final byte[] result = new byte[data.length]; + for (int i = 0; i < data.length; i++) { + result[i] = (byte) ~data[i]; + } + return BinaryModuleHelper.createBinaryResult(context, this, result); + } + + private Sequence bitwiseShift(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final int by = ((IntegerValue) args[1].itemAt(0)).getInt(); + final int originalLength = data.length; + + if (originalLength == 0) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + // Use BigInteger for bit shifting, then maintain original length + BigInteger bigInt = new BigInteger(1, data); + + if (by > 0) { + bigInt = bigInt.shiftLeft(by); + } else if (by < 0) { + bigInt = bigInt.shiftRight(-by); + } + + // Convert back to byte array of original length + final byte[] shifted = bigInt.toByteArray(); + final byte[] result = new byte[originalLength]; + + if (shifted.length <= originalLength) { + // Right-align in result + System.arraycopy(shifted, 0, result, originalLength - shifted.length, shifted.length); + } else { + // Truncate from the left to maintain original length + System.arraycopy(shifted, shifted.length - originalLength, result, 0, originalLength); + } + + return BinaryModuleHelper.createBinaryResult(context, this, result); + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryConversionFunctions.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryConversionFunctions.java new file mode 100644 index 00000000000..8ec6f9614fa --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryConversionFunctions.java @@ -0,0 +1,258 @@ +/* + * 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.modules.binary; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * EXPath Binary Module 4.0 — Constants and Conversions (Section 4). + * + *
    + *
  • bin:hex
  • + *
  • bin:bin
  • + *
  • bin:octal
  • + *
  • bin:to-octets
  • + *
  • bin:from-octets
  • + *
+ * + * @see EXPath Binary Module 4.0 §4 + */ +public class BinaryConversionFunctions extends BasicFunction { + + private static final QName QN_HEX = new QName("hex", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_BIN = new QName("bin", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_OCTAL = new QName("octal", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_TO_OCTETS = new QName("to-octets", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_FROM_OCTETS = new QName("from-octets", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + + static final FunctionSignature FS_HEX = functionSignature( + QN_HEX, + "Creates an xs:base64Binary value from a hexadecimal string.", + returnsOpt(Type.BASE64_BINARY), + optParam("value", Type.STRING, "The hexadecimal string") + ); + + static final FunctionSignature FS_BIN = functionSignature( + QN_BIN, + "Creates an xs:base64Binary value from a binary (0/1) string.", + returnsOpt(Type.BASE64_BINARY), + optParam("value", Type.STRING, "The binary digit string") + ); + + static final FunctionSignature FS_OCTAL = functionSignature( + QN_OCTAL, + "Creates an xs:base64Binary value from an octal string.", + returnsOpt(Type.BASE64_BINARY), + optParam("value", Type.STRING, "The octal string") + ); + + static final FunctionSignature FS_TO_OCTETS = functionSignature( + QN_TO_OCTETS, + "Returns the binary data as a sequence of octets.", + returnsOptMany(Type.INTEGER), + param("value", Type.BASE64_BINARY, "The binary data") + ); + + static final FunctionSignature FS_FROM_OCTETS = functionSignature( + QN_FROM_OCTETS, + "Converts a sequence of octets into binary data.", + returns(Type.BASE64_BINARY), + optManyParam("values", Type.INTEGER, "The octet values (0-255)") + ); + + public BinaryConversionFunctions(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + if (isCalledAs("hex")) { + return hexToBinary(args); + } else if (isCalledAs("bin")) { + return binToBinary(args); + } else if (isCalledAs("octal")) { + return octalToBinary(args); + } else if (isCalledAs("to-octets")) { + return toOctets(args); + } else { + return fromOctets(args); + } + } + + private Sequence hexToBinary(final Sequence[] args) throws XPathException { + if (args[0].isEmpty()) { + return Sequence.EMPTY_SEQUENCE; + } + + String hex = args[0].getStringValue(); + // Strip whitespace and underscores per spec + hex = hex.replaceAll("[\\s_]", ""); + + if (hex.isEmpty()) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + // Validate characters + for (int i = 0; i < hex.length(); i++) { + final char c = hex.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + throw new XPathException(this, BinaryModuleErrorCode.NON_NUMERIC_CHARACTER, + "Invalid hexadecimal character: '" + c + "'"); + } + } + + // Prepend "0" if odd length + if (hex.length() % 2 != 0) { + hex = "0" + hex; + } + + final byte[] data = new byte[hex.length() / 2]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + private Sequence binToBinary(final Sequence[] args) throws XPathException { + if (args[0].isEmpty()) { + return Sequence.EMPTY_SEQUENCE; + } + + String bin = args[0].getStringValue(); + bin = bin.replaceAll("[\\s_]", ""); + + if (bin.isEmpty()) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + // Validate characters + for (int i = 0; i < bin.length(); i++) { + final char c = bin.charAt(i); + if (c != '0' && c != '1') { + throw new XPathException(this, BinaryModuleErrorCode.NON_NUMERIC_CHARACTER, + "Invalid binary character: '" + c + "'"); + } + } + + // Pad to 8-bit multiple + final int remainder = bin.length() % 8; + if (remainder != 0) { + bin = "0".repeat(8 - remainder) + bin; + } + + final byte[] data = new byte[bin.length() / 8]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) Integer.parseInt(bin.substring(i * 8, i * 8 + 8), 2); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + private Sequence octalToBinary(final Sequence[] args) throws XPathException { + if (args[0].isEmpty()) { + return Sequence.EMPTY_SEQUENCE; + } + + String octal = args[0].getStringValue(); + octal = octal.replaceAll("[\\s_]", ""); + + if (octal.isEmpty()) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + // Validate characters + for (int i = 0; i < octal.length(); i++) { + final char c = octal.charAt(i); + if (c < '0' || c > '7') { + throw new XPathException(this, BinaryModuleErrorCode.NON_NUMERIC_CHARACTER, + "Invalid octal character: '" + c + "'"); + } + } + + // Convert each octal digit to 3-bit binary + final StringBuilder bits = new StringBuilder(); + for (int i = 0; i < octal.length(); i++) { + final int digit = octal.charAt(i) - '0'; + bits.append(String.format("%3s", Integer.toBinaryString(digit)).replace(' ', '0')); + } + + // Strip up to 2 leading zeros (octal digit = 3 bits, but only multiples of 8 matter) + String binaryStr = bits.toString(); + int stripCount = 0; + while (stripCount < 2 && binaryStr.length() > 0 && binaryStr.charAt(0) == '0' + && (binaryStr.length() - 1) % 8 != 7) { + binaryStr = binaryStr.substring(1); + stripCount++; + } + + // Pad to 8-bit multiple + final int remainder = binaryStr.length() % 8; + if (remainder != 0) { + binaryStr = "0".repeat(8 - remainder) + binaryStr; + } + + if (binaryStr.isEmpty()) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + final byte[] data = new byte[binaryStr.length() / 8]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) Integer.parseInt(binaryStr.substring(i * 8, i * 8 + 8), 2); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + private Sequence toOctets(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null || data.length == 0) { + return Sequence.EMPTY_SEQUENCE; + } + + final ValueSequence result = new ValueSequence(data.length); + for (final byte b : data) { + result.add(new IntegerValue(this, b & 0xFF)); + } + return result; + } + + private Sequence fromOctets(final Sequence[] args) throws XPathException { + if (args[0].isEmpty()) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + final int len = args[0].getItemCount(); + final byte[] data = new byte[len]; + for (int i = 0; i < len; i++) { + data[i] = (byte) ((IntegerValue) args[0].itemAt(i)).getInt(); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryInferEncodingFunction.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryInferEncodingFunction.java new file mode 100644 index 00000000000..d0c93fd202a --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryInferEncodingFunction.java @@ -0,0 +1,152 @@ +/* + * 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.modules.binary; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; + +import java.nio.charset.Charset; +import java.nio.charset.UnsupportedCharsetException; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * EXPath Binary Module 4.0 — bin:infer-encoding (Section 6.3). + * + *

Infers the actual encoding and byte offset of text data within binary data, + * based on BOM detection and the declared encoding.

+ * + * @see EXPath Binary Module 4.0 §6.3 + */ +public class BinaryInferEncodingFunction extends BasicFunction { + + private static final QName QN_INFER_ENCODING = new QName("infer-encoding", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + + private static final byte[] UTF8_BOM = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; + private static final byte[] UTF16_BE_BOM = {(byte) 0xFE, (byte) 0xFF}; + private static final byte[] UTF16_LE_BOM = {(byte) 0xFF, (byte) 0xFE}; + + static final FunctionSignature[] FS_INFER_ENCODING = functionSignatures( + QN_INFER_ENCODING, + "Infers the actual encoding and data offset from binary data, detecting BOMs and resolving encoding families.", + returns(Type.MAP_ITEM), + arities( + arity( + param("data", Type.BASE64_BINARY, "The binary data to analyze") + ), + arity( + param("data", Type.BASE64_BINARY, "The binary data to analyze"), + optParam("encoding", Type.STRING, "The declared encoding (default: UTF-8)") + ) + ) + ); + + public BinaryInferEncodingFunction(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + throw new XPathException(this, org.exist.xquery.ErrorCodes.XPTY0004, + "Empty sequence is not allowed as the first argument of bin:infer-encoding"); + } + + final String declaredEncoding; + if (args.length > 1 && !args[1].isEmpty()) { + declaredEncoding = args[1].getStringValue(); + } else { + declaredEncoding = "UTF-8"; + } + + validateEncoding(declaredEncoding); + + final String normalizedEncoding = normalizeEncodingFamily(declaredEncoding); + String resultEncoding = declaredEncoding; + int resultOffset = 0; + + if (isUtf8Family(normalizedEncoding)) { + if (startsWith(data, UTF8_BOM)) { + resultEncoding = "UTF-8"; + resultOffset = 3; + } + } else if (isUtf16Family(normalizedEncoding)) { + if (startsWith(data, UTF16_BE_BOM)) { + resultEncoding = "UTF-16BE"; + resultOffset = 2; + } else if (startsWith(data, UTF16_LE_BOM)) { + resultEncoding = "UTF-16LE"; + resultOffset = 2; + } else if ("UTF-16".equalsIgnoreCase(normalizedEncoding)) { + resultEncoding = "UTF-16BE"; + resultOffset = 0; + } + } + + final MapType result = new MapType(this, context); + result.add(new StringValue(this, "encoding"), new StringValue(this, resultEncoding)); + result.add(new StringValue(this, "offset"), new IntegerValue(this, resultOffset)); + return result; + } + + private void validateEncoding(final String encoding) throws XPathException { + try { + Charset.forName(encoding); + } catch (final UnsupportedCharsetException e) { + throw new XPathException(this, BinaryModuleErrorCode.UNKNOWN_ENCODING, + "Unknown encoding: '" + encoding + "'"); + } + } + + private static String normalizeEncodingFamily(final String encoding) { + return encoding.toUpperCase().replace("-", "").replace("_", ""); + } + + private static boolean isUtf8Family(final String normalized) { + return "UTF8".equals(normalized); + } + + private static boolean isUtf16Family(final String normalized) { + return "UTF16".equals(normalized) || "UTF16BE".equals(normalized) || "UTF16LE".equals(normalized); + } + + private static boolean startsWith(final byte[] data, final byte[] prefix) { + if (data.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (data[i] != prefix[i]) { + return false; + } + } + return true; + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModule.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModule.java new file mode 100644 index 00000000000..18c4c905645 --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModule.java @@ -0,0 +1,123 @@ +/* + * 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.modules.binary; + +import org.exist.xquery.AbstractInternalModule; +import org.exist.xquery.FunctionDef; + +import java.util.List; +import java.util.Map; + +import static org.exist.xquery.FunctionDSL.functionDefs; + +/** + * EXPath Binary Module 4.0. + * + * @see EXPath Binary Module 4.0 + */ +public class BinaryModule extends AbstractInternalModule { + + public static final String NAMESPACE_URI = "http://expath.org/ns/binary"; + public static final String PREFIX = "bin"; + public static final String INCLUSION_DATE = "2026-03-04"; + public static final String RELEASED_IN_VERSION = "eXist-7.0.0"; + + private static final FunctionDef[] functions = functionDefs( + functionDefs(BinaryConversionFunctions.class, + BinaryConversionFunctions.FS_HEX, + BinaryConversionFunctions.FS_BIN, + BinaryConversionFunctions.FS_OCTAL, + BinaryConversionFunctions.FS_TO_OCTETS, + BinaryConversionFunctions.FS_FROM_OCTETS), + + functionDefs(BinaryBasicFunctions.class, + BinaryBasicFunctions.FS_LENGTH, + BinaryBasicFunctions.FS_PART[0], + BinaryBasicFunctions.FS_PART[1], + BinaryBasicFunctions.FS_JOIN, + BinaryBasicFunctions.FS_INSERT_BEFORE, + BinaryBasicFunctions.FS_PAD_LEFT[0], + BinaryBasicFunctions.FS_PAD_LEFT[1], + BinaryBasicFunctions.FS_PAD_RIGHT[0], + BinaryBasicFunctions.FS_PAD_RIGHT[1], + BinaryBasicFunctions.FS_FIND), + + functionDefs(BinaryTextFunctions.class, + BinaryTextFunctions.FS_DECODE_STRING[0], + BinaryTextFunctions.FS_DECODE_STRING[1], + BinaryTextFunctions.FS_DECODE_STRING[2], + BinaryTextFunctions.FS_DECODE_STRING[3], + BinaryTextFunctions.FS_ENCODE_STRING[0], + BinaryTextFunctions.FS_ENCODE_STRING[1]), + + functionDefs(BinaryPackingFunctions.class, + BinaryPackingFunctions.FS_PACK_DOUBLE[0], + BinaryPackingFunctions.FS_PACK_DOUBLE[1], + BinaryPackingFunctions.FS_PACK_FLOAT[0], + BinaryPackingFunctions.FS_PACK_FLOAT[1], + BinaryPackingFunctions.FS_PACK_INTEGER[0], + BinaryPackingFunctions.FS_PACK_INTEGER[1], + BinaryPackingFunctions.FS_UNPACK_DOUBLE[0], + BinaryPackingFunctions.FS_UNPACK_DOUBLE[1], + BinaryPackingFunctions.FS_UNPACK_FLOAT[0], + BinaryPackingFunctions.FS_UNPACK_FLOAT[1], + BinaryPackingFunctions.FS_UNPACK_INTEGER[0], + BinaryPackingFunctions.FS_UNPACK_INTEGER[1], + BinaryPackingFunctions.FS_UNPACK_UNSIGNED_INTEGER[0], + BinaryPackingFunctions.FS_UNPACK_UNSIGNED_INTEGER[1]), + + functionDefs(BinaryBitwiseFunctions.class, + BinaryBitwiseFunctions.FS_OR, + BinaryBitwiseFunctions.FS_XOR, + BinaryBitwiseFunctions.FS_AND, + BinaryBitwiseFunctions.FS_NOT, + BinaryBitwiseFunctions.FS_SHIFT), + + functionDefs(BinaryInferEncodingFunction.class, + BinaryInferEncodingFunction.FS_INFER_ENCODING[0], + BinaryInferEncodingFunction.FS_INFER_ENCODING[1]) + ); + + public BinaryModule(final Map> parameters) { + super(functions, parameters); + } + + @Override + public String getNamespaceURI() { + return NAMESPACE_URI; + } + + @Override + public String getDefaultPrefix() { + return PREFIX; + } + + @Override + public String getDescription() { + return "EXPath Binary Module 4.0 https://qt4cg.org/specifications/expath-binary-40/Overview.html"; + } + + @Override + public String getReleaseVersion() { + return RELEASED_IN_VERSION; + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModuleErrorCode.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModuleErrorCode.java new file mode 100644 index 00000000000..cd2b0ad8daa --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModuleErrorCode.java @@ -0,0 +1,68 @@ +/* + * 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.modules.binary; + +import org.exist.dom.QName; +import org.exist.xquery.ErrorCodes.ErrorCode; + +/** + * Error codes for the EXPath Binary Module 4.0. + * + * @see EXPath Binary Module 4.0 - Errors + */ +public class BinaryModuleErrorCode { + + public static final ErrorCode NON_NUMERIC_CHARACTER = new ErrorCode( + new QName("non-numeric-character", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "The argument to bin:hex(), bin:bin(), or bin:octal() contains a character that is not valid for the specified notation."); + + public static final ErrorCode INDEX_OUT_OF_RANGE = new ErrorCode( + new QName("index-out-of-range", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "Offset and/or size is out of range for the given binary data."); + + public static final ErrorCode NEGATIVE_SIZE = new ErrorCode( + new QName("negative-size", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "Size, count, or padding is negative."); + + public static final ErrorCode UNKNOWN_ENCODING = new ErrorCode( + new QName("unknown-encoding", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "The specified encoding is not supported."); + + public static final ErrorCode CONVERSION_ERROR = new ErrorCode( + new QName("conversion-error", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "An error occurred during encoding or decoding of a string."); + + public static final ErrorCode DIFFERING_LENGTH_ARGUMENTS = new ErrorCode( + new QName("differing-length-arguments", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "The arguments to a bitwise operation are of differing length."); + + public static final ErrorCode INVALID_ENCODING = new ErrorCode( + new QName("invalid-encoding", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "The encoding is invalid for the given data."); + + public static final ErrorCode INTEGER_TOO_LARGE = new ErrorCode( + new QName("integer-too-large", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX), + "Integer value exceeds the implementation-defined maximum."); + + private BinaryModuleErrorCode() { + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModuleHelper.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModuleHelper.java new file mode 100644 index 00000000000..0b365bcba25 --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryModuleHelper.java @@ -0,0 +1,119 @@ +/* + * 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.modules.binary; + +import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; +import org.exist.xquery.Expression; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Base64BinaryValueType; +import org.exist.xquery.value.BinaryValue; +import org.exist.xquery.value.BinaryValueFromInputStream; +import org.exist.xquery.value.Sequence; + +import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream; + +import javax.annotation.Nullable; +import java.io.IOException; + +/** + * Shared utility methods for the EXPath Binary Module functions. + */ +class BinaryModuleHelper { + + /** + * Extracts a byte array from a binary sequence argument. + * + * @param arg the sequence argument (expected to contain a single binary value) + * @return the byte array, or null if the argument is an empty sequence + * @throws XPathException if the binary data cannot be read + */ + @Nullable + static byte[] getBinaryData(final Sequence arg) throws XPathException { + if (arg.isEmpty()) { + return null; + } + final BinaryValue binary = (BinaryValue) arg.itemAt(0); + try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { + binary.streamBinaryTo(os); + return os.toByteArray(); + } catch (final IOException e) { + throw new XPathException((Expression) null, "Failed to read binary data: " + e.getMessage(), e); + } + } + + /** + * Creates an xs:base64Binary value from a byte array. + * + * @param context the XQuery context + * @param expr the calling expression (for error reporting) + * @param data the byte array + * @return the base64Binary value + * @throws XPathException if the value cannot be created + */ + static BinaryValue createBinaryResult(final XQueryContext context, final Expression expr, final byte[] data) throws XPathException { + return BinaryValueFromInputStream.getInstance( + context, + new Base64BinaryValueType(), + new UnsynchronizedByteArrayInputStream(data), + expr + ); + } + + /** + * Validates the octet-order parameter string. + * + * @param order the order string + * @return true if little-endian, false if big-endian + * @throws XPathException if the value is not a valid octet order + */ + static boolean isLittleEndian(final Expression expr, final String order) throws XPathException { + switch (order) { + case "most-significant-first": + case "big-endian": + case "BE": + return false; + case "least-significant-first": + case "little-endian": + case "LE": + return true; + default: + throw new XPathException(expr, + org.exist.xquery.ErrorCodes.XPTY0004, + "Invalid octet order: '" + order + "'. Expected one of: most-significant-first, big-endian, BE, least-significant-first, little-endian, LE"); + } + } + + /** + * Reverses a byte array in place. + */ + static void reverseBytes(final byte[] data) { + for (int i = 0, j = data.length - 1; i < j; i++, j--) { + final byte tmp = data[i]; + data[i] = data[j]; + data[j] = tmp; + } + } + + private BinaryModuleHelper() { + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryPackingFunctions.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryPackingFunctions.java new file mode 100644 index 00000000000..f29577e28e1 --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryPackingFunctions.java @@ -0,0 +1,339 @@ +/* + * 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.modules.binary; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.DoubleValue; +import org.exist.xquery.value.FloatValue; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.Type; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * EXPath Binary Module 4.0 — Numeric Packing and Unpacking (Section 7). + * + *
    + *
  • bin:pack-double
  • + *
  • bin:pack-float
  • + *
  • bin:pack-integer
  • + *
  • bin:unpack-double
  • + *
  • bin:unpack-float
  • + *
  • bin:unpack-integer
  • + *
  • bin:unpack-unsigned-integer
  • + *
+ * + * @see EXPath Binary Module 4.0 §7 + */ +public class BinaryPackingFunctions extends BasicFunction { + + private static final QName QN_PACK_DOUBLE = new QName("pack-double", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_PACK_FLOAT = new QName("pack-float", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_PACK_INTEGER = new QName("pack-integer", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_UNPACK_DOUBLE = new QName("unpack-double", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_UNPACK_FLOAT = new QName("unpack-float", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_UNPACK_INTEGER = new QName("unpack-integer", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_UNPACK_UNSIGNED_INTEGER = new QName("unpack-unsigned-integer", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + + static final FunctionSignature[] FS_PACK_DOUBLE = functionSignatures( + QN_PACK_DOUBLE, + "Returns the 8-octet binary representation of an xs:double value.", + returns(Type.BASE64_BINARY), + arities( + arity(param("value", Type.DOUBLE, "The double value to pack")), + arity(param("value", Type.DOUBLE, "The double value to pack"), + param("order", Type.STRING, "The octet order: 'most-significant-first' (default), 'big-endian', 'BE', 'least-significant-first', 'little-endian', 'LE'")) + ) + ); + + static final FunctionSignature[] FS_PACK_FLOAT = functionSignatures( + QN_PACK_FLOAT, + "Returns the 4-octet binary representation of an xs:float value.", + returns(Type.BASE64_BINARY), + arities( + arity(param("value", Type.FLOAT, "The float value to pack")), + arity(param("value", Type.FLOAT, "The float value to pack"), + param("order", Type.STRING, "The octet order")) + ) + ); + + static final FunctionSignature[] FS_PACK_INTEGER = functionSignatures( + QN_PACK_INTEGER, + "Returns the two's-complement binary representation of an xs:integer value.", + returns(Type.BASE64_BINARY), + arities( + arity(param("value", Type.INTEGER, "The integer value to pack"), + param("size", Type.INTEGER, "The number of octets in the result")), + arity(param("value", Type.INTEGER, "The integer value to pack"), + param("size", Type.INTEGER, "The number of octets in the result"), + param("order", Type.STRING, "The octet order")) + ) + ); + + static final FunctionSignature[] FS_UNPACK_DOUBLE = functionSignatures( + QN_UNPACK_DOUBLE, + "Extracts an xs:double value from binary data.", + returns(Type.DOUBLE), + arities( + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset")), + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset"), + param("order", Type.STRING, "The octet order")) + ) + ); + + static final FunctionSignature[] FS_UNPACK_FLOAT = functionSignatures( + QN_UNPACK_FLOAT, + "Extracts an xs:float value from binary data.", + returns(Type.FLOAT), + arities( + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset")), + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset"), + param("order", Type.STRING, "The octet order")) + ) + ); + + static final FunctionSignature[] FS_UNPACK_INTEGER = functionSignatures( + QN_UNPACK_INTEGER, + "Extracts a signed xs:integer value from binary data.", + returns(Type.INTEGER), + arities( + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset"), + param("size", Type.INTEGER, "The number of octets to read")), + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset"), + param("size", Type.INTEGER, "The number of octets to read"), + param("order", Type.STRING, "The octet order")) + ) + ); + + static final FunctionSignature[] FS_UNPACK_UNSIGNED_INTEGER = functionSignatures( + QN_UNPACK_UNSIGNED_INTEGER, + "Extracts an unsigned xs:integer value from binary data.", + returns(Type.INTEGER), + arities( + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset"), + param("size", Type.INTEGER, "The number of octets to read")), + arity(param("value", Type.BASE64_BINARY, "The binary data"), + param("offset", Type.INTEGER, "The zero-based byte offset"), + param("size", Type.INTEGER, "The number of octets to read"), + param("order", Type.STRING, "The octet order")) + ) + ); + + public BinaryPackingFunctions(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + if (isCalledAs("pack-double")) { + return packDouble(args); + } else if (isCalledAs("pack-float")) { + return packFloat(args); + } else if (isCalledAs("pack-integer")) { + return packInteger(args); + } else if (isCalledAs("unpack-double")) { + return unpackDouble(args); + } else if (isCalledAs("unpack-float")) { + return unpackFloat(args); + } else if (isCalledAs("unpack-integer")) { + return unpackInteger(args); + } else { + return unpackUnsignedInteger(args); + } + } + + private boolean getByteOrder(final Sequence[] args, final int orderArgIndex) throws XPathException { + if (args.length > orderArgIndex && !args[orderArgIndex].isEmpty()) { + return BinaryModuleHelper.isLittleEndian(this, args[orderArgIndex].getStringValue()); + } + return false; // big-endian by default + } + + private Sequence packDouble(final Sequence[] args) throws XPathException { + final double value = ((DoubleValue) args[0].itemAt(0)).getDouble(); + final boolean le = getByteOrder(args, 1); + + final byte[] data = new byte[8]; + ByteBuffer.wrap(data).putLong(Double.doubleToRawLongBits(value)); + if (le) { + BinaryModuleHelper.reverseBytes(data); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + private Sequence packFloat(final Sequence[] args) throws XPathException { + final float value = ((FloatValue) args[0].itemAt(0)).getValue(); + final boolean le = getByteOrder(args, 1); + + final byte[] data = new byte[4]; + ByteBuffer.wrap(data).putInt(Float.floatToRawIntBits(value)); + if (le) { + BinaryModuleHelper.reverseBytes(data); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + private Sequence packInteger(final Sequence[] args) throws XPathException { + final BigInteger value = ((IntegerValue) args[0].itemAt(0)).toJavaObject(BigInteger.class); + final int size = ((IntegerValue) args[1].itemAt(0)).getInt(); + final boolean le = getByteOrder(args, 2); + + if (size < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Size must not be negative: " + size); + } + + if (size == 0) { + return BinaryModuleHelper.createBinaryResult(context, this, new byte[0]); + } + + final byte[] twosComplement = value.toByteArray(); + final byte[] data = new byte[size]; + + // Fill with sign extension byte (0x00 for positive, 0xFF for negative) + if (value.signum() < 0) { + Arrays.fill(data, (byte) 0xFF); + } + + // Copy the significant bytes into the result, right-aligned (big-endian) + if (twosComplement.length <= size) { + System.arraycopy(twosComplement, 0, data, size - twosComplement.length, twosComplement.length); + } else { + // Truncate from the left (most significant bytes) + System.arraycopy(twosComplement, twosComplement.length - size, data, 0, size); + } + + if (le) { + BinaryModuleHelper.reverseBytes(data); + } + return BinaryModuleHelper.createBinaryResult(context, this, data); + } + + private Sequence unpackDouble(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + final boolean le = getByteOrder(args, 2); + + validateUnpackRange(data, offset, 8); + + final byte[] slice = Arrays.copyOfRange(data, offset, offset + 8); + if (le) { + BinaryModuleHelper.reverseBytes(slice); + } + final long bits = ByteBuffer.wrap(slice).getLong(); + return new DoubleValue(this, Double.longBitsToDouble(bits)); + } + + private Sequence unpackFloat(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + final boolean le = getByteOrder(args, 2); + + validateUnpackRange(data, offset, 4); + + final byte[] slice = Arrays.copyOfRange(data, offset, offset + 4); + if (le) { + BinaryModuleHelper.reverseBytes(slice); + } + final int bits = ByteBuffer.wrap(slice).getInt(); + return new FloatValue(this, Float.intBitsToFloat(bits)); + } + + private Sequence unpackInteger(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + final int size = ((IntegerValue) args[2].itemAt(0)).getInt(); + final boolean le = getByteOrder(args, 3); + + if (size < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Size must not be negative: " + size); + } + + validateUnpackRange(data, offset, size); + + if (size == 0) { + return new IntegerValue(this, 0); + } + + final byte[] slice = Arrays.copyOfRange(data, offset, offset + size); + if (le) { + BinaryModuleHelper.reverseBytes(slice); + } + // BigInteger(byte[]) interprets as signed two's-complement + final BigInteger result = new BigInteger(slice); + return new IntegerValue(this, result); + } + + private Sequence unpackUnsignedInteger(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + final int offset = ((IntegerValue) args[1].itemAt(0)).getInt(); + final int size = ((IntegerValue) args[2].itemAt(0)).getInt(); + final boolean le = getByteOrder(args, 3); + + if (size < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Size must not be negative: " + size); + } + + validateUnpackRange(data, offset, size); + + if (size == 0) { + return new IntegerValue(this, 0); + } + + final byte[] slice = Arrays.copyOfRange(data, offset, offset + size); + if (le) { + BinaryModuleHelper.reverseBytes(slice); + } + // BigInteger(1, byte[]) interprets as unsigned (positive signum) + final BigInteger result = new BigInteger(1, slice); + return new IntegerValue(this, result); + } + + private void validateUnpackRange(final byte[] data, final int offset, final int size) throws XPathException { + if (data == null) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Binary data is empty"); + } + if (offset < 0 || offset + size > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " + size " + size + " exceeds binary data length " + data.length); + } + } +} diff --git a/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryTextFunctions.java b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryTextFunctions.java new file mode 100644 index 00000000000..aa759c93485 --- /dev/null +++ b/extensions/modules/expath-binary/src/main/java/org/exist/xquery/modules/binary/BinaryTextFunctions.java @@ -0,0 +1,194 @@ +/* + * 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.modules.binary; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.UnsupportedCharsetException; +import java.util.Arrays; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * EXPath Binary Module 4.0 — Text Encoding and Decoding (Section 6). + * + *
    + *
  • bin:decode-string
  • + *
  • bin:encode-string
  • + *
+ * + * @see EXPath Binary Module 4.0 §6 + */ +public class BinaryTextFunctions extends BasicFunction { + + private static final QName QN_DECODE_STRING = new QName("decode-string", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + private static final QName QN_ENCODE_STRING = new QName("encode-string", BinaryModule.NAMESPACE_URI, BinaryModule.PREFIX); + + static final FunctionSignature[] FS_DECODE_STRING = functionSignatures( + QN_DECODE_STRING, + "Decodes binary data to an xs:string using the specified encoding.", + returnsOpt(Type.STRING), + arities( + arity( + optParam("value", Type.BASE64_BINARY, "The binary data to decode") + ), + arity( + optParam("value", Type.BASE64_BINARY, "The binary data to decode"), + param("encoding", Type.STRING, "The character encoding (default: UTF-8)") + ), + arity( + optParam("value", Type.BASE64_BINARY, "The binary data to decode"), + param("encoding", Type.STRING, "The character encoding (default: UTF-8)"), + param("offset", Type.INTEGER, "The zero-based byte offset to start decoding") + ), + arity( + optParam("value", Type.BASE64_BINARY, "The binary data to decode"), + param("encoding", Type.STRING, "The character encoding (default: UTF-8)"), + param("offset", Type.INTEGER, "The zero-based byte offset to start decoding"), + param("size", Type.INTEGER, "The number of bytes to decode") + ) + ) + ); + + static final FunctionSignature[] FS_ENCODE_STRING = functionSignatures( + QN_ENCODE_STRING, + "Encodes an xs:string to binary data using the specified encoding.", + returnsOpt(Type.BASE64_BINARY), + arities( + arity( + optParam("value", Type.STRING, "The string to encode") + ), + arity( + optParam("value", Type.STRING, "The string to encode"), + param("encoding", Type.STRING, "The character encoding (default: UTF-8)") + ) + ) + ); + + public BinaryTextFunctions(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + if (isCalledAs("decode-string")) { + return decodeString(args); + } else { + return encodeString(args); + } + } + + private Sequence decodeString(final Sequence[] args) throws XPathException { + final byte[] data = BinaryModuleHelper.getBinaryData(args[0]); + if (data == null) { + return Sequence.EMPTY_SEQUENCE; + } + + final String encoding = (args.length > 1 && !args[1].isEmpty()) + ? args[1].getStringValue() + : "UTF-8"; + + final int offset = (args.length > 2 && !args[2].isEmpty()) + ? ((IntegerValue) args[2].itemAt(0)).getInt() + : 0; + + final int size = (args.length > 3 && !args[3].isEmpty()) + ? ((IntegerValue) args[3].itemAt(0)).getInt() + : data.length - offset; + + if (offset < 0 || offset > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " is out of range for binary data of length " + data.length); + } + + if (size < 0) { + throw new XPathException(this, BinaryModuleErrorCode.NEGATIVE_SIZE, + "Size must not be negative: " + size); + } + + if (offset + size > data.length) { + throw new XPathException(this, BinaryModuleErrorCode.INDEX_OUT_OF_RANGE, + "Offset " + offset + " + size " + size + " exceeds binary data length " + data.length); + } + + final Charset charset = resolveCharset(encoding); + + try { + final CharsetDecoder decoder = charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + final CharBuffer result = decoder.decode(ByteBuffer.wrap(data, offset, size)); + return new StringValue(this, result.toString()); + } catch (final CharacterCodingException e) { + throw new XPathException(this, BinaryModuleErrorCode.CONVERSION_ERROR, + "Failed to decode binary data using encoding '" + encoding + "': " + e.getMessage()); + } + } + + private Sequence encodeString(final Sequence[] args) throws XPathException { + if (args[0].isEmpty()) { + return Sequence.EMPTY_SEQUENCE; + } + + final String value = args[0].getStringValue(); + final String encoding = (args.length > 1 && !args[1].isEmpty()) + ? args[1].getStringValue() + : "UTF-8"; + + final Charset charset = resolveCharset(encoding); + + try { + final ByteBuffer encoded = charset.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(value)); + final byte[] data = Arrays.copyOf(encoded.array(), encoded.limit()); + return BinaryModuleHelper.createBinaryResult(context, this, data); + } catch (final CharacterCodingException e) { + throw new XPathException(this, BinaryModuleErrorCode.CONVERSION_ERROR, + "Failed to encode string using encoding '" + encoding + "': " + e.getMessage()); + } + } + + private Charset resolveCharset(final String encoding) throws XPathException { + try { + return Charset.forName(encoding); + } catch (final UnsupportedCharsetException e) { + throw new XPathException(this, BinaryModuleErrorCode.UNKNOWN_ENCODING, + "Unknown encoding: '" + encoding + "'"); + } + } +} diff --git a/extensions/modules/expath-file/pom.xml b/extensions/modules/expath-file/pom.xml new file mode 100644 index 00000000000..1eba38e8ffe --- /dev/null +++ b/extensions/modules/expath-file/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + + org.exist-db + exist-parent + 7.0.0-SNAPSHOT + ../../../exist-parent + + + exist-expath-file + jar + + eXist-db EXPath File Module + EXPath File Module 4.0 for eXist-db (http://expath.org/ns/file) + + + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + HEAD + + + + + org.exist-db + exist-core + ${project.version} + + + + + diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java new file mode 100644 index 00000000000..ee7dda37d76 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileErrorCode.java @@ -0,0 +1,73 @@ +/* + * 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.modules.file.expath; + +import org.exist.dom.QName; +import org.exist.xquery.ErrorCodes.ErrorCode; + +/** + * Error codes defined by the EXPath File Module 4.0. + * + * @see EXPath File Module 4.0 + */ +public class ExpathFileErrorCode { + + public static final ErrorCode NOT_FOUND = new ErrorCode( + new QName("not-found", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path does not exist."); + + public static final ErrorCode INVALID_PATH = new ErrorCode( + new QName("invalid-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path is invalid."); + + public static final ErrorCode EXISTS = new ErrorCode( + new QName("exists", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path already exists."); + + public static final ErrorCode NO_DIR = new ErrorCode( + new QName("no-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path does not point to a directory."); + + public static final ErrorCode IS_DIR = new ErrorCode( + new QName("is-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path points to a directory."); + + public static final ErrorCode IS_RELATIVE = new ErrorCode( + new QName("is-relative", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified path is relative."); + + public static final ErrorCode UNKNOWN_ENCODING = new ErrorCode( + new QName("unknown-encoding", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified encoding is not supported."); + + public static final ErrorCode OUT_OF_RANGE = new ErrorCode( + new QName("out-of-range", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "The specified offset or length is out of range."); + + public static final ErrorCode IO_ERROR = new ErrorCode( + new QName("io-error", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "A generic file system error occurred."); + + private ExpathFileErrorCode() { + // no instances + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java new file mode 100644 index 00000000000..07fec3e3332 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModule.java @@ -0,0 +1,148 @@ +/* + * 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.modules.file.expath; + +import java.util.List; +import java.util.Map; + +import org.exist.xquery.AbstractInternalModule; +import org.exist.xquery.FunctionDef; + +/** + * EXPath File Module 4.0 implementation for eXist-db. + * + * @see EXPath File Module 4.0 + */ +public class ExpathFileModule extends AbstractInternalModule { + + public static final String NAMESPACE_URI = "http://expath.org/ns/file"; + public static final String PREFIX = "exfile"; + public static final String INCLUSION_DATE = "2025-05-01"; + public static final String RELEASED_IN_VERSION = "7.0.0"; + + private static final FunctionDef[] functions = { + // FileProperties: exists, is-dir, is-file, is-absolute, last-modified, size(1), size(2) + new FunctionDef(FileProperties.signatures[0], FileProperties.class), + new FunctionDef(FileProperties.signatures[1], FileProperties.class), + new FunctionDef(FileProperties.signatures[2], FileProperties.class), + new FunctionDef(FileProperties.signatures[3], FileProperties.class), + new FunctionDef(FileProperties.signatures[4], FileProperties.class), + new FunctionDef(FileProperties.signatures[5], FileProperties.class), + new FunctionDef(FileProperties.signatures[6], FileProperties.class), + + // FileIO: read-text(1), read-text(2), read-text-lines(1), read-text-lines(2), + // read-text(3-fallback), read-text-lines(3-fallback), + // read-binary(1), read-binary(2), read-binary(3) + new FunctionDef(FileIO.signatures[0], FileIO.class), + new FunctionDef(FileIO.signatures[1], FileIO.class), + new FunctionDef(FileIO.signatures[2], FileIO.class), + new FunctionDef(FileIO.signatures[3], FileIO.class), + new FunctionDef(FileIO.signatures[4], FileIO.class), + new FunctionDef(FileIO.signatures[5], FileIO.class), + new FunctionDef(FileIO.signatures[6], FileIO.class), + new FunctionDef(FileIO.signatures[7], FileIO.class), + new FunctionDef(FileIO.signatures[8], FileIO.class), + + // FileWrite: write(2), write(3), write-text(2), write-text(3), + // write-text-lines(2), write-text-lines(3), write-binary(2), write-binary(3) + new FunctionDef(FileWrite.signatures[0], FileWrite.class), + new FunctionDef(FileWrite.signatures[1], FileWrite.class), + new FunctionDef(FileWrite.signatures[2], FileWrite.class), + new FunctionDef(FileWrite.signatures[3], FileWrite.class), + new FunctionDef(FileWrite.signatures[4], FileWrite.class), + new FunctionDef(FileWrite.signatures[5], FileWrite.class), + new FunctionDef(FileWrite.signatures[6], FileWrite.class), + new FunctionDef(FileWrite.signatures[7], FileWrite.class), + + // FileAppend: append(2), append(3), append-binary, append-text(2), append-text(3), + // append-text-lines(2), append-text-lines(3) + new FunctionDef(FileAppend.signatures[0], FileAppend.class), + new FunctionDef(FileAppend.signatures[1], FileAppend.class), + new FunctionDef(FileAppend.signatures[2], FileAppend.class), + new FunctionDef(FileAppend.signatures[3], FileAppend.class), + new FunctionDef(FileAppend.signatures[4], FileAppend.class), + new FunctionDef(FileAppend.signatures[5], FileAppend.class), + new FunctionDef(FileAppend.signatures[6], FileAppend.class), + + // FileManipulation: copy, move, delete(1), delete(2), create-dir, + // create-temp-dir(2), create-temp-dir(3), + // create-temp-file(2), create-temp-file(3), + // list(1), list(2), list(3), + // children, descendants, list-roots + new FunctionDef(FileManipulation.signatures[0], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[1], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[2], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[3], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[4], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[5], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[6], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[7], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[8], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[9], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[10], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[11], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[12], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[13], FileManipulation.class), + new FunctionDef(FileManipulation.signatures[14], FileManipulation.class), + + // FilePaths: name, parent, path-to-native, path-to-uri, resolve-path(1), resolve-path(2) + new FunctionDef(FilePaths.signatures[0], FilePaths.class), + new FunctionDef(FilePaths.signatures[1], FilePaths.class), + new FunctionDef(FilePaths.signatures[2], FilePaths.class), + new FunctionDef(FilePaths.signatures[3], FilePaths.class), + new FunctionDef(FilePaths.signatures[4], FilePaths.class), + new FunctionDef(FilePaths.signatures[5], FilePaths.class), + + // FileSystemProperties: dir-separator, line-separator, path-separator, + // temp-dir, base-dir, current-dir + new FunctionDef(FileSystemProperties.signatures[0], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[1], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[2], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[3], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[4], FileSystemProperties.class), + new FunctionDef(FileSystemProperties.signatures[5], FileSystemProperties.class) + }; + + public ExpathFileModule(final Map> parameters) { + super(functions, parameters); + } + + @Override + public String getNamespaceURI() { + return NAMESPACE_URI; + } + + @Override + public String getDefaultPrefix() { + return PREFIX; + } + + @Override + public String getDescription() { + return "EXPath File Module 4.0 - http://expath.org/ns/file"; + } + + @Override + public String getReleaseVersion() { + return RELEASED_IN_VERSION; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java new file mode 100644 index 00000000000..8339a1dd1a7 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/ExpathFileModuleHelper.java @@ -0,0 +1,137 @@ +/* + * 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.modules.file.expath; + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.exist.xquery.Expression; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; + +/** + * Helper utilities for the EXPath File Module. + */ +public class ExpathFileModuleHelper { + + private ExpathFileModuleHelper() { + // no instances + } + + /** + * Check that the calling user has DBA role. + * + * @param context the XQuery context + * @param expression the calling expression (for error reporting) + * @throws XPathException if the user is not a DBA + */ + public static void checkDbaRole(final XQueryContext context, final Expression expression) throws XPathException { + if (!context.getSubject().hasDbaRole()) { + throw new XPathException(expression, + "Permission denied, calling user '" + context.getSubject().getName() + + "' must be a DBA to call this function."); + } + } + + /** + * Resolve a path string (file: URI or native path) to a {@link Path}. + * Relative paths are resolved against the JVM working directory. + * + * @param path the path string or file: URI + * @param expression the calling expression (for error reporting) + * @return the resolved Path + * @throws XPathException if the path is invalid + */ + public static Path getPath(final String path, final Expression expression) throws XPathException { + return getPath(path, expression, null); + } + + /** + * Resolve a path string (file: URI or native path) to a {@link Path}. + * Relative paths are resolved against the XQuery static base URI if it is a + * file: URI, otherwise against the JVM working directory. + * + * @param path the path string or file: URI + * @param expression the calling expression (for error reporting) + * @param context the XQuery context (may be null) + * @return the resolved Path + * @throws XPathException if the path is invalid + */ + public static Path getPath(final String path, final Expression expression, final XQueryContext context) throws XPathException { + try { + if (path.startsWith("file:")) { + return Paths.get(new URI(path)); + } + + final Path p = Paths.get(path); + if (p.isAbsolute()) { + return p; + } + + // Resolve relative paths against static base URI if available + if (context != null) { + try { + final String baseUri = context.getBaseURI().getStringValue(); + if (baseUri != null && baseUri.startsWith("file:")) { + final Path basePath = Paths.get(new URI(baseUri)); + // Base URI may point to a file; resolve against its parent directory + final Path baseDir = java.nio.file.Files.isDirectory(basePath) ? basePath : basePath.getParent(); + if (baseDir != null) { + return baseDir.resolve(p); + } + } + } catch (final Exception ignored) { + // Fall through to default resolution + } + } + + return p; + } catch (final InvalidPathException e) { + throw new XPathException(expression, ExpathFileErrorCode.INVALID_PATH, + "Invalid path: " + path + " - " + e.getMessage()); + } catch (final Exception e) { + throw new XPathException(expression, ExpathFileErrorCode.INVALID_PATH, + path + " is not a valid path or URI: " + e.getMessage()); + } + } + + /** + * Validate and return a {@link Charset} for the given encoding name. + * + * @param encoding the encoding name + * @param expression the calling expression (for error reporting) + * @return the Charset + * @throws XPathException if the encoding is not supported + */ + public static Charset getCharset(final String encoding, final Expression expression) throws XPathException { + try { + return Charset.forName(encoding); + } catch (final UnsupportedCharsetException e) { + throw new XPathException(expression, ExpathFileErrorCode.UNKNOWN_ENCODING, + "Unknown encoding: " + encoding); + } + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java new file mode 100644 index 00000000000..03f9d706665 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileAppend.java @@ -0,0 +1,256 @@ +/* + * 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.modules.file.expath; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Properties; + +import org.exist.dom.QName; +import org.exist.storage.serializers.Serializer; +import org.exist.util.serializer.SAXSerializer; +import org.exist.util.serializer.SerializerPool; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.AbstractMapType; +import org.exist.xquery.util.SerializerUtils; +import org.exist.xquery.value.*; +import org.xml.sax.SAXException; + +/** + * EXPath File Module 4.0 - Append functions. + *

+ * Implements: file:append, file:append-binary, file:append-text, file:append-text-lines + */ +public class FileAppend extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, "The path to the file."); + + public static final FunctionSignature[] signatures = { + // file:append($file, $value) + new FunctionSignature( + new QName("append", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a serialized sequence to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append($file, $value, $options) + new FunctionSignature( + new QName("append", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a serialized sequence to a file with serialization options.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and append."), + new FunctionParameterSequenceType("options", Type.ITEM, Cardinality.ZERO_OR_ONE, "Serialization parameters as map(*) or element(output:serialization-parameters).") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-binary($file, $value) + new FunctionSignature( + new QName("append-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends binary data to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text($file, $value) + new FunctionSignature( + new QName("append-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a string to a file. Creates the file if it does not exist.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text($file, $value, $encoding) + new FunctionSignature( + new QName("append-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a string to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to append."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text-lines($file, $lines) + new FunctionSignature( + new QName("append-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a sequence of strings as lines to a file, separated by the platform line separator.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("lines", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to append.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:append-text-lines($file, $lines, $encoding) + new FunctionSignature( + new QName("append-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Appends a sequence of strings as lines to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("lines", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to append."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ) + }; + + public FileAppend(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + checkParentDir(path); + + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("append")) { + return append(path, args); + } else if (isCalledAs("append-binary")) { + return appendBinary(path, args); + } else if (isCalledAs("append-text")) { + return appendText(path, args); + } else if (isCalledAs("append-text-lines")) { + return appendTextLines(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence append(final Path path, final Sequence[] args) throws XPathException { + final Sequence value = args[1]; + final Properties outputProperties = new Properties(); + if (args.length > 2 && !args[2].isEmpty()) { + final Item optionsItem = args[2].itemAt(0); + if (optionsItem instanceof AbstractMapType) { + outputProperties.putAll(SerializerUtils.getSerializationOptions(this, (AbstractMapType) optionsItem)); + } else if (optionsItem instanceof NodeValue) { + SerializerUtils.getSerializationOptions(this, (NodeValue) optionsItem, outputProperties); + } + } + + final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class); + try { + final Serializer serializer = context.getBroker().borrowSerializer(); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND)), + StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + serializer.setProperties(outputProperties); + serializer.setSAXHandlers(sax, sax); + + for (final SequenceIterator i = value.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (Type.subTypeOf(item.getType(), Type.NODE)) { + serializer.toSAX((NodeValue) item); + } else { + writer.write(item.getStringValue()); + } + } + } finally { + context.getBroker().returnSerializer(serializer); + } + } catch (final IOException | SAXException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } finally { + SerializerPool.getInstance().returnObject(sax); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendBinary(final Path path, final Sequence[] args) throws XPathException { + final BinaryValue binaryValue = (BinaryValue) args[1].itemAt(0); + try (final OutputStream os = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + final InputStream is = binaryValue.getInputStream()) { + is.transferTo(os); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendText(final Path path, final Sequence[] args) throws XPathException { + final String text = args[1].getStringValue(); + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND), encoding)) { + writer.write(text); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence appendTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.APPEND), encoding)) { + final String lineSep = System.lineSeparator(); + for (final SequenceIterator i = args[1].iterate(); i.hasNext(); ) { + writer.write(i.nextItem().getStringValue()); + writer.write(lineSep); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void checkParentDir(final Path path) throws XPathException { + final Path parent = path.getParent(); + if (parent != null && !Files.isDirectory(parent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent directory does not exist: " + parent.toAbsolutePath()); + } + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java new file mode 100644 index 00000000000..412e16f4c0e --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileIO.java @@ -0,0 +1,323 @@ +/* + * 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.modules.file.expath; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Base64BinaryValueType; +import org.exist.xquery.value.BinaryValueFromBinaryString; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; + +/** + * EXPath File Module 4.0 - Input functions. + *

+ * Implements: file:read-text, file:read-text-lines, file:read-binary + */ +public class FileIO extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, + "The path to the file."); + private static final FunctionParameterSequenceType ENCODING_PARAM = + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, + "The character encoding. Default: UTF-8."); + + public static final FunctionSignature[] signatures = { + // file:read-text($file as xs:string) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text($file as xs:string, $encoding as xs:string) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text with the specified encoding.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text-lines($file as xs:string) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-text-lines($file as xs:string, $encoding as xs:string) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines with the specified encoding.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-text($file as xs:string, $encoding as xs:string?, $fallback as xs:boolean) as xs:string + new FunctionSignature( + new QName("read-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as text. If $fallback is true, invalid characters are replaced.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM, + new FunctionParameterSequenceType("fallback", Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "If true, replace invalid characters with the Unicode replacement character.")}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file contents as string.") + ), + // file:read-text-lines($file as xs:string, $encoding as xs:string?, $fallback as xs:boolean) as xs:string* + new FunctionSignature( + new QName("read-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as a sequence of lines. If $fallback is true, invalid characters are replaced.", + new SequenceType[]{FILE_PARAM, ENCODING_PARAM, + new FunctionParameterSequenceType("fallback", Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "If true, replace invalid characters with the Unicode replacement character.")}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "the lines of the file.") + ), + // file:read-binary($file as xs:string) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads the contents of a file as binary.", + new SequenceType[]{FILE_PARAM}, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ), + // file:read-binary($file as xs:string, $offset as xs:integer) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads a portion of a file as binary starting at the given byte offset.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The byte offset to start reading from. Default: 0.") + }, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ), + // file:read-binary($file as xs:string, $offset as xs:integer, $length as xs:integer) as xs:base64Binary + new FunctionSignature( + new QName("read-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Reads a portion of a file as binary with offset and length.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The byte offset to start reading from. Default: 0."), + new FunctionParameterSequenceType("length", Type.INTEGER, Cardinality.ZERO_OR_ONE, + "The number of bytes to read.") + }, + new FunctionReturnSequenceType(Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "the binary contents.") + ) + }; + + public FileIO(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "File not found: " + path.toAbsolutePath()); + } + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("read-text")) { + return readText(path, args); + } else if (isCalledAs("read-text-lines")) { + return readTextLines(path, args); + } else if (isCalledAs("read-binary")) { + return readBinary(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence readText(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 1); + final boolean fallback = args.length > 2 && !args[2].isEmpty() + && args[2].itemAt(0).toJavaObject(Boolean.class); + try { + final String content = readFileText(path, encoding, fallback); + // Normalize newlines per spec: CR or CRLF -> LF + final String normalized = content.replace("\r\n", "\n").replace("\r", "\n"); + return new StringValue(this, normalized); + } catch (final java.nio.charset.MalformedInputException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, + "Invalid characters in file for encoding " + encoding.name()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence readTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 1); + final boolean fallback = args.length > 2 && !args[2].isEmpty() + && args[2].itemAt(0).toJavaObject(Boolean.class); + try { + final String content = readFileText(path, encoding, fallback); + // Split at newline boundaries per spec + final String[] lines = content.split("\r\n|\r|\n", -1); + final ValueSequence result = new ValueSequence(lines.length); + // If file ends with newline, last split element is empty - exclude it per spec + final int count = (lines.length > 0 && lines[lines.length - 1].isEmpty()) ? lines.length - 1 : lines.length; + for (int i = 0; i < count; i++) { + result.add(new StringValue(this, lines[i])); + } + return result; + } catch (final java.nio.charset.MalformedInputException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, + "Invalid characters in file for encoding " + encoding.name()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence readBinary(final Path path, final Sequence[] args) throws XPathException { + final long offset = args.length > 1 && !args[1].isEmpty() ? args[1].itemAt(0).toJavaObject(Long.class) : 0; + final boolean hasLength = args.length > 2 && !args[2].isEmpty(); + final long length = hasLength ? args[2].itemAt(0).toJavaObject(Long.class) : -1; + + try { + final long fileSize = Files.size(path); + validateBinaryRange(offset, length, hasLength, fileSize); + + final byte[] data = readBinaryData(path, offset, hasLength, length, fileSize); + final String base64 = java.util.Base64.getEncoder().encodeToString(data); + return new BinaryValueFromBinaryString(this, new Base64BinaryValueType(), base64); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private void validateBinaryRange(final long offset, final long length, final boolean hasLength, final long fileSize) throws XPathException { + if (offset < 0 || offset > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset " + offset + " is out of range for file of size " + fileSize); + } + if (hasLength && length < 0) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Length must not be negative: " + length); + } + if (hasLength && offset + length > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset + length exceeds file size: " + (offset + length) + " > " + fileSize); + } + } + + private byte[] readBinaryData(final Path path, final long offset, final boolean hasLength, final long length, final long fileSize) throws IOException { + if (offset == 0 && !hasLength) { + return Files.readAllBytes(path); + } + try (final RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) { + raf.seek(offset); + final int readLen = hasLength ? (int) length : (int) (fileSize - offset); + final byte[] data = new byte[readLen]; + raf.readFully(data); + return data; + } + } + + /** + * Reads a file as text with the given encoding. + * If fallback is true, malformed byte sequences and XML-illegal characters + * are replaced with U+FFFD. Otherwise, an IOException is thrown if the file + * contains malformed bytes or XML-illegal characters. + */ + private String readFileText(final Path path, final Charset encoding, final boolean fallback) throws IOException { + final String content; + if (fallback) { + final java.nio.charset.CharsetDecoder decoder = encoding.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE) + .replaceWith("\uFFFD"); + final byte[] bytes = Files.readAllBytes(path); + content = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + // Replace XML-illegal characters with U+FFFD + return replaceXmlIllegalChars(content); + } else { + content = Files.readString(path, encoding); + // Check for XML-illegal characters + checkXmlIllegalChars(content); + return content; + } + } + + /** + * Check if a string contains characters illegal in XML 1.0 and throw IOException if so. + * XML 1.0 allows: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] + */ + private void checkXmlIllegalChars(final String text) throws IOException { + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if (c < 0x20 && c != 0x9 && c != 0xA && c != 0xD) { + throw new IOException("File contains XML-illegal character U+" + + String.format("%04X", (int) c) + " at position " + i); + } + if (c >= 0xFFFE) { + throw new IOException("File contains XML-illegal character U+" + + String.format("%04X", (int) c) + " at position " + i); + } + } + } + + /** + * Replace characters illegal in XML 1.0 with U+FFFD. + */ + private String replaceXmlIllegalChars(final String text) { + final StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if ((c < 0x20 && c != 0x9 && c != 0xA && c != 0xD) || c >= 0xFFFE) { + sb.append('\uFFFD'); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java new file mode 100644 index 00000000000..dad29cb3f1a --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileManipulation.java @@ -0,0 +1,538 @@ +/* + * 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.modules.file.expath; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.*; + +/** + * EXPath File Module 4.0 - File/directory manipulation and listing functions. + *

+ * Implements: file:copy, file:move, file:delete, file:create-dir, file:create-temp-dir, + * file:create-temp-file, file:list, file:children, file:descendants + */ +public class FileManipulation extends BasicFunction { + + private static final FunctionParameterSequenceType PATH_PARAM = + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, "The file or directory path."); + + public static final FunctionSignature[] signatures = { + // file:copy($source, $target) + new FunctionSignature( + new QName("copy", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Copies a file or directory. If the target exists it is overwritten.", + new SequenceType[]{ + new FunctionParameterSequenceType("source", Type.STRING, Cardinality.EXACTLY_ONE, "Source path."), + new FunctionParameterSequenceType("target", Type.STRING, Cardinality.EXACTLY_ONE, "Target path.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:move($source, $target) + new FunctionSignature( + new QName("move", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Moves a file or directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("source", Type.STRING, Cardinality.EXACTLY_ONE, "Source path."), + new FunctionParameterSequenceType("target", Type.STRING, Cardinality.EXACTLY_ONE, "Target path.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:delete($path) + new FunctionSignature( + new QName("delete", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Deletes a file or empty directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:delete($path, $recursive) + new FunctionSignature( + new QName("delete", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Deletes a file or directory. If $recursive is true, non-empty directories are removed recursively.", + new SequenceType[]{ + PATH_PARAM, + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, + "If true, delete directories recursively. Default: false.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:create-dir($dir) + new FunctionSignature( + new QName("create-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a directory, including any necessary parent directories.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:create-temp-dir($prefix, $suffix) + new FunctionSignature( + new QName("create-temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary directory in the system default temp directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the directory name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the directory name.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary directory.") + ), + // file:create-temp-dir($prefix, $suffix, $dir) + new FunctionSignature( + new QName("create-temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the directory name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the directory name."), + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.ZERO_OR_ONE, "The parent directory. Default: system temp dir.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary directory.") + ), + // file:create-temp-file($prefix, $suffix) + new FunctionSignature( + new QName("create-temp-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary file in the system default temp directory.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the file name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the file name.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary file.") + ), + // file:create-temp-file($prefix, $suffix, $dir) + new FunctionSignature( + new QName("create-temp-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Creates a temporary file.", + new SequenceType[]{ + new FunctionParameterSequenceType("prefix", Type.STRING, Cardinality.ZERO_OR_ONE, "Prefix for the file name."), + new FunctionParameterSequenceType("suffix", Type.STRING, Cardinality.ZERO_OR_ONE, "Suffix for the file name."), + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.ZERO_OR_ONE, "The parent directory. Default: system temp dir.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "The path of the created temporary file.") + ), + // file:list($dir) + new FunctionSignature( + new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Lists the contents of a directory as relative paths.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of directory contents.") + ), + // file:list($dir, $recursive) + new FunctionSignature( + new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Lists the contents of a directory, optionally recursively.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path."), + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, "If true, list recursively. Default: false.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of directory contents.") + ), + // file:list($dir, $recursive, $pattern) + new FunctionSignature( + new QName("list", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Lists the contents of a directory matching a glob pattern.", + new SequenceType[]{ + new FunctionParameterSequenceType("dir", Type.STRING, Cardinality.EXACTLY_ONE, "The directory path."), + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, "If true, list recursively. Default: false."), + new FunctionParameterSequenceType("pattern", Type.STRING, Cardinality.ZERO_OR_ONE, "A glob pattern to filter results.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The relative paths of matching directory contents.") + ), + // file:children($path) + new FunctionSignature( + new QName("children", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the paths of immediate children of a directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The absolute paths of children.") + ), + // file:descendants($path) + new FunctionSignature( + new QName("descendants", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the paths of all descendants of a directory recursively.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The absolute paths of all descendants.") + ), + // file:list-roots() + new FunctionSignature( + new QName("list-roots", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the root directories of the file system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_MORE, "The root directories.") + ) + }; + + public FileManipulation(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + if (isCalledAs("list-roots")) { + return listRoots(); + } + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (isCalledAs("copy")) { + return copy(path, args); + } else if (isCalledAs("move")) { + return move(path, args); + } else if (isCalledAs("delete")) { + return delete(path, args); + } else if (isCalledAs("create-dir")) { + return createDir(path); + } else if (isCalledAs("create-temp-dir")) { + return createTempDir(args); + } else if (isCalledAs("create-temp-file")) { + return createTempFile(args); + } else if (isCalledAs("list")) { + return list(path, args); + } else if (isCalledAs("children")) { + return children(path); + } else if (isCalledAs("descendants")) { + return descendants(path); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence copy(final Path source, final Sequence[] args) throws XPathException { + if (!Files.exists(source)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Source does not exist: " + source.toAbsolutePath()); + } + final Path target = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context); + + // Check target parent directory exists + final Path targetParent = target.toAbsolutePath().getParent(); + if (targetParent != null && !Files.isDirectory(targetParent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Target parent directory does not exist: " + targetParent); + } + + try { + if (Files.isDirectory(source)) { + copyDirectory(source, target); + } else { + // If target is an existing directory, copy into it + final Path actualTarget = Files.isDirectory(target) ? target.resolve(source.getFileName()) : target; + Files.copy(source, actualTarget, StandardCopyOption.REPLACE_EXISTING); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void copyDirectory(final Path source, final Path target) throws IOException { + Files.walkFileTree(source, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException { + final Path targetDir = target.resolve(source.relativize(dir)); + Files.createDirectories(targetDir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { + Files.copy(file, target.resolve(source.relativize(file)), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } + + private Sequence move(final Path source, final Sequence[] args) throws XPathException { + if (!Files.exists(source)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Source does not exist: " + source.toAbsolutePath()); + } + final Path target = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context); + + // Check target parent directory exists + final Path targetParent = target.toAbsolutePath().getParent(); + if (targetParent != null && !Files.isDirectory(targetParent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Target parent directory does not exist: " + targetParent); + } + + try { + // If target is an existing directory, move into it + final Path actualTarget = Files.isDirectory(target) ? target.resolve(source.getFileName()) : target; + Files.move(source, actualTarget, StandardCopyOption.REPLACE_EXISTING); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence delete(final Path path, final Sequence[] args) throws XPathException { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + final boolean recursive = args.length > 1 && !args[1].isEmpty() + && args[1].itemAt(0).toJavaObject(Boolean.class); + + try { + if (Files.isDirectory(path)) { + if (recursive) { + try (final Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.delete(p); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } else { + // Attempt to delete; will fail if non-empty + try { + Files.delete(path); + } catch (final DirectoryNotEmptyException e) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Directory is not empty (use $recursive = true()): " + path.toAbsolutePath()); + } + } + } else { + Files.delete(path); + } + } catch (final UncheckedIOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getCause().getMessage()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence createDir(final Path path) throws XPathException { + // Check if the path itself or any ancestor is an existing non-directory file + Path check = path.toAbsolutePath().normalize(); + while (check != null) { + if (Files.exists(check) && !Files.isDirectory(check)) { + throw new XPathException(this, ExpathFileErrorCode.EXISTS, + "Path exists and is not a directory: " + check); + } + check = check.getParent(); + } + try { + Files.createDirectories(path); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence createTempDir(final Sequence[] args) throws XPathException { + final String prefix = args.length > 0 && !args[0].isEmpty() ? args[0].getStringValue() : ""; + final String suffix = args.length > 1 && !args[1].isEmpty() ? args[1].getStringValue() : ""; + final Path dir = args.length > 2 && !args[2].isEmpty() + ? ExpathFileModuleHelper.getPath(args[2].getStringValue(), this, context) + : Paths.get(System.getProperty("java.io.tmpdir")); + + if (!Files.isDirectory(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent is not a directory: " + dir.toAbsolutePath()); + } + + try { + // Java's createTempDirectory only supports prefix, so we append suffix manually + final Path tempDir = Files.createTempDirectory(dir, prefix); + if (!suffix.isEmpty()) { + final Path renamed = tempDir.resolveSibling(tempDir.getFileName().toString() + suffix); + Files.move(tempDir, renamed); + return new StringValue(this, renamed.toAbsolutePath().toString() + File.separator); + } + return new StringValue(this, tempDir.toAbsolutePath().toString() + File.separator); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence createTempFile(final Sequence[] args) throws XPathException { + final String prefix = args.length > 0 && !args[0].isEmpty() ? args[0].getStringValue() : ""; + final String suffix = args.length > 1 && !args[1].isEmpty() ? args[1].getStringValue() : ""; + final Path dir = args.length > 2 && !args[2].isEmpty() + ? ExpathFileModuleHelper.getPath(args[2].getStringValue(), this, context) + : Paths.get(System.getProperty("java.io.tmpdir")); + + if (!Files.isDirectory(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent is not a directory: " + dir.toAbsolutePath()); + } + + try { + final Path tempFile = Files.createTempFile(dir, prefix, suffix.isEmpty() ? null : suffix); + return new StringValue(this, tempFile.toAbsolutePath().toString()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + @SuppressWarnings("PMD.NPathComplexity") + private Sequence list(final Path dir, final Sequence[] args) throws XPathException { + if (!Files.exists(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Directory does not exist: " + dir.toAbsolutePath()); + } + if (!Files.isDirectory(dir)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Path is not a directory: " + dir.toAbsolutePath()); + } + + final boolean recursive = args.length > 1 && !args[1].isEmpty() + && args[1].itemAt(0).toJavaObject(Boolean.class); + final String pattern = args.length > 2 && !args[2].isEmpty() + ? args[2].getStringValue() : null; + + final Pattern regex = pattern != null ? globToRegex(pattern) : null; + + try { + final ValueSequence result = new ValueSequence(); + try (final Stream stream = recursive ? Files.walk(dir) : Files.list(dir)) { + stream.filter(p -> !p.equals(dir)) + .forEach(p -> { + final String relative = dir.relativize(p).toString(); + final String entry = Files.isDirectory(p) + ? relative + File.separator : relative; + if (regex == null || regex.matcher(entry.replace(File.separator, "/")).matches() + || regex.matcher(p.getFileName().toString()).matches()) { + result.add(new StringValue(this, entry)); + } + }); + } + return result; + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence children(final Path path) throws XPathException { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + if (!Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Path is not a directory: " + path.toAbsolutePath()); + } + + try { + final ValueSequence result = new ValueSequence(); + try (final Stream stream = Files.list(path)) { + stream.forEach(p -> { + final String absPath = p.toAbsolutePath().toString(); + final String entry = Files.isDirectory(p) ? absPath + File.separator : absPath; + result.add(new StringValue(this, entry)); + }); + } + return result; + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence descendants(final Path path) throws XPathException { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + if (!Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Path is not a directory: " + path.toAbsolutePath()); + } + + try { + final ValueSequence result = new ValueSequence(); + try (final Stream walk = Files.walk(path)) { + walk.filter(p -> !p.equals(path)) + .forEach(p -> { + final String absPath = p.toAbsolutePath().toString(); + final String entry = Files.isDirectory(p) ? absPath + File.separator : absPath; + result.add(new StringValue(this, entry)); + }); + } + return result; + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + private Sequence listRoots() { + final ValueSequence result = new ValueSequence(); + for (final File root : File.listRoots()) { + result.add(new StringValue(this, root.getAbsolutePath())); + } + return result; + } + + /** + * Convert a simple glob pattern (with * and ?) to a Java regex Pattern. + */ + private static Pattern globToRegex(final String glob) { + final StringBuilder regex = new StringBuilder(); + for (int i = 0; i < glob.length(); i++) { + final char c = glob.charAt(i); + switch (c) { + case '*': + regex.append(".*"); + break; + case '?': + regex.append('.'); + break; + case '.': + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '\\': + case '^': + case '$': + case '|': + case '+': + regex.append('\\').append(c); + break; + default: + regex.append(c); + } + } + return Pattern.compile(regex.toString()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java new file mode 100644 index 00000000000..0d06fdd3467 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FilePaths.java @@ -0,0 +1,139 @@ +/* + * 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.modules.file.expath; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.*; + +/** + * EXPath File Module 4.0 - Path functions. + *

+ * Implements: file:name, file:parent, file:path-to-native, file:path-to-uri, file:resolve-path + */ +public class FilePaths extends BasicFunction { + + private static final FunctionParameterSequenceType PATH_PARAM = + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, "The file path."); + + public static final FunctionSignature[] signatures = { + // file:name($path as xs:string) as xs:string + new FunctionSignature( + new QName("name", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the name of a file or directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the file or directory name.") + ), + // file:parent($path as xs:string) as xs:string? + new FunctionSignature( + new QName("parent", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the parent directory of a path.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "the parent directory path, or empty for root.") + ), + // file:path-to-native($path as xs:string) as xs:string + new FunctionSignature( + new QName("path-to-native", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the native, canonical path.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the native path.") + ), + // file:path-to-uri($path as xs:string) as xs:anyURI + new FunctionSignature( + new QName("path-to-uri", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the path as a file:// URI.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.ANY_URI, Cardinality.EXACTLY_ONE, "the file:// URI.") + ), + // file:resolve-path($path as xs:string) as xs:string + new FunctionSignature( + new QName("resolve-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Resolves a relative path against the current working directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the resolved absolute path.") + ), + // file:resolve-path($path as xs:string, $base as xs:string) as xs:string + new FunctionSignature( + new QName("resolve-path", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Resolves a relative path against a base directory.", + new SequenceType[]{ + PATH_PARAM, + new FunctionParameterSequenceType("base", Type.STRING, Cardinality.ZERO_OR_ONE, "The base directory to resolve against.") + }, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the resolved absolute path.") + ) + }; + + public FilePaths(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (isCalledAs("name")) { + final Path fileName = path.getFileName(); + return new StringValue(this, fileName != null ? fileName.toString() : ""); + } else if (isCalledAs("parent")) { + final Path absPath = path.toAbsolutePath().normalize(); + final Path parent = absPath.getParent(); + if (parent == null) { + return Sequence.EMPTY_SEQUENCE; + } + return new StringValue(this, parent.toString() + File.separator); + } else if (isCalledAs("path-to-native")) { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + try { + return new StringValue(this, path.toRealPath().toString()); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } else if (isCalledAs("path-to-uri")) { + final Path abs = path.toAbsolutePath().normalize(); + return new AnyURIValue(this, abs.toUri().toString()); + } else if (isCalledAs("resolve-path")) { + if (args.length > 1 && !args[1].isEmpty()) { + final Path base = ExpathFileModuleHelper.getPath(args[1].getStringValue(), this, context); + return new StringValue(this, base.resolve(path).toAbsolutePath().normalize().toString()); + } + return new StringValue(this, path.toAbsolutePath().normalize().toString()); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java new file mode 100644 index 00000000000..e612a24a9a0 --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileProperties.java @@ -0,0 +1,184 @@ +/* + * 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.modules.file.expath; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Date; +import java.util.stream.Stream; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.BooleanValue; +import org.exist.xquery.value.DateTimeValue; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.Type; + +/** + * EXPath File Module 4.0 - File Properties functions. + *

+ * Implements: file:exists, file:is-dir, file:is-file, file:is-absolute, file:last-modified, file:size + */ +public class FileProperties extends BasicFunction { + + private static final FunctionParameterSequenceType PATH_PARAM = + new FunctionParameterSequenceType("path", Type.STRING, Cardinality.EXACTLY_ONE, + "The file path."); + + public static final FunctionSignature[] signatures = { + // file:exists($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("exists", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path exists.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path exists.") + ), + // file:is-dir($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("is-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path points to a directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path is a directory.") + ), + // file:is-file($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("is-file", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path points to a regular file.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path is a regular file.") + ), + // file:is-absolute($path as xs:string) as xs:boolean + new FunctionSignature( + new QName("is-absolute", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Tests whether a path is absolute.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.BOOLEAN, Cardinality.EXACTLY_ONE, + "true if the path is absolute.") + ), + // file:last-modified($path as xs:string) as xs:dateTime + new FunctionSignature( + new QName("last-modified", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the last modification time of a file or directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.DATE_TIME, Cardinality.EXACTLY_ONE, + "the last modification time.") + ), + // file:size($path as xs:string) as xs:integer + new FunctionSignature( + new QName("size", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the byte size of a file, or 0 for a directory.", + new SequenceType[]{PATH_PARAM}, + new FunctionReturnSequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE, + "the file size in bytes.") + ), + // file:size($path as xs:string, $recursive as xs:boolean) as xs:integer + new FunctionSignature( + new QName("size", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the byte size of a file, or for a directory the recursive size if $recursive is true.", + new SequenceType[]{ + PATH_PARAM, + new FunctionParameterSequenceType("recursive", Type.BOOLEAN, Cardinality.ZERO_OR_ONE, + "If true and path is a directory, compute recursive size.") + }, + new FunctionReturnSequenceType(Type.INTEGER, Cardinality.EXACTLY_ONE, + "the file or directory size in bytes.") + ) + }; + + public FileProperties(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + if (isCalledAs("exists")) { + return BooleanValue.valueOf(Files.exists(path)); + } else if (isCalledAs("is-dir")) { + return BooleanValue.valueOf(Files.isDirectory(path)); + } else if (isCalledAs("is-file")) { + return BooleanValue.valueOf(Files.isRegularFile(path)); + } else if (isCalledAs("is-absolute")) { + return BooleanValue.valueOf(path.isAbsolute()); + } else if (isCalledAs("last-modified")) { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + try { + final FileTime ft = Files.getLastModifiedTime(path); + return new DateTimeValue(this, new Date(ft.toMillis())); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } else if (isCalledAs("size")) { + if (!Files.exists(path)) { + throw new XPathException(this, ExpathFileErrorCode.NOT_FOUND, + "Path does not exist: " + path.toAbsolutePath()); + } + try { + if (Files.isDirectory(path)) { + final boolean recursive = args.length > 1 && !args[1].isEmpty() + && args[1].itemAt(0).toJavaObject(Boolean.class); + if (recursive) { + try (final Stream walk = Files.walk(path)) { + final long total = walk + .filter(Files::isRegularFile) + .mapToLong(p -> { + try { + return Files.size(p); + } catch (final IOException e) { + return 0L; + } + }) + .sum(); + return new IntegerValue(this, total); + } + } + return new IntegerValue(this, 0); + } + return new IntegerValue(this, Files.size(path)); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java new file mode 100644 index 00000000000..3f2b928a7ff --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileSystemProperties.java @@ -0,0 +1,143 @@ +/* + * 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.modules.file.expath; + +import java.io.File; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.exist.dom.QName; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; + +/** + * EXPath File Module 4.0 - System property functions. + *

+ * Implements: file:dir-separator, file:line-separator, file:path-separator, + * file:temp-dir, file:base-dir, file:current-dir + */ +public class FileSystemProperties extends BasicFunction { + + public static final FunctionSignature[] signatures = { + // file:dir-separator() as xs:string + new FunctionSignature( + new QName("dir-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the directory separator used by the operating system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the directory separator.") + ), + // file:line-separator() as xs:string + new FunctionSignature( + new QName("line-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the line separator used by the operating system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the line separator.") + ), + // file:path-separator() as xs:string + new FunctionSignature( + new QName("path-separator", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the path separator used by the operating system.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the path separator.") + ), + // file:temp-dir() as xs:string + new FunctionSignature( + new QName("temp-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the path of the temporary directory.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the temporary directory path.") + ), + // file:base-dir() as xs:string? + new FunctionSignature( + new QName("base-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the base directory of the current query, or empty if not available.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.ZERO_OR_ONE, "the base directory path.") + ), + // file:current-dir() as xs:string + new FunctionSignature( + new QName("current-dir", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Returns the current working directory.", + new SequenceType[]{}, + new FunctionReturnSequenceType(Type.STRING, Cardinality.EXACTLY_ONE, "the current working directory.") + ) + }; + + public FileSystemProperties(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + if (isCalledAs("dir-separator")) { + return new StringValue(this, File.separator); + } else if (isCalledAs("line-separator")) { + return new StringValue(this, System.lineSeparator()); + } else if (isCalledAs("path-separator")) { + return new StringValue(this, File.pathSeparator); + } else if (isCalledAs("temp-dir")) { + return new StringValue(this, System.getProperty("java.io.tmpdir") + File.separator); + } else if (isCalledAs("base-dir")) { + try { + final String baseURI = context.getBaseURI().getStringValue(); + if (baseURI != null && !baseURI.isEmpty() && baseURI.startsWith("file:")) { + final Path basePath = Paths.get(new URI(baseURI)); + final Path parent = basePath.getParent(); + if (parent != null) { + return new StringValue(this, parent.toString() + File.separator); + } + } + } catch (final Exception e) { + // Fall through to return empty + } + return Sequence.EMPTY_SEQUENCE; + } else if (isCalledAs("current-dir")) { + // If a file: base URI is set (e.g., sandpit), use its directory as the working directory + try { + final String baseURI = context.getBaseURI().getStringValue(); + if (baseURI != null && !baseURI.isEmpty() && baseURI.startsWith("file:")) { + final Path basePath = Paths.get(new URI(baseURI)); + final Path dir = java.nio.file.Files.isDirectory(basePath) ? basePath : basePath.getParent(); + if (dir != null) { + return new StringValue(this, dir.toString() + File.separator); + } + } + } catch (final Exception ignored) { + // Fall through to JVM CWD + } + return new StringValue(this, System.getProperty("user.dir") + File.separator); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } +} diff --git a/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java new file mode 100644 index 00000000000..1b24cae555a --- /dev/null +++ b/extensions/modules/expath-file/src/main/java/org/exist/xquery/modules/file/expath/FileWrite.java @@ -0,0 +1,296 @@ +/* + * 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.modules.file.expath; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import org.exist.dom.QName; +import org.exist.storage.serializers.Serializer; +import org.exist.util.serializer.SAXSerializer; +import org.exist.util.serializer.SerializerPool; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.AbstractMapType; +import org.exist.xquery.util.SerializerUtils; +import org.exist.xquery.value.*; +import org.xml.sax.SAXException; + +/** + * EXPath File Module 4.0 - Write functions. + *

+ * Implements: file:write, file:write-text, file:write-text-lines, file:write-binary + */ +public class FileWrite extends BasicFunction { + + private static final FunctionParameterSequenceType FILE_PARAM = + new FunctionParameterSequenceType("file", Type.STRING, Cardinality.EXACTLY_ONE, "The path to the file."); + + public static final FunctionSignature[] signatures = { + // file:write($file, $value) + new FunctionSignature( + new QName("write", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a serialized sequence to a file. Creates the file if it does not exist, overwrites it otherwise.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write($file, $value, $options) + new FunctionSignature( + new QName("write", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a serialized sequence to a file with serialization options.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.ITEM, Cardinality.ZERO_OR_MORE, "The items to serialize and write."), + new FunctionParameterSequenceType("options", Type.ITEM, Cardinality.ZERO_OR_ONE, "Serialization parameters as map(*) or element(output:serialization-parameters).") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text($file, $value) + new FunctionSignature( + new QName("write-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a string to a file. Creates the file if it does not exist, overwrites it otherwise.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text($file, $value, $encoding) + new FunctionSignature( + new QName("write-text", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a string to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.STRING, Cardinality.EXACTLY_ONE, "The string to write."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text-lines($file, $values) + new FunctionSignature( + new QName("write-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a sequence of strings as lines to a file, separated by the platform line separator.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("values", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-text-lines($file, $values, $encoding) + new FunctionSignature( + new QName("write-text-lines", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes a sequence of strings as lines to a file with the specified encoding.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("values", Type.STRING, Cardinality.ZERO_OR_MORE, "The lines to write."), + new FunctionParameterSequenceType("encoding", Type.STRING, Cardinality.ZERO_OR_ONE, "The character encoding. Default: UTF-8.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-binary($file, $value) + new FunctionSignature( + new QName("write-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes binary data to a file. Creates the file if it does not exist, overwrites it otherwise.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to write.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ), + // file:write-binary($file, $value, $offset) + new FunctionSignature( + new QName("write-binary", ExpathFileModule.NAMESPACE_URI, ExpathFileModule.PREFIX), + "Writes binary data to a file at the given offset.", + new SequenceType[]{ + FILE_PARAM, + new FunctionParameterSequenceType("value", Type.BASE64_BINARY, Cardinality.EXACTLY_ONE, "The binary data to write."), + new FunctionParameterSequenceType("offset", Type.INTEGER, Cardinality.ZERO_OR_ONE, "The byte offset at which to start writing. Default: 0.") + }, + new FunctionReturnSequenceType(Type.ITEM, Cardinality.EMPTY_SEQUENCE, "empty sequence.") + ) + }; + + public FileWrite(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + ExpathFileModuleHelper.checkDbaRole(context, this); + + final String pathStr = args[0].getStringValue(); + final Path path = ExpathFileModuleHelper.getPath(pathStr, this, context); + + checkParentDir(path); + + if (Files.isDirectory(path)) { + throw new XPathException(this, ExpathFileErrorCode.IS_DIR, + "Path is a directory: " + path.toAbsolutePath()); + } + + if (isCalledAs("write")) { + return write(path, args); + } else if (isCalledAs("write-text")) { + return writeText(path, args); + } else if (isCalledAs("write-text-lines")) { + return writeTextLines(path, args); + } else if (isCalledAs("write-binary")) { + return writeBinary(path, args); + } + + throw new XPathException(this, "Unknown function: " + getSignature().getName().getLocalPart()); + } + + private Sequence write(final Path path, final Sequence[] args) throws XPathException { + final Sequence value = args[1]; + final Properties outputProperties = new Properties(); + if (args.length > 2 && !args[2].isEmpty()) { + final Item optionsItem = args[2].itemAt(0); + if (optionsItem instanceof AbstractMapType) { + outputProperties.putAll(SerializerUtils.getSerializationOptions(this, (AbstractMapType) optionsItem)); + } else if (optionsItem instanceof NodeValue) { + SerializerUtils.getSerializationOptions(this, (NodeValue) optionsItem, outputProperties); + } + } + + final SAXSerializer sax = (SAXSerializer) SerializerPool.getInstance().borrowObject(SAXSerializer.class); + try { + final Serializer serializer = context.getBroker().borrowSerializer(); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path)), StandardCharsets.UTF_8)) { + sax.setOutput(writer, outputProperties); + serializer.setProperties(outputProperties); + serializer.setSAXHandlers(sax, sax); + + for (final SequenceIterator i = value.iterate(); i.hasNext(); ) { + final Item item = i.nextItem(); + if (Type.subTypeOf(item.getType(), Type.NODE)) { + serializer.toSAX((NodeValue) item); + } else { + writer.write(item.getStringValue()); + } + } + } finally { + context.getBroker().returnSerializer(serializer); + } + } catch (final IOException | SAXException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } finally { + SerializerPool.getInstance().returnObject(sax); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence writeText(final Path path, final Sequence[] args) throws XPathException { + final String text = args[1].getStringValue(); + final Charset encoding = getEncoding(args, 2); + try { + Files.writeString(path, text, encoding); + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence writeTextLines(final Path path, final Sequence[] args) throws XPathException { + final Charset encoding = getEncoding(args, 2); + try (final Writer writer = new OutputStreamWriter( + new BufferedOutputStream(Files.newOutputStream(path)), encoding)) { + final String lineSep = System.lineSeparator(); + for (final SequenceIterator i = args[1].iterate(); i.hasNext(); ) { + writer.write(i.nextItem().getStringValue()); + writer.write(lineSep); + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence writeBinary(final Path path, final Sequence[] args) throws XPathException { + final BinaryValue binaryValue = (BinaryValue) args[1].itemAt(0); + final long offset = args.length > 2 && !args[2].isEmpty() ? args[2].itemAt(0).toJavaObject(Long.class) : 0; + + try { + if (offset == 0) { + try (final OutputStream os = Files.newOutputStream(path); + final InputStream is = binaryValue.getInputStream()) { + is.transferTo(os); + } + } else { + if (offset < 0) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset must not be negative: " + offset); + } + if (Files.exists(path)) { + final long fileSize = Files.size(path); + if (offset > fileSize) { + throw new XPathException(this, ExpathFileErrorCode.OUT_OF_RANGE, + "Offset " + offset + " exceeds file size " + fileSize); + } + } + try (final RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw"); + final InputStream is = binaryValue.getInputStream()) { + raf.seek(offset); + is.transferTo(new OutputStream() { + @Override + public void write(int b) throws IOException { + raf.write(b); + } + @Override + public void write(byte[] b, int off, int len) throws IOException { + raf.write(b, off, len); + } + }); + } + } + } catch (final IOException e) { + throw new XPathException(this, ExpathFileErrorCode.IO_ERROR, e.getMessage()); + } + return Sequence.EMPTY_SEQUENCE; + } + + private void checkParentDir(final Path path) throws XPathException { + final Path parent = path.getParent(); + if (parent != null && !Files.isDirectory(parent)) { + throw new XPathException(this, ExpathFileErrorCode.NO_DIR, + "Parent directory does not exist: " + parent.toAbsolutePath()); + } + } + + private Charset getEncoding(final Sequence[] args, final int index) throws XPathException { + if (args.length > index && !args[index].isEmpty()) { + return ExpathFileModuleHelper.getCharset(args[index].getStringValue(), this); + } + return StandardCharsets.UTF_8; + } +} diff --git a/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml b/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml index 399137a7230..15f0afa2bf5 100644 --- a/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml +++ b/extensions/modules/expathrepo/expathrepo-trigger-test/src/test/resources/conf.xml @@ -750,6 +750,8 @@ + + diff --git a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml index 0203297b9dd..48a39125f62 100644 --- a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml +++ b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml @@ -760,6 +760,8 @@ + + diff --git a/extensions/modules/file/src/test/resources-filtered/conf.xml b/extensions/modules/file/src/test/resources-filtered/conf.xml index 11c020c728e..df096c13903 100644 --- a/extensions/modules/file/src/test/resources-filtered/conf.xml +++ b/extensions/modules/file/src/test/resources-filtered/conf.xml @@ -757,6 +757,8 @@ + + diff --git a/extensions/modules/file/src/test/resources/standalone-webapp/WEB-INF/web.xml b/extensions/modules/file/src/test/resources/standalone-webapp/WEB-INF/web.xml index 4722b24716c..6359e6f427c 100644 --- a/extensions/modules/file/src/test/resources/standalone-webapp/WEB-INF/web.xml +++ b/extensions/modules/file/src/test/resources/standalone-webapp/WEB-INF/web.xml @@ -25,9 +25,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database diff --git a/extensions/modules/image/src/test/resources-filtered/conf.xml b/extensions/modules/image/src/test/resources-filtered/conf.xml index 9df613700e8..894059f9bae 100644 --- a/extensions/modules/image/src/test/resources-filtered/conf.xml +++ b/extensions/modules/image/src/test/resources-filtered/conf.xml @@ -760,6 +760,8 @@ + + diff --git a/extensions/modules/mail/src/test/resources-filtered/conf.xml b/extensions/modules/mail/src/test/resources-filtered/conf.xml index cfebd73a39d..98508221191 100644 --- a/extensions/modules/mail/src/test/resources-filtered/conf.xml +++ b/extensions/modules/mail/src/test/resources-filtered/conf.xml @@ -749,6 +749,8 @@ + + diff --git a/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml b/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml index 6850c1477fe..c64215c2f5b 100644 --- a/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml +++ b/extensions/modules/persistentlogin/src/test/resources-filtered/conf.xml @@ -753,6 +753,8 @@ + + diff --git a/extensions/modules/persistentlogin/src/test/resources/standalone-webapp/WEB-INF/web.xml b/extensions/modules/persistentlogin/src/test/resources/standalone-webapp/WEB-INF/web.xml index 4722b24716c..6359e6f427c 100644 --- a/extensions/modules/persistentlogin/src/test/resources/standalone-webapp/WEB-INF/web.xml +++ b/extensions/modules/persistentlogin/src/test/resources/standalone-webapp/WEB-INF/web.xml @@ -25,9 +25,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database diff --git a/extensions/modules/pom.xml b/extensions/modules/pom.xml index 0f8bf723555..7ea1d52c453 100644 --- a/extensions/modules/pom.xml +++ b/extensions/modules/pom.xml @@ -52,8 +52,10 @@ cqlparser example exi + expath-binary expathrepo expathrepo/expathrepo-trigger-test + expath-file file image jndi diff --git a/extensions/modules/sql/src/test/resources-filtered/conf.xml b/extensions/modules/sql/src/test/resources-filtered/conf.xml index 09ba6545e1e..127e928e2a4 100644 --- a/extensions/modules/sql/src/test/resources-filtered/conf.xml +++ b/extensions/modules/sql/src/test/resources-filtered/conf.xml @@ -753,6 +753,8 @@ + + diff --git a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml index a1a95c324d6..f878d12a31a 100644 --- a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml +++ b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml @@ -757,6 +757,8 @@ + + diff --git a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml index 3e14e631740..284dff2b72c 100644 --- a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml +++ b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml @@ -759,6 +759,8 @@ + + diff --git a/extensions/webdav/pom.xml b/extensions/webdav/pom.xml index 9d62bedd323..4ea803a0d8a 100644 --- a/extensions/webdav/pom.xml +++ b/extensions/webdav/pom.xml @@ -84,7 +84,7 @@ - org.exist-db.thirdparty.com.ettrema + com.evolvedbinary.thirdparty.com.ettrema milton-servlet diff --git a/extensions/webdav/src/test/java/org/exist/webdav/LockTest.java b/extensions/webdav/src/test/java/org/exist/webdav/LockTest.java new file mode 100644 index 00000000000..f660745ce8c --- /dev/null +++ b/extensions/webdav/src/test/java/org/exist/webdav/LockTest.java @@ -0,0 +1,155 @@ +/* + * 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.webdav; + +import com.bradmcevoy.http.exceptions.BadRequestException; +import com.bradmcevoy.http.exceptions.ConflictException; +import com.bradmcevoy.http.exceptions.NotAuthorizedException; +import com.bradmcevoy.http.exceptions.NotFoundException; +import com.ettrema.httpclient.*; +import org.apache.http.impl.client.AbstractHttpClient; +import org.exist.TestUtils; +import org.exist.test.ExistWebServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; + +import static org.junit.Assert.*; + +/** + * Tests for WebDAV LOCK and UNLOCK operations. + */ +public class LockTest { + + @ClassRule + public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); + + @ClassRule + public static final TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void lockAndUnlockXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, + HttpException, ConflictException, NotFoundException, URISyntaxException { + final String docName = "webdav-lock-test.xml"; + final String docContent = "lock test"; + + final Host host = buildHost(); + final Folder folder = host.getFolder("/"); + assertNotNull(folder); + + // store document + final java.io.File tmpFile = tempFolder.newFile(); + Files.writeString(tmpFile.toPath(), docContent); + assertNotNull(folder.uploadFile(docName, tmpFile, null)); + + // lock + final String docUri = docUri(docName); + final String lockToken = host.doLock(docUri); + assertNotNull("LOCK should return a lock token", lockToken); + assertFalse("Lock token should not be empty", lockToken.isEmpty()); + + // unlock + final int unlockStatus = host.doUnLock(docUri, lockToken); + assertTrue("UNLOCK should return 2xx status, got " + unlockStatus, + unlockStatus >= 200 && unlockStatus < 300); + } + + @Test + public void lockAndUnlockBinDocument() throws IOException, NotAuthorizedException, BadRequestException, + HttpException, ConflictException, NotFoundException, URISyntaxException { + final String docName = "webdav-lock-test.bin"; + final String docContent = "binary lock test data"; + + final Host host = buildHost(); + final Folder folder = host.getFolder("/"); + assertNotNull(folder); + + // store document + final java.io.File tmpFile = tempFolder.newFile(); + Files.writeString(tmpFile.toPath(), docContent); + assertNotNull(folder.uploadFile(docName, tmpFile, null)); + + // lock + final String docUri = docUri(docName); + final String lockToken = host.doLock(docUri); + assertNotNull("LOCK should return a lock token", lockToken); + + // unlock + final int unlockStatus = host.doUnLock(docUri, lockToken); + assertTrue("UNLOCK should return 2xx status, got " + unlockStatus, + unlockStatus >= 200 && unlockStatus < 300); + } + + @Test + public void relockReturnsFreshToken() throws IOException, NotAuthorizedException, BadRequestException, + HttpException, ConflictException, NotFoundException, URISyntaxException { + final String docName = "webdav-relock-test.xml"; + final String docContent = "relock test"; + + final Host host = buildHost(); + final Folder folder = host.getFolder("/"); + assertNotNull(folder); + + // store document + final java.io.File tmpFile = tempFolder.newFile(); + Files.writeString(tmpFile.toPath(), docContent); + assertNotNull(folder.uploadFile(docName, tmpFile, null)); + + final String docUri = docUri(docName); + + // first lock + final String lockToken1 = host.doLock(docUri); + assertNotNull("First LOCK should succeed", lockToken1); + + // second lock by same user replaces the lock + final String lockToken2 = host.doLock(docUri); + assertNotNull("Second LOCK by same user should succeed", lockToken2); + + // cleanup: unlock with latest token + host.doUnLock(docUri, lockToken2); + } + + private String docUri(final String docName) { + return "http://localhost:" + existWebServer.getPort() + "/webdav/db/" + docName; + } + + private Host buildHost() { + final HostBuilder builder = new HostBuilder(); + builder.setServer("localhost"); + builder.setPort(existWebServer.getPort()); + builder.setUser(TestUtils.ADMIN_DB_USER); + builder.setPassword(TestUtils.ADMIN_DB_PWD); + builder.setRootPath("webdav/db"); + final Host host = builder.buildHost(); + + // preemptive Basic auth for all requests + final AbstractHttpClient httpClient = (AbstractHttpClient) host.getClient(); + httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); + + return host; + } +} diff --git a/extensions/webdav/src/test/resources-filtered/conf.xml b/extensions/webdav/src/test/resources-filtered/conf.xml index 5dc0efc380a..afa5e53bbb0 100644 --- a/extensions/webdav/src/test/resources-filtered/conf.xml +++ b/extensions/webdav/src/test/resources-filtered/conf.xml @@ -743,6 +743,8 @@ + + diff --git a/extensions/webdav/src/test/resources/standalone-webapp/WEB-INF/web.xml b/extensions/webdav/src/test/resources/standalone-webapp/WEB-INF/web.xml index 3a4f5698102..8779b57ee79 100644 --- a/extensions/webdav/src/test/resources/standalone-webapp/WEB-INF/web.xml +++ b/extensions/webdav/src/test/resources/standalone-webapp/WEB-INF/web.xml @@ -25,9 +25,9 @@ + version="6.0"> eXist-db – Open Source Native XML Database eXist-db XML Database diff --git a/extensions/xqdoc/src/test/resources-filtered/conf.xml b/extensions/xqdoc/src/test/resources-filtered/conf.xml index 7c96ef98809..9d12d005787 100644 --- a/extensions/xqdoc/src/test/resources-filtered/conf.xml +++ b/extensions/xqdoc/src/test/resources-filtered/conf.xml @@ -759,6 +759,8 @@ + + diff --git a/pom.xml b/pom.xml index f82e32d6b2b..09b6c322159 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ exist-jetty-config exist-samples exist-service + exist-services exist-start extensions exist-xqts diff --git a/schema/collection.xconf.xsd b/schema/collection.xconf.xsd index f64f800b1c9..fcd8eb16ede 100644 --- a/schema/collection.xconf.xsd +++ b/schema/collection.xconf.xsd @@ -338,7 +338,7 @@ - + diff --git a/taskings/grammar-dispatch-audit.py b/taskings/grammar-dispatch-audit.py new file mode 100644 index 00000000000..dca591ac469 --- /dev/null +++ b/taskings/grammar-dispatch-audit.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +""" +Grammar Dispatch Audit: Cross-references EBNF productions against +the rd parser's parse* method calls to find Expr/ExprSingle mismatches. + +Usage: + python3 taskings/grammar-dispatch-audit.py + +Reads: + ~/workspace/eXide/grammars/XQuery-40-Family-XQUFEL.ebnf + ~/workspace/exist/.claude/worktrees/feature-new-parser/exist-core/src/main/java/org/exist/xquery/parser/next/XQueryParser.java + +Produces: a report of all productions, their EBNF references, parser calls, +and any Expr/ExprSingle mismatches. +""" + +import re +import sys +from pathlib import Path +from collections import defaultdict + +EBNF_PATH = Path.home() / "workspace/eXide/grammars/XQuery-40-Family-XQUFEL.ebnf" +PARSER_PATH = Path.home() / "workspace/exist/.claude/worktrees/feature-new-parser/exist-core/src/main/java/org/exist/xquery/parser/next/XQueryParser.java" + + +def parse_ebnf(path): + """Extract productions and their references from EBNF.""" + content = path.read_text() + + # Parse productions: Name ::= RHS + # Handle multi-line productions (continuation lines start with whitespace or |) + productions = {} + current_name = None + current_rhs = [] + + for line in content.split('\n'): + # Skip comments + stripped = line.strip() + if stripped.startswith('/*') or stripped.startswith('*') or stripped.startswith('//'): + continue + + # New production: starts with letter at column 0 + m = re.match(r'^([A-Z]\w*)\s*$', line) + if m: + # Name on its own line, ::= on next + if current_name and current_rhs: + productions[current_name] = ' '.join(current_rhs) + current_name = m.group(1) + current_rhs = [] + continue + + m = re.match(r'^([A-Z]\w*)\s+::=\s*(.*)', line) + if m: + if current_name and current_rhs: + productions[current_name] = ' '.join(current_rhs) + current_name = m.group(1) + current_rhs = [m.group(2)] + continue + + # Continuation: ::= on its own or continuation of RHS + if current_name: + m = re.match(r'^\s+::=\s*(.*)', line) + if m: + current_rhs.append(m.group(1)) + continue + if line.startswith(' ') or line.startswith('\t'): + current_rhs.append(stripped) + continue + elif stripped == '': + continue + else: + # End of current production + if current_rhs: + productions[current_name] = ' '.join(current_rhs) + current_name = None + current_rhs = [] + + if current_name and current_rhs: + productions[current_name] = ' '.join(current_rhs) + + # Extract references from each production's RHS + production_refs = {} + for name, rhs in productions.items(): + # Find all PascalCase references (production names) + refs = re.findall(r'\b([A-Z][A-Za-z0-9]+)\b', rhs) + # Filter out string literals and common non-productions + refs = [r for r in refs if r not in ('CDATA', 'EOF', 'NOT', 'AND', 'OR', 'IN')] + production_refs[name] = refs + + return productions, production_refs + + +def parse_parser(path): + """Extract parse methods and their calls from the rd parser.""" + content = path.read_text() + lines = content.split('\n') + + # Find all method definitions and their bodies + methods = {} + current_method = None + current_body = [] + brace_depth = 0 + + for i, line in enumerate(lines): + # Match method declarations + m = re.match(r'\s+(?:private|public|protected)?\s*(?:static\s+)?(?:\w+\s+)?(parse\w+)\s*\(', line) + if m and '{' not in line[:line.index('parse')]: + m = re.match(r'\s+.*\b(parse\w+)\s*\(', line) + if m: + method_name = m.group(1) + if current_method and current_body: + methods[current_method] = '\n'.join(current_body) + current_method = method_name + current_body = [line] + brace_depth = line.count('{') - line.count('}') + continue + + if current_method: + current_body.append(line) + brace_depth += line.count('{') - line.count('}') + if brace_depth <= 0 and len(current_body) > 2: + methods[current_method] = '\n'.join(current_body) + current_method = None + current_body = [] + brace_depth = 0 + + if current_method and current_body: + methods[current_method] = '\n'.join(current_body) + + # For each method, extract calls to other parse* methods + method_calls = {} + for name, body in methods.items(): + # Find all parseXxx() calls + calls = re.findall(r'\b(parse\w+)\s*\(', body) + # Remove self-references + calls = [c for c in calls if c != name] + method_calls[name] = calls + + return methods, method_calls + + +def find_expr_mismatches(productions, production_refs, methods, method_calls): + """Find cases where EBNF says ExprSingle but parser uses parseExpr or vice versa.""" + + # Key EBNF rules that reference Expr vs ExprSingle + # ExprSingle is used in: return clauses, function args, predicates, etc. + # Expr (comma-separated) is used in: function body, parenthesized expressions, element content + + mismatches = [] + + # Map EBNF productions to likely parser methods + ebnf_to_parser = { + 'ReturnClause': 'parseFLWOR', + 'LetClause': 'parseLetBinding', + 'ForClause': 'parseForBinding', + 'WhereClause': 'parseWhereClause', + 'OrderSpec': 'parseOrderByClause', + 'QuantifiedExpr': 'parseQuantified', + 'IfExpr': 'parseIfExpr', + 'SwitchExpr': 'parseSwitchExpr', + 'TypeswitchExpr': 'parseTypeswitchExpr', + 'TryCatchExpr': 'parseTryCatchExpr', + 'FunctionBody': 'parseFunctionDecl', # also parseInlineFunction + 'EnclosedExpr': 'scanEnclosedExpr', + 'Argument': 'parseFunctionArg', + 'CompElemConstructor': 'parseComputedElementConstructor', + 'CompAttrConstructor': 'parseComputedAttributeConstructor', + 'CompTextConstructor': 'parseComputedTextConstructor', + 'CompCommentConstructor': 'parseComputedCommentConstructor', + 'CompDocConstructor': 'parseComputedDocumentConstructor', + 'CompPIConstructor': 'parseComputedPIConstructor', + 'CompNamespaceConstructor': 'parseComputedNamespaceConstructor', + 'Predicate': 'parsePredicate', + 'ParenthesizedExpr': 'parseParenthesized', + 'CastExpr': 'parseCastExpr', + 'CastableExpr': 'parseCastableExpr', + 'TreatAsExpr': 'parseTreatExpr', + 'InstanceofExpr': 'parseInstanceOfExpr', + 'InsertExpr': 'parseInsertExpr', + 'DeleteExpr': 'parseDeleteExpr', + 'ReplaceExpr': 'parseReplaceExpr', + 'RenameExpr': 'parseRenameExpr', + 'TransformExpr': 'parseTransformExpr', + 'WindowClause': 'parseWindowClause', + 'WhileClause': 'parseWhileClause', + 'CountClause': 'parseCountClause', + 'GroupByClause': 'parseGroupByClause', + } + + # Productions that should use ExprSingle (not Expr) per EBNF + expr_single_productions = set() + expr_productions = set() + for name, rhs in productions.items(): + if 'ExprSingle' in rhs: + expr_single_productions.add(name) + if re.search(r'\bExpr\b', rhs) and 'ExprSingle' not in rhs: + expr_productions.add(name) + + # Check each mapped production + for ebnf_name, parser_method in ebnf_to_parser.items(): + if parser_method not in method_calls: + continue + + calls = method_calls[parser_method] + method_body = methods.get(parser_method, '') + + # Check: does the EBNF say ExprSingle but parser calls parseExpr? + if ebnf_name in expr_single_productions: + expr_calls = [c for c in calls if c == 'parseExpr'] + if expr_calls: + # Verify it's not inside a sub-block (like function body) + # Simple heuristic: check if parseExpr appears in context + mismatches.append({ + 'production': ebnf_name, + 'parser_method': parser_method, + 'issue': 'EBNF says ExprSingle but parser calls parseExpr()', + 'severity': 'BUG', + 'detail': f'Found {len(expr_calls)} parseExpr() calls where ExprSingle expected' + }) + + # Check: does the EBNF say Expr but parser calls parseExprSingle? + if ebnf_name in expr_productions: + if 'parseExprSingle' in calls and 'parseExpr' not in calls: + mismatches.append({ + 'production': ebnf_name, + 'parser_method': parser_method, + 'issue': 'EBNF says Expr but parser only calls parseExprSingle()', + 'severity': 'BUG', + 'detail': 'Too restrictive — should allow comma-separated sequences' + }) + + # Specific checks: (EBNF_name, parser_method, expected_level, expected_call) + # These verify that each parse* method uses the correct Expr vs ExprSingle + specific_checks = [ + # === ExprSingle contexts (MUST NOT use parseExpr) === + # ReturnClause ::= "return" ExprSingle + ('ReturnClause', 'parseFLWOR', 'ExprSingle', 'parseExprSingle'), + # Argument ::= ExprSingle | ArgumentPlaceholder + ('Argument', 'parseFunctionArg', 'ExprSingle', 'parseExprSingle'), + # WhereClause ::= "where" ExprSingle + ('WhereClause', 'parseWhereClause', 'ExprSingle', 'parseExprSingle'), + # OrderSpec key ::= ExprSingle + ('OrderSpec', 'parseOrderByClause', 'ExprSingle', 'parseExprSingle'), + # QuantifierBinding ::= "$" VarName "in" ExprSingle + ('QuantifierBinding', 'parseQuantified', 'ExprSingle', 'parseExprSingle'), + # WindowCondition when ::= ExprSingle + ('WindowCondition', 'parseWindowCondition', 'ExprSingle', 'parseExprSingle'), + # WhileClause ::= "while" ExprSingle + ('WhileClause', 'parseWhileClause', 'ExprSingle', 'parseExprSingle'), + # MapConstructorEntry ::= ExprSingle ":" ExprSingle + ('MapConstructorEntry', 'parseMapEntry', 'ExprSingle', 'parseExprSingle'), + # ForBinding in ::= ExprSingle + ('ForBinding', 'parseForBinding', 'ExprSingle', 'parseExprSingle'), + # LetBinding ::= ExprSingle + ('LetBinding', 'parseLetBinding', 'ExprSingle', 'parseExprSingle'), + + # === Expr contexts (MAY use parseExpr) === + # Predicate ::= "[" Expr "]" + ('Predicate', 'parsePredicate', 'Expr', 'parseExpr'), + # ParenthesizedExpr ::= "(" Expr? ")" + ('ParenthesizedExpr', 'parseParenthesized', 'Expr', 'parseExpr'), + # FunctionBody ::= EnclosedExpr ::= "{" Expr "}" + ('FunctionBody', 'parseFunctionDecl', 'Expr', 'parseExpr'), + # EnclosedExpr inside XML ::= "{" Expr "}" + ('EnclosedExpr', 'scanEnclosedExpr', 'Expr', 'parseExpr'), + # CompDocConstructor ::= "document" "{" Expr "}" + ('CompDocConstructor', 'parseComputedDocumentConstructor', 'Expr', 'parseExpr'), + # TryClause ::= "try" "{" Expr "}" + ('TryClause', 'parseTryCatchExpr', 'Expr', 'parseExpr'), + ] + + for ebnf_name, parser_method, expected_level, expected_call in specific_checks: + if parser_method not in methods: + continue + body = methods[parser_method] + + # Find actual calls in the method body + actual_expr_calls = list(re.finditer(r'\b(parseExpr|parseExprSingle)\s*\(', body)) + + for match in actual_expr_calls: + actual_call = match.group(1) + if actual_call != expected_call: + # Get line number context + pos = match.start() + line_num = body[:pos].count('\n') + 1 + context_line = body.split('\n')[line_num - 1].strip()[:80] + + mismatches.append({ + 'production': ebnf_name, + 'parser_method': parser_method, + 'issue': f'EBNF says {expected_level} but parser calls {actual_call}()', + 'severity': 'BUG' if expected_level == 'ExprSingle' and actual_call == 'parseExpr' else 'WARN', + 'detail': f'Line ~{line_num}: {context_line}' + }) + + return mismatches + + +def main(): + print("=" * 80) + print("Grammar Dispatch Audit: EBNF vs rd Parser") + print("=" * 80) + print() + + # Parse EBNF + if not EBNF_PATH.exists(): + print(f"ERROR: EBNF file not found: {EBNF_PATH}") + sys.exit(1) + productions, production_refs = parse_ebnf(EBNF_PATH) + print(f"EBNF: {len(productions)} productions parsed") + + # Parse rd parser + if not PARSER_PATH.exists(): + print(f"ERROR: Parser file not found: {PARSER_PATH}") + sys.exit(1) + methods, method_calls = parse_parser(PARSER_PATH) + print(f"Parser: {len(methods)} parse methods found") + print() + + # Find mismatches + mismatches = find_expr_mismatches(productions, production_refs, methods, method_calls) + + # Report + bugs = [m for m in mismatches if m['severity'] == 'BUG'] + warns = [m for m in mismatches if m['severity'] == 'WARN'] + + print(f"{'='*80}") + print(f"MISMATCHES FOUND: {len(bugs)} bugs, {len(warns)} warnings") + print(f"{'='*80}") + print() + + if bugs: + print("=== BUGS (Expr/ExprSingle mismatch) ===") + for m in bugs: + print(f" [{m['severity']}] {m['production']} ({m['parser_method']})") + print(f" {m['issue']}") + print(f" {m['detail']}") + print() + + if warns: + print("=== WARNINGS ===") + for m in warns: + print(f" [{m['severity']}] {m['production']} ({m['parser_method']})") + print(f" {m['issue']}") + print(f" {m['detail']}") + print() + + # Summary + print(f"{'='*80}") + print(f"SUMMARY") + print(f"{'='*80}") + print(f" EBNF productions: {len(productions)}") + print(f" Parser methods: {len(methods)}") + print(f" Bugs found: {len(bugs)}") + print(f" Warnings: {len(warns)}") + + if bugs: + print() + print(" FIX NEEDED:") + for m in bugs: + print(f" - {m['parser_method']}: change parseExpr() to parseExprSingle()") + sys.exit(1) + else: + print() + print(" ALL CLEAR: No Expr/ExprSingle mismatches found!") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/taskings/jnode-type-hierarchy-overhaul.md b/taskings/jnode-type-hierarchy-overhaul.md new file mode 100644 index 00000000000..e8d34b1626c --- /dev/null +++ b/taskings/jnode-type-hierarchy-overhaul.md @@ -0,0 +1,212 @@ +# JNode Type Hierarchy Overhaul — Tasking + +## Context + +eXist-db 7.0 (next-v3) has initial XQuery 4.0 JNode support implemented via an +**intercept approach**: LocationStep and PathExpr detect maps/arrays/JNodes at +runtime and dispatch to parallel navigation code. This achieved +71 XQTS tests +(from 17 to ~64 of ~200 JNode path tests) but has structural limitations. + +### What the intercept approach achieved +- `Type.isNavigable()` — maps/arrays accepted as LHS of `/` +- `LocationStep.evalMapArrayAxis()` — child/descendant/self on maps/arrays +- `LocationStep.evalJNodeAxis()` — all 15 XPath axes on JNodes +- `PathExpr.containsNavigableItem()` — runtime type detection +- `fn:jvalue()`/`fn:jkey()` tolerance of non-JNode inputs +- `fn:get()` returns empty for out-of-bounds +- NameTest key matching, wildcard `*` matching on JNodes + +### Where the intercept approach fails (~130 remaining tests) +1. **Multi-step paths with intermediate typing** — `$map/store/book/*/author` + loses map type after step 2, PathExpr's `gotAtomicResult` flag blocks step 3 +2. **`get()` as path step** — `$array//get(3)` requires parser grammar changes +3. **Mixed XML/JSON sequences** — only first item checked for type routing +4. **Document ordering** — JNodes have no natural document order across trees +5. **`node()` type matching** — JNodes don't participate in standard `node()` tests + outside our intercept points +6. **`gnode()` unification** — XML nodes don't implement the GNode interface + +## Goal + +Unify XML and JSON nodes under a common navigation interface so the path +expression engine handles both uniformly, without special intercepts. + +## Design: The GNode Approach (Approach 2, extended) + +### Phase 1: GNode Interface Expansion + +**GNode.java** (currently 92 lines) becomes the universal navigation interface: + +```java +public interface GNode extends Item { + // Identity + boolean isSameNode(GNode other); + + // Tree navigation + GNode getGNodeParent(); + List getGNodeChildren(); + GNode getGNodeRoot(); + + // Axes (all 15) + List getFollowingSiblings(); + List getPrecedingSiblings(); + List getFollowing(); + List getPreceding(); + List getDescendants(); + List getAncestors(); + + // Node properties + String getGNodeName(); // element name or map key + Sequence getGNodeValue(); // typed value + int getGNodeKind(); // Type constant + int getDocumentOrderPosition(); // for ordering across trees +} +``` + +**Effort**: 1-2 days. Low risk — interface only. + +### Phase 2: JNode implements GNode (already done) + +JNode already implements GNode with all axis methods. This phase is complete. + +### Phase 3: XML Node GNode Adapter + +Create `GNodeAdapter` that wraps eXist XML nodes (`NodeProxy`, `NodeImpl`) to +implement GNode: + +```java +public class XmlGNodeAdapter implements GNode { + private final NodeValue xmlNode; + // Delegates to existing DOM/eXist node navigation +} +``` + +This does NOT modify NodeProxy or NodeImpl — it wraps them. The adapter is +created on-demand when XML nodes enter JNode-aware code paths. + +**Key decisions**: +- Adapter created lazily (not for every XML node) +- Document ordering: XML nodes keep their existing ordering; JNodes get + synthetic positions based on tree structure +- Mixed sequences: sort by (source-document, position) with JSON trees + getting synthetic document IDs + +**Effort**: 3-5 days. Medium risk — must not regress XML processing. + +### Phase 4: LocationStep GNode Unification + +Replace the current three-way dispatch (XML nodes / JNodes / maps/arrays) with +a single GNode code path: + +```java +// Current: three separate paths +if (hasJNode) { evalJNodeAxis(ctx); } +else if (hasMapArray) { evalMapArrayAxis(ctx); } +else { /* normal XML processing */ } + +// Target: single GNode path when context contains GNodes +if (hasGNode) { evalGNodeAxis(ctx); } +else { /* normal XML processing for persistent nodes */ } +``` + +The `evalGNodeAxis` method works uniformly on any GNode, whether it wraps an +XML node, a JNode, or a map/array. + +**Critical**: The normal XML processing path (persistent NodeSets, structural +indexes) must NOT be affected. GNode routing only activates for in-memory nodes +and JNodes. + +**Effort**: 5-7 days. High risk — touches the core evaluation loop. + +### Phase 5: PathExpr Simplification + +With GNode unification, PathExpr no longer needs: +- `containsNavigableItem()` hack +- `Type.isNavigable()` (replaced by `instanceof GNode` checks) +- `gotAtomicResult` special cases for maps/arrays + +The type flow becomes: if a step returns GNodes, the next step can navigate them. +If it returns atomics, XPTY0019 fires. Clean and structural. + +**Effort**: 2-3 days. Medium risk — simplification, not new code. + +### Phase 6: Map/Array Auto-wrapping + +When a raw map or array enters a path expression context, auto-wrap it as a +JNode tree (equivalent to implicit `jtree()`). This eliminates the need for +separate `evalMapArrayAxis` — all map/array navigation goes through JNode. + +```java +// In LocationStep, when context item is a map/array: +if (item instanceof MapType || item instanceof ArrayType) { + item = JNode.wrap(item); // Creates JNode tree on the fly +} +// Then handle as GNode +``` + +**Effort**: 2-3 days. Medium risk. + +### Phase 7: Parser Grammar Additions + +Independent of the type hierarchy, but needed for full compliance: + +1. **`get()` as path step** — `child::get("key")`, `descendant::get(3)` +2. **`jnode(*, type)` kind test** — `jnode(*, xs:integer)`, `jnode(*, record(...))` +3. **`gnode()` kind test** — matches any GNode (XML or JSON) +4. **Union types in kind tests** — `jnode(*, xs:string | xs:integer)` + +Grammar changes in XQuery.g (ANTLR 2) and XQueryParser.java (RD parser). + +**Effort**: 3-5 days. Medium risk — parser changes are well-understood. + +## Risk Analysis + +| Phase | Risk | Mitigation | +|-------|------|------------| +| 1-2 | Low | Interface-only, JNode already works | +| 3 | Medium | Adapter pattern isolates changes from XML code | +| 4 | **High** | Core eval loop; extensive regression testing needed | +| 5 | Medium | Removing hacks, not adding complexity | +| 6 | Medium | JNode.wrap() is simple, but ordering semantics are complex | +| 7 | Medium | Parser changes are well-understood from XQ4 work | + +## Testing Strategy + +1. **Before starting**: Baseline XQTS scores for QT4, XQ 3.1, and FTTS +2. **After each phase**: Run full XQTS + JNode JUnit tests + XQSuite +3. **XQ 3.1 regression gate**: Must not drop below current 93% +4. **QT4 target**: 75%+ of JNode path tests (from current ~32%) + +## Estimated Total Effort + +- **Minimum viable** (Phases 1-4): 10-15 days +- **Full overhaul** (Phases 1-7): 20-25 days +- **Recommended approach**: Ship Phases 1-4 as `v4/gnode-overhaul`, then + Phases 5-7 as follow-ups + +## Dependencies + +- No external dependencies +- Should be based on `next-v3` (which has the intercept approach as foundation) +- Parser changes (Phase 7) can proceed in parallel with Phases 3-5 + +## Reference Implementations + +- **BaseX** (`org.basex.query.value.node`): Single `FNode` hierarchy for all + node types (XML elements, JSON nodes, function items). ~8 classes, ~2000 lines. + Key insight: JSON nodes are first-class nodes with document ordering. +- **Saxon**: Separate `NodeInfo` interface implemented by both XML and JSON + node implementations. More similar to our GNode approach. + +## Open Questions + +1. **Document ordering across JSON trees**: How should two independent JNode + trees be ordered relative to each other? BaseX assigns synthetic document + IDs. We need the same for `<<` / `>>` operators. +2. **Schema typing**: JNode values are untyped. Should `jnode(*, xs:integer)` + check the dynamic type of the value, or require schema annotation? +3. **Mutability**: XQuery Update on JNodes? BaseX supports it. For v4, we + could defer this. +4. **Serialization**: JNode trees in `fn:serialize()` with method=json. Our + current JNode serializer works but isn't integrated with the main + serialization pipeline. diff --git a/taskings/v3-reviewer-guide.md b/taskings/v3-reviewer-guide.md new file mode 100644 index 00000000000..58f42a2f333 --- /dev/null +++ b/taskings/v3-reviewer-guide.md @@ -0,0 +1,319 @@ +# Reviewer Guide: eXist-db 7.0 (next-v3) + +## Overview + +The `next-v3` integration branch merges 37 PRs (14 `v2/` + 23 additional) into a single testable branch based on a clean `v2/new-parser` foundation. It includes 293 commits ahead of `develop`, comprising 52 merged branches plus 103 post-merge conformance fixes plus 11 post-90 reconciliation cherry-picks. + +This branch powers the **Docker demo image** with a complete redesigned application suite. + +### Quick Start + +```bash +docker pull joewiz/existdb:next-v3 +docker run -d --name existdb -p 8080:8080 -p 8443:8443 joewiz/existdb:next-v3 +# Access at http://localhost:8080/exist/apps/dashboard/ +``` + +Default admin password is empty (just press Log in). + +### Testing Methodology + +Every branch was tested individually: build (`mvn install -pl exist-core -am`), exist-core unit tests, and Codacy static analysis. Branches with grammar changes also ran XQTS compliance suites. All branches were then merged together in `next-v3` and tested again. The Docker image includes a complete application suite with Cypress E2E tests for each app. + +--- + +## Review Waves + +PRs are organized into 7 waves by dependency and review complexity. **You can review in any order**, but merging should follow the wave order to minimize conflicts. + +### Wave 1: Small, Independent Bugfixes (merge in any order) + +These fix real bugs with tests. No shared infrastructure changes. Each can be reviewed and merged independently. + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6207](https://github.com/eXist-db/exist/pull/6207) | `v2/xq31-compliance-fixes` | XQuery 3.1 compliance fixes (23 bugfixes) | Medium | Low | +| [#6222](https://github.com/eXist-db/exist/pull/6222) | `v2/copy-namespaces-fix` | Fix `declare copy-namespaces no-inherit` | Low | Low | +| [#6191](https://github.com/eXist-db/exist/pull/6191) | `bugfix/flwor-sort-race-condition` | Fix race condition in OrderedValueSequence | Low | Low | +| [#6180](https://github.com/eXist-db/exist/pull/6180) | `bugfix/hof-parameter-type-checking` | Enforce HOF parameter type checking | Low | Low | +| [#6181](https://github.com/eXist-db/exist/pull/6181) | `bugfix/improve-wrong-arity-error-message` | Better error messages for wrong-arity calls | Low | Low | +| [#6088](https://github.com/eXist-db/exist/pull/6088) | `fix/2205-context-problem-map-get-predicate` | Fix context problem with map:get() in predicates | Low | Low | +| [#6089](https://github.com/eXist-db/exist/pull/6089) | `fix/5103-fn-lang-context-corruption` | Fix fn:lang corrupting XQueryContext | Low | Low | +| [#6090](https://github.com/eXist-db/exist/pull/6090) | `fix/4425-format-date-string-type-check` | Reject xs:string in format-date() | Low | Low | +| [#6094](https://github.com/eXist-db/exist/pull/6094) | `fix/issue-5189-range-index-prefixed-condition` | Fix namespace for prefixed attribute in range index | Low | Low | +| [#6095](https://github.com/eXist-db/exist/pull/6095) | `fix/issue-3989-xml-to-json-stored` | Fix fn:xml-to-json on stored XML | Low | Low | +| [#6110](https://github.com/eXist-db/exist/pull/6110) | `fix/path-expr-dedup-function-calls` | Fix dedup for function calls in path exprs | Low | Low | +| [#6081](https://github.com/eXist-db/exist/pull/6081) | `fix/issue-2529-timeout-option` | Restore `declare option exist:timeout "-1"` | Low | Low | +| [#4641](https://github.com/eXist-db/exist/pull/4641) | `relax-anyuri-params-allow-string-2` | Relax xs:anyURI parameters to accept xs:string | Low | Low | +| [#6206](https://github.com/eXist-db/exist/pull/6206) | `feature/xinclude-test-suite` | W3C XInclude test suite + conformance improvements | Low | Low | + +### Wave 2: Small Features (merge in any order, before Wave 3) + +No shared infrastructure changes. Can be reviewed independently. + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6208](https://github.com/eXist-db/exist/pull/6208) | `v2/query-profiling` | Query profiling functions (util:time, memory, track, explain) | Low | Very low | +| [#6209](https://github.com/eXist-db/exist/pull/6209) | `v2/xq4-axes` | XQuery 4.0 axes (following-or-self, etc.) | Low | Low | +| [#6210](https://github.com/eXist-db/exist/pull/6210) | `v2/xq4-record-types` | XQuery 4.0 record type declarations | Low | Low | +| [#6211](https://github.com/eXist-db/exist/pull/6211) | `v2/xq4-filter-expr-am` | XQuery 4.0 `?[expr]` array/map filter | Very low | Low | +| [#6092](https://github.com/eXist-db/exist/pull/6092) | `feature/zn-timezone-modifier` | [ZN] timezone name modifier for format-dateTime | Low | Low | +| [#6182](https://github.com/eXist-db/exist/pull/6182) | `feature/module-discovery` | Unify module discovery | Low | Low | +| [#6184](https://github.com/eXist-db/exist/pull/6184) | `feature/repo-resource-available` | Add repo:resource-available() | Low | Low | +| [#6192](https://github.com/eXist-db/exist/pull/6192) | `feature/collection-file-uris` | Support file: URIs in fn:collection() | Low | Low | +| [#6112](https://github.com/eXist-db/exist/pull/6112) | `feature/preclaiming-locks` | Preclaiming two-phase locking for XQuery updates | Medium | Low | + +### Wave 3: Infrastructure Upgrades (merge before Wave 4) + +Major dependency upgrades. Don't conflict with each other but should merge before grammar PRs. + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6212](https://github.com/eXist-db/exist/pull/6212) | `v2/saxon-12-upgrade` | Saxon 9.9 → 12.5 (eliminates exist-saxon-regex) | Medium | Medium | +| [#6145](https://github.com/eXist-db/exist/pull/6145) | `feature/websocket-core` | Jetty 11 → 12 (Jakarta Servlet 6.0) + WebSocket | Medium | Medium | + +**Saxon 12**: The main work is migrating fn:replace/fn:matches/fn:analyze-string from Saxon 9's `CharSequence` API to Saxon 12's `UnicodeString` API. Eliminates the unmaintained `exist-saxon-regex` fork module entirely. + +**Jetty 12**: `javax.servlet` → `jakarta.servlet` across all modules. Addresses review feedback from @reinhapa and @dizzzz. Also adds a WebSocket module with streaming XQuery evaluation. + +### Wave 4: Grammar Changes (merge in order — these touch XQuery.g/XQueryTree.g) + +These PRs modify the ANTLR 2 grammar. They use **labeled sections** to minimize conflicts, but should be merged in the listed order. Grammar conflicts between them are trivial (keyword list sections). + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6214](https://github.com/eXist-db/exist/pull/6214) | `v2/w3c-xquery-update-3.0` | W3C XQuery Update Facility 3.0 | **High** | Medium | +| [#6215](https://github.com/eXist-db/exist/pull/6215) | `v2/xqft-phase2` | W3C Full Text 3.0 | **High** | Low-medium | +| [#6216](https://github.com/eXist-db/exist/pull/6216) | `v2/xquery-4.0-parser` | XQuery 4.0 parser + version gating | **High** | Medium | +| [#6217](https://github.com/eXist-db/exist/pull/6217) | `v2/declare-decimal-format` | declare decimal-format (depends on merged grammar) | Low | Low | + +**XQUF 3.0**: copy-modify-return, insert, delete, replace, rename. New `org.exist.xquery.xquf` package with PUL architecture. XQTS: 684/684 non-schema (100%). + +**XQFT 3.0**: `contains text` expressions with stemming, thesaurus, wildcards, proximity, scoring. New `org.exist.xquery.ft` package. XQTS FTTS: 659/667 (98.8%). + +**XQ4 Parser**: All XQ4 syntax (pipeline `->`, otherwise, ternary, string templates, etc.). Version gating per @line-o's review: XQ4 constructs throw XPST0003 in `xquery version "3.1"` mode. Feature flag `exist.xquery4.enabled`. + +### Wave 5: Functions (merge after parser) + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6218](https://github.com/eXist-db/exist/pull/6218) | `v2/xq4-core-functions` | 82 XQuery 4.0 functions (fn:, array:, map:, math:) | **High** | Low-medium | + +Largest PR by file count (130 files). Includes fn:replace/fn:tokenize empty-match version gating (XQ4 behavior only in `xquery version "4.0"` mode). + +### Wave 6: Serialization, Parser, and Platform (merge after Wave 5, any order within) + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6219](https://github.com/eXist-db/exist/pull/6219) | `v2/serialization-compliance` | W3C serialization compliance (XML/HTML/JSON/CSV) | Medium | Low-medium | +| [#6220](https://github.com/eXist-db/exist/pull/6220) | `v2/new-parser` | Recursive descent parser (opt-in via `-Dexist.parser=rd`) | Medium | Very low | +| [#6087](https://github.com/eXist-db/exist/pull/6087) | `fix/issue-2291-xinclude-relative-paths` | Fix XInclude relative path resolution | Low | Low | +| [#6154](https://github.com/eXist-db/exist/pull/6154) | `feature/native-restxq` | Native RESTXQ (replaces EXQuery library) | Medium | Medium | + +**Recursive descent parser**: 15-82x faster than ANTLR 2, opt-in only. Zero impact on existing behavior. + +**Serialization**: Fixes across all output methods. Critical fix: self-closing meta tags in XHTML mode that broke the URL rewrite view pipeline. + +### Wave 7: Platform Infrastructure (merge last — builds on all above) + +| PR | Branch | Title | Effort | Risk | +|----|--------|-------|--------|------| +| [#6247](https://github.com/eXist-db/exist/pull/6247) | `feature/builtin-package-api` | Built-in PackageManagementServlet | Medium | Low | +| [#6248](https://github.com/eXist-db/exist/pull/6248) | `feature/openapi-routing` | PoC: OpenAPI routing with controller.json | Medium | Low | + +**PackageManagementServlet**: Replaces the packageservice XAR with a built-in servlet. Apps can now self-upgrade without needing packageservice installed. + +**OpenAPI routing**: Proof-of-concept OpenApiServlet that reads `api.json` + `controller.json` from an app collection and routes requests to XQuery handlers — no Roaster, no controller.xql boilerplate. + +--- + +## Merge Order Summary + +``` +Wave 1 (any order): 14 bugfix PRs (#6207, #6222, #6191, #6180, #6181, + #6088, #6089, #6090, #6094, #6095, #6110, #6081, #4641, #6206) + +Wave 2 (any order): 9 small feature PRs (#6208, #6209, #6210, #6211, + #6092, #6182, #6184, #6192, #6112) + +Wave 3 (any order): #6212 Saxon 12 + #6145 Jetty 12 + +Wave 4 (in order): #6214 XQUF 3.0 + #6215 XQFT 3.0 + #6216 XQ4 Parser + #6217 declare decimal-format + +Wave 5: #6218 XQ4 Functions + +Wave 6 (any order): #6219 Serialization + #6220 RD Parser + #6087 XInclude fix + #6154 Native RESTXQ + +Wave 7 (any order): #6247 Built-in Package API + #6248 OpenAPI routing (PoC) + +Standalone (any time): #6264 [optimize] XQuery local-variable resolution + (joewiz:feature/performance-optimizations, base develop) + +Deferred (after Waves): optimizer/expression-optimize-method + feature/optimizer-index-rewriter + feature/jnode-paths-axes (post-90 JNode fixes) +``` + +### Deferred PRs (depend on integration) + +Three branches hold post-90 work that cannot stand against `develop` until the v2/ wave merges: + +- **`optimizer/expression-optimize-method`** (`joewiz`, 268 commits ahead of develop): + `Expression.optimize()` framework + CompileContext, optimize() prototypes on + Conditional/GeneralComparison/LetExpr, FLWOR loop-invariant hoisting via + rewrite-into-let, FLWOR hash-join recognition. Open as PR after Waves 1–6 land + — the diff against `develop` shrinks to ~7 files at that point. + +- **`feature/optimizer-index-rewriter`** (`joewiz`, 284 commits ahead of develop): + Four new `QueryRewriter` hook methods, priority ordering, INFO/profiler + surfacing, FilteredExpr/comparison/function/where wiring, predicate + distribution over LocationStep unions (issue #2363). Open after the optimizer + framework PR above. + +- **`feature/jnode-paths-axes`** (`joewiz`): Two extra commits (`5845a45a10` + JNode XQ4 0-arg overloads/root behavior; `61e3e76a1a` get()-as-step / lookup / + type-check fix) require `feature/xq4-type-promotion` (DynamicTypeCheck.isXQ4* + helpers) and `feature/lookup-edge-cases` (`nextItem` field). They compile and + pass on `next-v3` but not on `feature/jnode-paths-axes` standalone — rebase + after Waves land. + +--- + +## CI Health Note + +**Known noise in CI results** — do not treat these as blockers: + +- **Integration failures (ubuntu/windows/macOS)**: 1-3 integration job failures per PR. Pre-existing test hangs (surefire fork timeout fires). Not caused by any v2/v3 change. +- **XQTS runner crash on #6212 (Saxon 12)**: CI XQTS job uses the Saxon 9.9 runner against Saxon 12 classpath. [exist-xqts-runner #49](https://github.com/eXist-db/exist-xqts-runner/pull/49) adds Saxon 12 compatibility — merge alongside #6212. +- **`replace.empty-match` unit tests (#6212 and #6218)**: Complementary failures. #6212 has `replace.empty-match-fails` failing (Saxon 12 permits empty matches; gating is in #6218). #6218 has `replace.empty-match-allowed` failing (requires Saxon 12 from #6212). Both pass when merged together. + +--- + +## XQTS Compliance Scores + +*Updated 2026-04-26 after post-90 cherry-picks.* + +| Suite | Score | Notes | +|-------|-------|-------| +| **QT4** (XQuery 4.0, RD parser) | 35,460/41,859 (84.7%) | XQuery 4.0 + XQUF (Apr 22 run) | +| **XQ 3.1** (ANTLR parser) | 24,422/26,274 (93.0%) | Up from 89.7% baseline (Apr 24 run) | +| **FTTS** (Full Text) | 659/667 (98.8%) | 8 remaining are spec edge cases | +| **XQUF** (Update) | 684/684 non-schema (100%) | Schema revalidation out of scope | + +--- + +## Application Suite + +The Docker image ships with a complete redesigned application suite. All apps share a common architecture: + +- **Shared navbar** via exist-site-shell (sitewide navigation, search, login/logout) +- **Shared CSS** for app-tabs, breadcrumbs, login pages, and search forms +- **Consistent login/logout** across all apps (centered card design, fetch-based JSON auth, persistent cookie) +- **Jinks template engine** with profile-based page shell (base-page.html → page-content.tpl → content templates) +- **App tabs + breadcrumbs** for in-app navigation + +### exist-site-shell ([joewiz/exist-site-shell](https://github.com/joewiz/exist-site-shell)) + +The shared chrome layer: navbar, footer, CSS, sitewide Lucene search with app facets, cross-reference registry, and Jinks profile for page shells. + +### eXist-db Dashboard ([joewiz/dashboard-next](https://github.com/joewiz/dashboard-next)) + +Complete rewrite replacing both dashboard and monex. Tabs: Launcher / Collections / Packages / Users / Monitoring / Profiling / Console / Indexes / System. Collections manager includes tree navigation, drag-and-drop upload, context menu, permissions dialog. Architecture: Jinks templates, Roaster for login API, vanilla JS. + +### eXist-db Documentation ([joewiz/documentation-next](https://github.com/joewiz/documentation-next)) + +Unified app replacing doc and fundocs. Tabs: Articles / Functions / Search / Admin. XDITA articles via TEI Publisher ODD. XQDoc function reference with 1,112 try-it queries. Architecture: Jinks, controller → view.xq two-pass rendering. + +### eXist-db Blog ([joewiz/exist-blog](https://github.com/joewiz/exist-blog)) + +Replaces AtomicWiki. Tabs: Posts / Archive / Search / Admin. Markdown posts with executable XQuery cells. Architecture: Jinks, html-templating, controller → view.xq. + +### eXist-db Notebook ([joewiz/notebook](https://github.com/joewiz/notebook)) + +Replaces sandbox. Tabs: Home / Search / Admin. Interactive XQuery notebooks with cell chaining, multiple serializations, sharing. Multi-chapter books with sidebar navigation. Architecture: controller → view.xq → content.xqm (refactored from Roaster-only to match blog/docs pattern). + +### Other Bundled Apps + +In particular, eXide received a significant overhaul in [#778](https://github.com/eXist-db/eXide/pull/778): the editor was migrated from CodeMirror 5 to CodeMirror 6 with a modern extension architecture, the XQuery parser was replaced with a REx-generated parser for accurate syntax highlighting and error recovery, and Language Server Protocol (LSP) support was added for hover documentation and go-to-definition. The PR also modernizes eXide's login flow to use the persistent login module (fixing the `login:set-user()` duration parameter bug). + +Also bundled: exist-api (unified REST API using Roaster), EXPath File/Binary/HTTP Client/Crypto modules, FunctX, and Public Repo. + +--- + +## App Architecture Patterns + +All four main apps follow consistent patterns: + +### URL Routing + +``` +controller.xq → view.xq → content module → Jinks template +``` + +### Login/Logout + +- **GET /login**: Renders `login.tpl` through view pipeline (gets navbar) +- **POST /login**: Returns JSON `{"user": "admin", "isAdmin": true}` or 401 +- **GET /logout**: Clears persistent cookie, invalidates session, redirects + +### Shared CSS Classes (from site.css) + +`.app-tabs` / `.breadcrumb` / `.login-card` / `.app-search` + +--- + +## App Test Results + +Tests run on a fresh `joewiz/existdb:next-v3` container (2026-04-26): + +| App | Test Type | Pass/Total | Notes | +|-----|-----------|------------|-------| +| exist-site-shell | XQuery (xst) | **17/17** | Site-config, nav, search | +| Notebook | Node.js | **15/15** | Context chain, markdown parser | +| Notebook | Cypress E2E | **48/48** | Admin CRUD, eval, chaining, content, share | +| Blog | Cypress E2E | **27/27** | Pages, admin CRUD, login, migration | +| Dashboard | Cypress E2E | **88/95** | 7 pre-existing (session timing, element timeouts) | +| Documentation | Cypress E2E | **42/59** | Pre-existing (editor component timing) | + +--- + +## Differences from next-v2 + +1. **Parser foundation**: next-v2 used `feature/new-parser` (shaky merge base). next-v3 uses `v2/new-parser` which merges cleanly. +2. **QT4 regressions fixed**: 619 regressions traced to missing files during branch reconstruction, fixed. +3. **Notebook architecture**: Refactored from Roaster-only to controller → view.xq → content.xqm (matches blog/docs). +4. **App consistency**: Shared login pages, app-tabs, breadcrumbs, search styling. +5. **exist-services module**: Extracted from exist-core per reviewer feedback. + +--- + +## Repos to Transfer to eXist-db Org + +| Repo | Description | +|------|-------------| +| [joewiz/exist-site-shell](https://github.com/joewiz/exist-site-shell) | Shared navbar, search, CSS, Jinks profile | +| [joewiz/dashboard-next](https://github.com/joewiz/dashboard-next) | Dashboard (replaces dashboard + monex) | +| [joewiz/documentation-next](https://github.com/joewiz/documentation-next) | Documentation (replaces doc + fundocs) | +| [joewiz/exist-blog](https://github.com/joewiz/exist-blog) | Blog (replaces AtomicWiki) | +| [joewiz/notebook](https://github.com/joewiz/notebook) | Notebook (replaces sandbox) | +| [joewiz/exist-api](https://github.com/joewiz/exist-api) | Unified REST API | +| [joewiz/exist-file](https://github.com/joewiz/exist-file) | EXPath File module XAR | +| [joewiz/exist-binary](https://github.com/joewiz/exist-binary) | EXPath Binary module XAR | +| [joewiz/exist-crypto](https://github.com/joewiz/exist-crypto) | EXPath Crypto module XAR | +| [joewiz/exist-http-client](https://github.com/joewiz/exist-http-client) | EXPath HTTP Client module XAR | + +## Cross-Repo PRs + +| Repo | PR | Title | Status | +|------|----|-------|--------| +| exist-xqts-runner | [#49](https://github.com/eXist-db/exist-xqts-runner/pull/49) | QT4/FTTS/XQUF suites + Saxon 12 | Needs review | +| eXide | [#778](https://github.com/eXist-db/eXide/pull/778) | Modernize: CM6 editor, REx parser, LSP | Needs review | +| exist-markdown | [#69](https://github.com/eXist-db/exist-markdown/pull/69) | CommonMark/GFM (flexmark-java) | Approved |