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..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); @@ -260,21 +262,34 @@ 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); + 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(); + 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++; - if (character == ']') brackets--; - if (brackets == 0 && " \t\r\n".indexOf(character) >= 0) continue; + if (character == '[') { + brackets++; + } else if (character == ']' && brackets > 0) { + brackets--; + } } result.append(character); escaped = !escaped && character == '\\'; @@ -282,8 +297,9 @@ private static String removePatternWhitespace(String expression) { return result.toString(); } + /** @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; 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..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 @@ -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,35 @@ 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()); + int i = 0; + while (i < s.length()) { + 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)); + } + 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..52dc83c06 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeExpressionEvaluatorTest.java @@ -0,0 +1,137 @@ +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 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 + @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\")")); + } +} 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(