Repository navigation
[#161] Keep every match of negated, multi-valued and numeric XML search filters - #203
Conversation
… search filters not(a > v) became a < v and not(a < v) became a > v, so an entry equal to v was lost. not(a >= v) and not(a <= v) had the opposite swap and returned entries equal to v. Each negation now emits the exact complement: <=, <, >= and >.
…With filters fn:starts-with($x/ri:email, 'y') takes a single item, so one entry with two emails failed every search with that filter or its negation (XPTY0004). The filter is now a predicate on each value, $x/ri:email[fn:starts-with(., 'y')], negated with fn:not() around it.
…s numbers
A Number value of >, >=, < and <= was compared as a quoted string, so age > 9 missed age 10. An integer bound of magnitude below 2^53 is now compared directly, $x/ri:age[number(.) > 9]. Any other number gives a non-strict form that keeps every value the framework selects: $x/ri:n[. != '' and not(number(.) < xs:double('L'))] for > and >=, mirrored for < and <=. number() rather than a cast keeps the empty elements of unset attributes from failing the search.
…l that reads as it XPath reads a stored float as a double, and before JDK 19 Float.toString wrote some floats with more digits (3.3565272E7, now 3.356527E7). With the bound's own toString() as the literal, >= and <= lost an entry stored with the other text. The literal is now the lower or upper end of the decimals Java reads as the float. Also pins the non-strict <= branch.
…ger XML filter bound
The exact form of an integer bound, $x/ri:age[number(.) > 9], dropped stored text that Java reads and XPath reads as NaN: non-Latin digits in an int attribute, or Infinity in a double attribute. An integer bound of magnitude below 2^53 now uses the same negated form as any other bound and only keeps its operator strict: $x/ri:age[. != '' and not(number(.) <= xs:double('9'))]. Text that Java cannot read either, such as abc, is kept too, so reading it back fails the search, as it fails a search without a filter.
…Number equality in XML filter tests A Byte bound is compared exactly, like an Integer. roundingEdge is pinned at 0.125f, a power of two, where the gap to the float below is half the gap to the float above, so a symmetric interval or one out to the neighbouring floats fails. = with a Double still compares the quoted text.
vharseko
left a comment
There was a problem hiding this comment.
I checked the translator against the framework's own filters and against Saxon-HE 9.4.0.7, the version the bundle embeds. I found no regression:
- the four negations are exact complements;
- the per-value
starts-with/ends-withpredicate is correct; - the numeric bounds never exclude a value the framework selects. This includes the 2^53 cut-off with
Long.MIN_VALUEand-2^53, theFloatrounding edges,-0.0andINF.
CI is green.
The points below already exist on master; this PR does not introduce them. They belong to the same class as #161: a filter that fails or loses matches. The first two are small and sit on helpers this PR builds on, so they could be folded in here. The rest could go to a follow-up issue. Your call.
-
escape()does not escape&(L292). XQuery string literals decode entity references:startsWith(lastname, "AT&T"),lastname = "AT&T"andlastname > "AT&T"all produce the literal'AT&T'. Saxon rejects it withXPST0003 No closing ';' found for entity or character reference, and the whole search fails with aConnectorException.lastname = "AT&T"produces'AT&T'. That literal matches the entry whose text isAT&Tand loses the entry whose text isAT&T.
Fix:
"'" + value.replace("&", "&").replace("'", "''") + "'". The index path is not affected, becauseComparisonQuerykeeps the raw value separately. -
containsis not escaped at all (L143).ContainsQuerypastes the value between quotes as it is. Socontains(lastname, "O'B")produces[contains(.,'O'B')], which fails withXPST0003 Unmatched quote in expression. A value containing&fails as in point 1.createPredicateQuery(prefixedName, createFunctionCondition("contains", value), not)builds the same$x/name[...]/fn:not(...)shape with escaping, andContainsQuerycan then be deleted. -
A single-value
containsAllValuesbecomes a substringcontains(L98). This causes two problems:not(containsAllValues(email, ["v"]))producesfn:not($x/ri:email[contains(.,'v')]), which loses an entry withemail = ["vx"]. The framework selects that entry, because["vx"].containsAll(["v"])is false.- A non-
Stringvalue, such ascontainsAllValues(age, [5]), throwsIllegalArgumentException("Value must be a string!"), becauseContainsFilteris aStringFilter.
Building an
EqualsFilterfromtoString(), as the multi-value branch already does, fixes both. -
equalson a multi-valued filter attribute throws (L152).AttributeUtil.getSingleValuerejectsequals(email, ["x2", "y2"])withIllegalArgumentException, so the search fails. Returningnullin that case would keep the match: the framework then filters every entry.
Nit: createFunctionCondition (L283) builds a FunctionQuery only to format fn:name(., literal). After this PR, the not branch of FunctionQuery has no production caller; only testFunctionQueryExpressionWhereNotIsTrue uses it. It is not worth a change on its own.
An XQuery string literal decodes entity references, and escape() doubled only the quote. A filter value with an & then failed the whole search: equalTo, startsWith or greaterThan on AT&T was rejected with "No closing ';' found for entity or character reference". AT&T was read as AT&T, so equalTo matched the entry stored as AT&T instead of the one stored as AT&T, and its negation lost the AT&T entry. The literal now writes & as &. The __UID__ and __NAME__ index lookups read the raw value and are unchanged.
ContainsQuery wrote the value between quotes as it was, so contains(firstname, "O'B") failed the search with "Unmatched quote in expression", an & failed it as in the other filters, and & was read as &. contains now builds $x/ri:firstname[fn:contains(., 'v')] and its fn:not(...) through the same predicate and escaped literal as startsWith and endsWith, and ContainsQuery, which has no other caller, is removed.
…equality in XML search filters A containsAllValues filter with one value became a substring contains: not(containsAllValues(email, ["x"])) produced fn:not($x/ri:email[contains(., 'x')]) and lost the entries whose email is x1, which the framework selects, and a value that is not a String, such as containsAllValues(age, [9]), failed with "Value must be a string!". One value is now an equality, as each of several values already was: $x/ri:email = 'x', negated $x/ri:email != 'x'. A null value made the search fail with an IllegalArgumentException or a NullPointerException; it now leaves the filter to the framework, as no value already did.
… one value to the framework equalTo read its value with AttributeUtil.getSingleValue, so a filter with several values, such as equalTo(email, ["x2", "y2"]), failed the search with "The email attribute is not single value attribute.", and one with no value or a null value failed with a NullPointerException. The framework compares the whole list of values, which an XQuery comparison does not; such a filter now translates to null, so the connector returns every entry and the framework selects from them, as it does for an empty containsAllValues.
|
Review 5665044757: all four points are folded into this PR, one commit each (
Nit: left as it is. Tests:
The PR body is updated: new bullets in the filter list, Tests and the mutant list, and one Not covered line. |
vharseko
left a comment
There was a problem hiding this comment.
Re-checked ce81d9cb..fadb4a48 against the four points. All four hold as fixed, and I found no regression in the new code:
9e3664eb: every literal goes throughescape()(createComparisonQueryandcreateFunctionCondition), and thenumericConditionliterals are number text. The__UID__/__NAME__index lookup still readsComparisonQuery.getValue(), the raw value, not the literal.806a4a0e:containsnow builds the same$x/name[fn:contains(., literal)]/fn:not(...)shape asstartsWith. Nothing else referencedContainsQuery.79ed89c2: the negation$x/ri:email != 'x'is false for an entry with noemail, but the framework never selects such an entry either:FilteredResultsHandlerVisitorreturnsUNDEFINEDfor a missing attribute, andnotkeeps itUNDEFINED. Rows a4 and a5 pin this. The null check runs before anytoString(), so the several-value branch never getsnullfromcreateEqualsExpression.fadb4a48: anullquery falls through toentryIndex.all, and the framework selects from every entry.
The nit stays as it is, agreed.
Locally at fadb4a48 on JDK 21, all 321 unit tests pass, including the 49 XMLFilterSearchTests rows and the 26 XMLFilterTranslatorTests. CI was still queued when I checked.
Closes #161.
By default the framework applies the filter again to every object the XML connector returns. That drops wrong matches but cannot add the ones the generated XQuery missed, so every miss was lost silently. These filters missed matches or failed:
Negated ordering comparisons.
not(a > v)becamea < vandnot(a < v)becamea > v, so an entry equal tovwas lost.not(a >= v)andnot(a <= v)had the opposite swap and returned entries equal tov. Each negation now emits its exact complement:<=,<,>=and>.startsWithandendsWithon multi-valued attributes.fn:starts-with($x/ri:email, 'y')takes a single item, so one entry with two emails failed every search with that filter or its negation (XPTY0004). The filter is now a predicate on each value,$x/ri:email[fn:starts-with(., 'y')], negated asfn:not($x/ri:email[...]). The framework never matches a multi-valued attribute for these filters, so it drops the extra entries this returns.Numbers compared as strings. A
Numbervalue of>,>=,<and<=was quoted, soage > 9missedage10. Now the XQuery keeps every value it does not exclude:age > 9is$x/ri:age[. != '' and not(number(.) <= xs:double('9'))].>and>=(mirrored for<and<=)IntegerandByte;LongandBigIntegerof magnitude below 2^53<=and<Number:Double,Float,BigDecimal, larger integers<for both-0.0equals0.0.Infinityor non-Latin digits, reads as NaN, andnot(NaN <= 9)holds, so the entry is kept. Text that Java cannot read either, such asabc, is kept too, and reading it back fails the search, as it fails a search without a filter. Before,age < 10skipped such an entry.Lis the value's owntoString(), withInfinitywritten asINF.Float,Lis instead the lower (upper) end of the decimals that Java reads as that float. XPath reads a stored float as a double, and its text need not be the one the current JDK writes: before JDK 19,Float.toStringwrote some floats with more digits, such as3.3565272E7for what it now writes as3.356527E7.number()rather than a cast keeps the empty elements thatcreatewrites for unset attributes from failing the search.. != ''keeps them out.=and!=stay string comparisons.An
&in the value. An XQuery string literal decodes entity references, and only'was escaped.equalTo,startsWithor>onAT&Tfailed the search ("No closing ';' found for entity or character reference").AT&Twas read asAT&T, soequalToreturned the entry stored asAT&Tinstead of the one stored asAT&T, and its negation lost theAT&Tentry. Every literal now writes&as&. The__UID__and__NAME__index lookups read the raw value and are unchanged.containswas not escaped. The value went between quotes as it was, socontains(firstname, "O'B")failed with "Unmatched quote in expression", and an&failed as above.containsnow builds$x/ri:firstname[fn:contains(., 'v')]and itsfn:not(...)with the same escaped literal asstartsWith, andContainsQueryis removed.containsAllValueswith one value. It became a substringcontains, sonot(containsAllValues(email, ["x"]))lost an entry whose email isx1, and a value that is not aString, such as[9], failed with "Value must be a string!". One value is now an equality,$x/ri:email = 'x', as each of several values already was. A null value failed with anIllegalArgumentExceptionor aNullPointerException; it is now left to the framework, as an empty list already was.equalTowith other than one value.AttributeUtil.getSingleValuerejectedequalTo(email, ["x2", "y2"]), and no value or a null value failed with aNullPointerException. The framework compares the whole list of values, which an XQuery comparison does not; such a filter now translates tonull, so the connector returns every entry and the framework selects from them.Tests
XMLFilterSearchTests(new) searches a five-entry file through the facade withsetEnableFilteredResultsHandler(false), so every miss shows. For each of 49 filters it checks that the result contains every entry the framework's own filter (FilteredResultsHandlerVisitor) selects, and that it equals the expected list. The filters cover:notfor anint;>and<for anIntegerstored in Arabic-Indic digits, which XPath reads as NaN;LongandBigInteger, includingLong.MIN_VALUEand-2^53;Infinity,-0.0and<=for aDouble;>=and<=for aFloatstored as two decimals that are notFloat.toString's text but read as it;BigDecimalbound;AT&T,AT&TandO'B:&inequalToand its negation,startsWithand>, and'and&incontainsand its negation;containsAllValueswith oneStringand its negation, with oneInteger, and with a null value alone or beside another;equalToand its negation with two values, andequalTowith no value and with a null value.XMLFilterTranslatorTestspins the generated expressions, including aBytebound and=with aDouble, the ends of the rounding interval of0.125f(at a power of two the gap to the float below is half the gap to the float above),&in a comparison and in a function literal, andcontainswith a quote.Without the fixes, 45 of the 49 filters and five of the six new
XMLFilterTranslatorTestsmethods fail; the sixth callsroundingEdge, which does not exist there. The remaining four filters pin choices inside the new code. Each of these mutants fails at least one test:Math.absonLong.MIN_VALUE;bitLength()of-2^53;LongorBigIntegerbound never compared exactly;Bytebound not compared exactly;DoubleorBigDecimalbound compared exactly;number(.) > 9, which drops text XPath reads as NaN;>=and<=too;Floatbound used as it is, with its ends swapped, half an ulp away, or at the neighbouring floats;INF;. != '';<=branch alone;=with aNumbercompared as a number;&in comparison literals, in function literals, or in either;containswithout escaping;containsAllValuesvalue that ignoresnot;containsAllValues, or one on the first value only;equalTothat drops any of its checks: a null list, a size other than one, a null value.Not covered
FloatbeyondFloat.MAX_VALUE, such as3.5E38, which Java reads asInfinity:>= Float.POSITIVE_INFINITYloses it.=and!=on aNumbercompare text, so an entry whose text is not the value'stoString()is lost: non-Latin digits, or aFloatorDoublewritten by a JDK older than 19 with a different text for the same value (JDK 17 wrote1.0E23as9.999999999999999E22).enableCaseInsensitiveFilter=true.QueryImpl.andandQueryImpl.oradd no parentheses. This only returns extra entries.containsAllValueswith several values joins the equalities withor, so an entry that has only one of them is returned too. This only returns extra entries.