From 49707867e387a601e282e2591c254b0f68855201 Mon Sep 17 00:00:00 2001 From: "AD\\aabdoun" Date: Mon, 21 Sep 2026 14:07:41 +0200 Subject: [PATCH 1/2] [Query] Support SPARQL XPath REGEX flags (x, q, s, m, i) and pattern matching --- .../NativeStringExpressionEvaluator.java | 27 ++--- .../sparql/bridge/SparqlTermResolver.java | 30 ++++- .../bridge/NativeExpressionEvaluatorTest.java | 104 ++++++++++++++++++ 3 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java index d0156bbdd..faf1cc3e5 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java @@ -260,30 +260,16 @@ private static DatatypeValue digest( } } + @SuppressWarnings("MagicConstant") private static Pattern compilePattern(String expression, String flags) { int options = regexOptions(flags); - return Pattern.compile(flags.contains("x") ? removePatternWhitespace(expression) : expression, options); - } - - private static String removePatternWhitespace(String expression) { - StringBuilder result = new StringBuilder(); - boolean escaped = false; - int brackets = 0; - for (int index = 0; index < expression.length(); index++) { - char character = expression.charAt(index); - if (!escaped) { - if (character == '[') brackets++; - if (character == ']') brackets--; - if (brackets == 0 && " \t\r\n".indexOf(character) >= 0) continue; - } - result.append(character); - escaped = !escaped && character == '\\'; - } - return result.toString(); + String processed = flags.contains("q") ? Pattern.quote(expression) : expression; + return Pattern.compile(processed, options); } + /** @return a combination of {@link Pattern} flag constants */ private static int regexOptions(String flags) { - if (!flags.chars().allMatch(flag -> "imsx".indexOf(flag) >= 0)) { + if (!flags.chars().allMatch(flag -> "imsxq".indexOf(flag) >= 0)) { throw new QueryTypeErrorException("Invalid regular expression flags"); } int options = 0; @@ -296,6 +282,9 @@ private static int regexOptions(String flags) { if (flags.contains("s")) { options |= Pattern.DOTALL; } + if (flags.contains("x")) { + options |= Pattern.COMMENTS; + } return options; } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java index 179ee5292..6de9a4305 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java @@ -16,6 +16,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; /** Resolves SPARQL terms against one immutable query-prologue snapshot. */ public final class SparqlTermResolver { @@ -98,10 +99,33 @@ public String unquoteLexical(String lexical) { return lexical; } String unquotedTriple = stripTripleQuotes(lexical); - if (unquotedTriple != null) { - return unquotedTriple; + return processSparqlEscapes(Objects.requireNonNullElseGet(unquotedTriple, () -> stripSingleQuotes(lexical))); + } + + static String processSparqlEscapes(String s) { + if (s.indexOf('\\') < 0) { + return s; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '\\' && i + 1 < s.length()) { + i++; + switch (s.charAt(i)) { + case 't' -> sb.append('\t'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case '"' -> sb.append('"'); + case '\'' -> sb.append('\''); + case '\\' -> sb.append('\\'); + default -> { sb.append('\\'); sb.append(s.charAt(i)); } + } + } else { + sb.append(s.charAt(i)); + } } - return stripSingleQuotes(lexical); + return sb.toString(); } private static String stripTripleQuotes(String lexical) { diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java new file mode 100644 index 000000000..4652befa2 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java @@ -0,0 +1,104 @@ +package fr.inria.corese.core.next.query.impl.sparql.bridge; + +import fr.inria.corese.core.next.query.Repositories; +import fr.inria.corese.core.next.storage.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for SPARQL REGEX built-in with XPath flag support. + * + */ +@DisplayName("NativeExpressionEvaluator - XPath REGEX flag support") +class NativeExpressionEvaluatorTest { + + private boolean ask(String filter) { + try (var repo = Repositories.create(MemoryStorageManager.builder().build()); + var conn = repo.getConnection()) { + return conn.prepareBooleanQuery( + "PREFIX ex: ASK { FILTER(" + filter + ") }").evaluate(); + } + } + + + @Test + @DisplayName("REGEX with i flag matches case-insensitively") + void iFlagMatchesCaseInsensitively() { + assertTrue(ask("REGEX(\"ABCdefGHIjkl\", \"abc\", \"i\")")); + assertTrue(ask("REGEX(\"abcDEFghiJKL\", \"ABC\", \"i\")")); + } + + @Test + @DisplayName("REGEX with s flag makes dot match newlines") + void sFlagDotMatchesNewline() { + assertTrue(ask("REGEX(\"a\\nb\", \"a.b\", \"s\")")); + assertFalse(ask("REGEX(\"a\\nb\", \"a.b\")")); + } + + + @Test + @DisplayName("REGEX with m flag makes ^ and $ match line boundaries") + void mFlagAnchorMatchesLineBoundary() { + assertTrue(ask("REGEX(\"foo\\nbar\", \"^bar$\", \"m\")")); + assertFalse(ask("REGEX(\"foo\\nbar\", \"^bar$\")")); + } + + + @Test + @DisplayName("REGEX with x flag ignores whitespace in pattern") + void xFlagIgnoresWhitespace() { + assertTrue(ask("REGEX(\"ac\", \" a c \", \"x\")")); + } + + @Test + @DisplayName("REGEX with x flag ignores newline and tab in pattern") + void xFlagIgnoresNewlineAndTab() { + assertTrue(ask("REGEX(\"ac\", \" a\\n\\tc \", \"x\")")); + } + + @Test + @DisplayName("REGEX with x flag preserves whitespace inside bracket expression") + void xFlagPreservesWhitespaceInsideBrackets() { + assertTrue(ask("REGEX(\"a\\nc\", \" a[\\\\n]c \", \"x\")")); + } + + @Test + @DisplayName("REGEX with x flag ignores # comments until end of line") + void xFlagIgnoresHashComments() { + assertTrue(ask("REGEX(\"ac\", \"a # match a\\nc\", \"x\")")); + } + + @Test + @DisplayName("REGEX with q flag treats pattern as literal (no metacharacters)") + void qFlagTreatsPatternAsLiteral() { + assertTrue(ask("REGEX(\"a?+*.{}()[]c\", \"a?+*.{}()[]c\", \"q\")")); + assertFalse(ask("REGEX(\"abc\", \"a?+*.{}()[]c\", \"q\")")); + } + + @Test + @DisplayName("REGEX with iq flags matches literal pattern case-insensitively") + void iqFlagsMatchLiteralCaseInsensitively() { + assertTrue(ask("REGEX(\"a?+*.{}()[]c\", \"a?+*.{}()[]C\", \"iq\")")); + assertFalse(ask("REGEX(\"abc\", \"a?+*.{}()[]C\", \"iq\")")); + } + + @Test + @DisplayName("REGEX with [] expression matches 'b' in character class") + void bracketExpressionMatchesB() { + assertTrue(ask("REGEX(\"abc\", \"a[b\\\\n]c\")")); + } + + @Test + @DisplayName("REGEX with [] expression matches newline in character class") + void bracketExpressionMatchesNewline() { + assertTrue(ask("REGEX(\"a\\nc\", \"a[b\\\\n]c\")")); + } + + @Test + @DisplayName("REGEX with [] expression does not match unrelated characters") + void bracketExpressionDoesNotMatchOther() { + assertFalse(ask("REGEX(\"adc\", \"a[b\\\\n]c\")")); + } +} From 58473305449829161d37cd736af5257e4d90fb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?= Date: Tue, 22 Sep 2026 11:20:04 +0200 Subject: [PATCH 2/2] fix(regex): align XPath x flag whitespace semantics and quote REPLACE string with q flag --- .../NativeStringExpressionEvaluator.java | 37 +++++++++++++++--- .../sparql/bridge/SparqlTermResolver.java | 4 +- .../bridge/NativeExpressionEvaluatorTest.java | 39 +++++++++++++++++-- .../sparql/bridge/SparqlTermResolverTest.java | 17 ++++++++ 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java index faf1cc3e5..9bc068a0e 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeStringExpressionEvaluator.java @@ -38,6 +38,7 @@ import java.util.List; import java.util.Locale; import java.util.UUID; +import java.util.regex.Matcher; import java.util.regex.Pattern; /** Evaluates SPARQL expressions that produce string literals. */ @@ -209,7 +210,8 @@ private static DatatypeValue replace(ReplaceAst expression, NativeEvaluationCont throw new QueryTypeErrorException("REPLACE pattern must not match an empty string"); } try { - String result = compiled.matcher(source.getLabel()).replaceAll(replacement); + String result = compiled.matcher(source.getLabel()).replaceAll( + flags.contains("q") ? Matcher.quoteReplacement(replacement) : replacement); return stringLike(source, result, context); } catch (IndexOutOfBoundsException invalidGroup) { throw new QueryTypeErrorException("Invalid replacement group", invalidGroup); @@ -263,10 +265,38 @@ private static DatatypeValue digest( @SuppressWarnings("MagicConstant") private static Pattern compilePattern(String expression, String flags) { int options = regexOptions(flags); - String processed = flags.contains("q") ? Pattern.quote(expression) : expression; + String processed = expression; + if (flags.contains("q")) { + processed = Pattern.quote(expression); + } else if (flags.contains("x")) { + processed = removePatternWhitespace(expression); + } return Pattern.compile(processed, options); } + /** XPath x ignores XML whitespace outside character classes, but does not introduce comments. */ + private static String removePatternWhitespace(String expression) { + StringBuilder result = new StringBuilder(expression.length()); + boolean escaped = false; + int brackets = 0; + for (int index = 0; index < expression.length(); index++) { + char character = expression.charAt(index); + if (brackets == 0 && " \t\r\n".indexOf(character) >= 0) { + continue; + } + if (!escaped) { + if (character == '[') { + brackets++; + } else if (character == ']' && brackets > 0) { + brackets--; + } + } + result.append(character); + escaped = !escaped && character == '\\'; + } + return result.toString(); + } + /** @return a combination of {@link Pattern} flag constants */ private static int regexOptions(String flags) { if (!flags.chars().allMatch(flag -> "imsxq".indexOf(flag) >= 0)) { @@ -282,9 +312,6 @@ private static int regexOptions(String flags) { if (flags.contains("s")) { options |= Pattern.DOTALL; } - if (flags.contains("x")) { - options |= Pattern.COMMENTS; - } return options; } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java index 6de9a4305..60155b40e 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolver.java @@ -107,7 +107,8 @@ static String processSparqlEscapes(String s) { return s; } StringBuilder sb = new StringBuilder(s.length()); - for (int i = 0; i < s.length(); i++) { + int i = 0; + while (i < s.length()) { if (s.charAt(i) == '\\' && i + 1 < s.length()) { i++; switch (s.charAt(i)) { @@ -124,6 +125,7 @@ static String processSparqlEscapes(String s) { } else { sb.append(s.charAt(i)); } + i++; } return sb.toString(); } diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java index 4652befa2..52dc83c06 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java @@ -65,9 +65,42 @@ void xFlagPreservesWhitespaceInsideBrackets() { } @Test - @DisplayName("REGEX with x flag ignores # comments until end of line") - void xFlagIgnoresHashComments() { - assertTrue(ask("REGEX(\"ac\", \"a # match a\\nc\", \"x\")")); + @DisplayName("REGEX with x flag preserves hashes and whitespace in classes") + void xFlagPreservesHashesAndClassWhitespace() { + assertFalse(ask("REGEX(\"a\", \"a#b\", \"x\")")); + assertTrue(ask("REGEX(\"a#b\", \" a # b \", \"x\")")); + assertTrue(ask("REGEX(\"#\", \"[#]\", \"x\")")); + assertTrue(ask("REGEX(\" \", \"[ a]\", \"x\")")); + assertTrue(ask("REGEX(\"\\n\\t\\r\", \"^[ \\n\\t\\r]+$\", \"x\")")); + assertFalse(ask("REGEX(\"ac\", \"a[ ]c\", \"x\")")); + } + + @Test + void xFlagRespectsEscapedBrackets() { + assertTrue(ask("REGEX(\"[a]\", \"\\\\[ a \\\\]\", \"x\")")); + assertTrue(ask("REGEX(\"] \", \"^[\\\\] ]+$\", \"x\")")); + } + + @Test + void qFlagOverridesOtherPatternFlags() { + assertTrue(ask("REGEX(\" A#b \", \" a#b \", \"qxims\")")); + assertFalse(ask("REGEX(\"ab\", \" a b \", \"qx\")")); + assertFalse(ask("REGEX(\"a\\nb\", \"a.b\", \"qs\")")); + assertFalse(ask("REGEX(\"a\\nb\", \"^b$\", \"qm\")")); + } + + @Test + void qFlagQuotesReplacement() { + assertTrue(ask("REPLACE(\"a/b\", \"/\", \"$0\", \"q\") = \"a$0b\"")); + assertTrue(ask("REPLACE(\"a/b\", \"/\", \"$\", \"q\") = \"a$b\"")); + assertTrue(ask("REPLACE(\"a/b\", \"/\", \"\\\\\", \"q\") = \"a\\\\b\"")); + assertTrue(ask("REPLACE(\"a/b\", \"/\", \"$0\") = \"a/b\"")); + } + + @Test + void xFlagAlsoAppliesToReplace() { + assertTrue(ask("REPLACE(\"a b\", \"[ ]\", \"-\", \"x\") = \"a-b\"")); + assertTrue(ask("REPLACE(\"a#b\", \"#\", \"-\", \"x\") = \"a-b\"")); } @Test diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java index d9b4c2f90..c91c2d5af 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/SparqlTermResolverTest.java @@ -13,6 +13,23 @@ class SparqlTermResolverTest { + @Test + void decodesSparqlEscapesExactlyOnce() { + SparqlTermResolver resolver = new SparqlTermResolver(null); + + assertEquals("\t\n\r\b\f\"'\\", resolver.unquoteLexical("\"\\t\\n\\r\\b\\f\\\"\\'\\\\\"")); + assertEquals("\\n", resolver.unquoteLexical("\"\\\\n\"")); + assertEquals("a\nb", resolver.unquoteLexical("\"\"\"a\\nb\"\"\"")); + assertEquals("a\tb", resolver.unquoteLexical("'''a\\tb'''")); + } + + @Test + void preservesUnknownEscapesAndTrailingBackslash() { + assertEquals("\\d", SparqlTermResolver.processSparqlEscapes("\\d")); + assertEquals("end\\", SparqlTermResolver.processSparqlEscapes("end\\")); + assertEquals("", SparqlTermResolver.processSparqlEscapes("")); + } + @Test void resolvesTermsFromItsOwnPrologue() { QueryPrologueAst prologue = new QueryPrologueAst(