From 6df97cf0a0e687a31f03399cd906081e6ca9e442 Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:20:49 -0700 Subject: [PATCH] fix(triplet_store): stop CONSTRUCT detection matching inside a leading comment (#951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONSTRUCT_QUERY_RE skipped comments with a bare \#[^\n]*, whose trailing * backtracks. For '# CONSTRUCT ...\nSELECT ...' the engine gave back everything after the '#', so the CONSTRUCT inside the comment satisfied the query-form keyword and a SELECT/ASK was reported as a CONSTRUCT. All four SPARQL backends delegate to this regex, so such a query took the CONSTRUCT branch of execute_sparql, which sends Accept: text/turtle and parses the body as Turtle — failing with a misleading 'Failed to parse CONSTRUCT response as Turtle'. Require a comment to reach a line terminator. Both LF and CR are accepted because the SPARQL grammar ends a comment at either; matching only LF would regress CR-terminated comments into false negatives. Add regression tests covering both directions across all four backends.> --- semantica/triplet_store/sparql_escaping.py | 13 +- .../test_construct_query_detection.py | 210 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 tests/triplet_store/test_construct_query_detection.py diff --git a/semantica/triplet_store/sparql_escaping.py b/semantica/triplet_store/sparql_escaping.py index 311fdb41..ea56fd41 100644 --- a/semantica/triplet_store/sparql_escaping.py +++ b/semantica/triplet_store/sparql_escaping.py @@ -50,12 +50,23 @@ _DISALLOWED_URI_CHARS_RE = re.compile(r"[\s<>\"{}|\\^`]") # # Shared by BlazegraphStore and RDF4JStore so the detection logic has one # canonical implementation rather than being duplicated per-backend. +# +# The comment alternative must consume the whole comment up to a line +# terminator. Written as a bare `\#[^\n]*`, the trailing `*` backtracks: for +# "# CONSTRUCT ...\nSELECT ...", the engine gives back everything after the +# '#', letting the CONSTRUCT *inside the comment* satisfy the query-form +# keyword and misreporting a SELECT as a CONSTRUCT. Requiring a terminator +# ([\n\r], or end of input for a trailing comment) makes that backtracking +# impossible: if the character class gives a character back, the next +# character is by definition not a terminator, so the group cannot match. +# Both LF and CR are treated as terminators because the SPARQL grammar ends +# a comment at either. CONSTRUCT_QUERY_RE = re.compile( r""" \A # anchor to start of string (?: # skip zero or more of: \s+ # whitespace - | \#[^\n]* # comments (until newline) + | \#[^\n\r]*(?:[\n\r]|\Z) # comment, to end of line or end of input | PREFIX\s+[\w\-]*:\s*<[^>]*> # PREFIX declaration | BASE\s+<[^>]*> # BASE declaration )* diff --git a/tests/triplet_store/test_construct_query_detection.py b/tests/triplet_store/test_construct_query_detection.py new file mode 100644 index 00000000..58bbe8db --- /dev/null +++ b/tests/triplet_store/test_construct_query_detection.py @@ -0,0 +1,210 @@ +"""Regression tests for CONSTRUCT query-form detection (issue #931). + +``CONSTRUCT_QUERY_RE``'s comment alternative used to be written ``\\#[^\\n]*``. +The trailing ``*`` backtracks, so for a query like:: + + # CONSTRUCT in a comment + SELECT * WHERE { } + +the engine consumed the ``#``, gave back everything after it, and let the +CONSTRUCT *inside the comment* satisfy the query-form keyword. Every SPARQL +backend delegates to this one regex, so a SELECT/ASK carrying such a leading +comment was routed down the CONSTRUCT path of ``execute_sparql`` — which sends +``Accept: text/turtle`` and parses the body as Turtle, failing with a +misleading "Failed to parse CONSTRUCT response as Turtle". + +Both directions are pinned here: the false positives that motivated the fix, +and the queries that were already detected correctly, so a future tightening +cannot silently start dropping real CONSTRUCT queries instead. +""" + +import unittest +from unittest.mock import patch + +from semantica.triplet_store import sparql_escaping +from semantica.triplet_store.anzo_store import AnzoStore +from semantica.triplet_store.blazegraph_store import BlazegraphStore +from semantica.triplet_store.jena_store import JenaStore +from semantica.triplet_store.rdf4j_store import RDF4JStore + +# Queries whose *form* is CONSTRUCT. Each must be detected. +CONSTRUCT_CASES = { + "bare": "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "lowercase": "construct { ?s ?p ?o } where { ?s ?p ?o }", + "mixed_case": "Construct { ?s ?p ?o } Where { ?s ?p ?o }", + "leading_whitespace": " \n\t CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "prefix_preamble": ( + "PREFIX e: \nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + ), + "base_preamble": "BASE CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "comment_then_construct": "# a comment\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "two_comments_then_construct": "#\n#\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "comment_crlf": "# a comment\r\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "comment_cr_only": "# a comment\rCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "empty_comment": "#\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "mixed_preamble": ( + " \n # header \n PREFIX e: \n # note \n " + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + ), +} + +# Queries whose form is NOT CONSTRUCT. None may be detected. +NON_CONSTRUCT_CASES = { + "plain_select": "SELECT ?s WHERE { ?s ?p ?o }", + "plain_ask": "ASK { ?s ?p ?o }", + "describe": "DESCRIBE ", + "keyword_in_literal": 'SELECT * WHERE { ?s ?p "please CONSTRUCT this" }', + "keyword_in_trailing_comment": "SELECT * WHERE { } # CONSTRUCT", + "keyword_as_substring": 'SELECT ?s WHERE { ?s "CONSTRUCTOR" }', + # The issue #931 payloads: CONSTRUCT inside a *leading* comment. + "leading_comment_lf": "# CONSTRUCT in a comment\nSELECT * WHERE { }", + "leading_comment_crlf": "# CONSTRUCT in a comment\r\nSELECT * WHERE { }", + "leading_comment_cr_only": "# CONSTRUCT in a comment\rSELECT * WHERE { }", + "leading_comment_no_space": "#CONSTRUCT\nSELECT * WHERE { }", + "leading_comment_mid_sentence": "# we will CONSTRUCT later\nSELECT * WHERE { }", + "leading_comment_second_line": "#\n# CONSTRUCT\nSELECT * WHERE { }", + "leading_comment_before_ask": "# TODO: CONSTRUCT\nASK { ?s ?p ?o }", + "leading_comment_word_construction": "# CONSTRUCTION notes\nSELECT * WHERE { }", + "comment_only_no_newline": "# CONSTRUCT", +} + + +def _blazegraph_store() -> BlazegraphStore: + with patch.object(BlazegraphStore, "_connect", autospec=True): + store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph") + store.connected = True + return store + + +def _rdf4j_store() -> RDF4JStore: + with patch.object(RDF4JStore, "_connect", autospec=True): + store = RDF4JStore( + endpoint="http://localhost:8080/rdf4j-server", repository_id="repo1" + ) + store.connected = True + return store + + +def _anzo_store() -> AnzoStore: + with patch.object(AnzoStore, "_connect", autospec=True): + store = AnzoStore( + endpoint="http://localhost:8080", + dataset_uri="http://cambridgesemantics.com/Graphmart/abc123", + ) + store.connected = True + return store + + +def _jena_store() -> JenaStore: + return JenaStore() + + +# Every backend that delegates to CONSTRUCT_QUERY_RE. Detection is shared, so +# a per-backend regression would otherwise only surface in whichever backend +# happened to be covered. +BACKENDS = { + "blazegraph": _blazegraph_store, + "rdf4j": _rdf4j_store, + "anzo": _anzo_store, + "jena": _jena_store, +} + + +class TestConstructQueryRegex(unittest.TestCase): + """Direct tests of the shared regex.""" + + def test_case_tables_are_populated(self): + """Guard against a vacuous suite. + + Every test below iterates a table; if a table were emptied or renamed + away, those loops would pass without asserting anything. + """ + self.assertGreaterEqual(len(CONSTRUCT_CASES), 12) + self.assertGreaterEqual(len(NON_CONSTRUCT_CASES), 15) + self.assertEqual(len(BACKENDS), 4) + + def test_detects_construct_query_forms(self): + for label, query in CONSTRUCT_CASES.items(): + with self.subTest(case=label): + self.assertIsNotNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search(query), + f"{label}: real CONSTRUCT query was not detected", + ) + + def test_rejects_non_construct_query_forms(self): + for label, query in NON_CONSTRUCT_CASES.items(): + with self.subTest(case=label): + self.assertIsNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search(query), + f"{label}: non-CONSTRUCT query was misdetected as CONSTRUCT", + ) + + def test_comment_alternative_does_not_backtrack(self): + """The specific mechanism behind #931. + + A comment must be consumed up to its terminator. If the character + class backtracks, the match ends *inside* the comment instead of + failing, which is what let CONSTRUCT-in-a-comment win. + """ + query = "# CONSTRUCT in a comment\nSELECT * WHERE { }" + self.assertIsNone(sparql_escaping.CONSTRUCT_QUERY_RE.search(query)) + + def test_carriage_return_terminates_a_comment(self): + """CR alone ends a comment, so CONSTRUCT after it is a real CONSTRUCT. + + Pins the difference between `[^\\n]*(?:\\n|\\Z)` and the shipped + `[^\\n\\r]*(?:[\\n\\r]|\\Z)`: the former treats a CR-terminated comment + as running to end of input, swallowing the query form after it. + """ + self.assertIsNotNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search( + "# a comment\rCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + ) + ) + self.assertIsNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search( + "# CONSTRUCT in a comment\rSELECT * WHERE { }" + ) + ) + + +class TestConstructDetectionAcrossBackends(unittest.TestCase): + """The regex is shared, so assert every backend's public detector agrees.""" + + def test_all_backends_detect_construct_query_forms(self): + for backend, factory in BACKENDS.items(): + store = factory() + for label, query in CONSTRUCT_CASES.items(): + with self.subTest(backend=backend, case=label): + self.assertTrue( + store._is_construct_query(query), + f"{backend}/{label}: real CONSTRUCT query was not detected", + ) + + def test_all_backends_reject_non_construct_query_forms(self): + for backend, factory in BACKENDS.items(): + store = factory() + for label, query in NON_CONSTRUCT_CASES.items(): + with self.subTest(backend=backend, case=label): + self.assertFalse( + store._is_construct_query(query), + f"{backend}/{label}: non-CONSTRUCT query was misdetected", + ) + + def test_every_backend_exposes_the_detector(self): + """Fail loudly if a backend stops delegating to the shared regex. + + Without this, a backend that dropped `_is_construct_query` would make + the loops above error rather than report a meaningful failure. + """ + for backend, factory in BACKENDS.items(): + with self.subTest(backend=backend): + store = factory() + self.assertTrue( + callable(getattr(store, "_is_construct_query", None)), + f"{backend}: no callable _is_construct_query", + ) + + +if __name__ == "__main__": + unittest.main()