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..77e1f556d5a 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 @@ -192,6 +192,8 @@ imaginaryTokenDefinitions PREVIOUS_ITEM NEXT_ITEM WINDOW_VARS + DECIMAL_FORMAT_DECL + DEF_DECIMAL_FORMAT_DECL ; // === XPointer === @@ -256,7 +258,7 @@ 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" ) ) => s:setter { if(!inSetters) @@ -311,6 +313,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" ) => @@ -330,9 +335,30 @@ setter | ( "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" ) @@ -2304,6 +2330,30 @@ reservedKeywords returns [String name] "next" { name = "next"; } | "when" { name = "when"; } + | + "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"; } ; 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 20308296806..9781c206c15 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 @@ -210,6 +210,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; + int 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": + dfRequireSingleChar(child, propName, value); + minusSign = value.codePointAt(0); + 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] @@ -337,6 +453,8 @@ throws PermissionDeniedException, EXistException, XPathException boolean baseuri = false; boolean ordering = false; boolean construction = false; + Set declaredDecimalFormats = new HashSet(); + boolean defaultDecimalFormatDeclared = false; }: ( @@ -632,6 +750,35 @@ throws PermissionDeniedException, EXistException, XPathException ) ) | + #( + 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] diff --git a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java index 46a54962ad5..bc4a7b4add3 100644 --- a/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java +++ b/exist-core/src/main/java/org/exist/xquery/DecimalFormat.java @@ -24,7 +24,7 @@ /** * Data class for a Decimal Format. * - * See https://www.w3.org/TR/xpath-31/#dt-static-decimal-formats + * See https://www.w3.org/TR/xquery-31/#id-decimal-format-decl * * NOTE: UTF-16 characters are stored as code-points! * @@ -32,6 +32,11 @@ */ public class DecimalFormat { + /** + * The default (unnamed) decimal format as defined by the XQuery 3.1 specification. + * + * @see XQuery 3.1 §4.10: Decimal Format Declaration + */ public static final DecimalFormat UNNAMED = new DecimalFormat( '.', 'e', diff --git a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java index 23226a155f2..a50e4794086 100644 --- a/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java +++ b/exist-core/src/main/java/org/exist/xquery/ErrorCodes.java @@ -127,6 +127,13 @@ public class ErrorCodes { public static final ErrorCode XQST0094 = new W3CErrorCode("XQST0094", "The name of each grouping variable must be equal (by the eq operator on expanded QNames) to the name of a variable in the input tuple stream."); + public static final ErrorCode XQST0097 = new W3CErrorCode("XQST0097", + "It is a static error to have more than one decimal-format declaration with the same name, " + + "or more than one default decimal-format declaration, in the same module."); + public static final ErrorCode XQST0098 = new W3CErrorCode("XQST0098", + "It is a static error if the properties representing characters used in a picture string " + + "do not each have distinct values, or if a property value is not valid for its property."); + public static final ErrorCode XQDY0101 = new W3CErrorCode("XQDY0101", "An error is raised if a computed namespace constructor attempts to do any of the following:\n" + "Bind the prefix xml to some namespace URI other than http://www.w3.org/XML/1998/namespace.\n" + "Bind a prefix other than xml to the namespace URI http://www.w3.org/XML/1998/namespace.\n" + diff --git a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java index b3721c34179..7c3b84a2ce3 100644 --- a/exist-core/src/main/java/org/exist/xquery/XQueryContext.java +++ b/exist-core/src/main/java/org/exist/xquery/XQueryContext.java @@ -420,6 +420,9 @@ public class XQueryContext implements BinaryValueManager, Context { * HTTP context. */ private @Nullable HttpContext httpContext = null; + /** + * Sentinel QName for the default (unnamed) decimal format per XQuery 3.1 §4.10. + */ private static final QName UNNAMED_DECIMAL_FORMAT = new QName("__UNNAMED__", Function.BUILTIN_FUNCTION_NS); private final Map staticDecimalFormats = hashMap(Tuple(UNNAMED_DECIMAL_FORMAT, DecimalFormat.UNNAMED)); @@ -2902,6 +2905,10 @@ public void setStaticDecimalFormat(final QName qnDecimalFormat, final DecimalFor staticDecimalFormats.put(qnDecimalFormat, decimalFormat); } + public void setDefaultStaticDecimalFormat(final DecimalFormat decimalFormat) { + staticDecimalFormats.put(UNNAMED_DECIMAL_FORMAT, decimalFormat); + } + public Map getCachedUriCollectionResults() { return cachedUriCollectionResults; } 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