Skip to content

fix(index): express comparison bounds in the indexed property's type (#222) - #228

Merged
mrdevrobot merged 1 commit into
mainfrom
fix/222-index-bound-types
Oct 15, 2026
Merged

mrdevrobot merged 1 commit into
mainfrom
fix/222-index-bound-types

Conversation

@mrdevrobot

Copy link
Copy Markdown
Contributor

Fixes #222.

Problem

In r.Count < 9.5 or r.Count >= 5L the compiler wraps the property in Convert(r.Count, Double/Int64). The two Convert branches of IndexOptimizer.ParseSimplePredicate returned the constant with its own type. ConvertToIndexKey then encoded it as a double or long key, compared bytewise against the index's int keys, so the scan covered the wrong range: wrong counts and rows, and an exception for Max.

Fix

Both Convert branches now call TryConvertBound. It expresses the bound in the property's own type, adjusting the operator where needed. Properties with a value converter are left as they were.

int/long properties. Only a widening comparison is planned:

  • int→long, int/long→decimal and int→double are exact everywhere;
  • long→double is exact below 2^53;
  • int/long→float is exact below 2^24.

The bound becomes the integer that selects the same values. < 9.5 becomes <= 9, >= 2.5 becomes >= 3 and > 2.5 becomes >= 3. == 5.5 is always false.

Out-of-range and NaN bounds. An always-true comparison becomes >= MinValue, which keeps the plan exact and still leaves out the nulls of an int?. An always-false one becomes > MaxValue. HasResiduePredicate stays false, so the key-count push-down in CountByPredicateAsync stays correct.

Pairs already keyed alike are kept:

  • float/double;
  • DateTime/DateTimeOffset;
  • numeric bounds on a decimal property, which the decimal-keyed index converts itself;
  • integer bounds on a nullable enum, which become the enum value.

Anything else gets no index, and the predicate is evaluated per document. That covers NaN on float/double/decimal properties, narrowing casts such as (int)r.Total == 5, and lossy bounds beyond the exact limit. In an AND, the other side becomes the plan, with a residue.

Beyond the issue. Only Convert/ConvertChecked count as "the property" now. Before, -r.Count > 5 or an ArrayLength over an indexed property could be planned as a key range on that property.

Aggregates. The type guard in TryAggregateAsync (from #183) stays. Converted bounds now pass it, so these filters use the index-key aggregate. Only its comment changed, and one in LinqAggregateTests.

DynamicCollection doesn't use this planner and is not affected.

Tests

IndexBoundConversionTests has 171 tests on AggregateDbContext/Reading (indexed int Count, long Total and int? MaybeCount), with data 0..19 plus documents at the int and long minimum and maximum.

  • 56 predicates, each compared with LINQ to Objects through Where().ToListAsync(), CountAsync(pred) and Where().Max/Min (exceptions included). They cover the issue's table plus inclusive and strict bounds, in and out of range, NaN, ±infinity, float and decimal bounds, long and int? properties, AND merges of converted bounds, captured variables, and the reversed form 9.5 > r.Count.
  • Plan-shape checks: Count < 9.5 gives an exact plan with max 9 inclusive; Count >= 5L gives an int min of 5; Total > 1e17 gives no plan.

Before the fix 112 of the 171 failed. Full BLite.Tests suite: 2964 passed, 4 skipped, 0 failed. BLite.Core builds with no warnings.

Left as is

  • Indexes keyed on a cast, such as CreateIndexAsync(r => (long)r.Units). CollectionIndexInfo doesn't carry the key type, so bounds are still expressed in the property type. Even a plain r.Units == 5 against such an index was already mismatched before this PR.
  • A double bound on a decimal property ((double)r.Price >= x) is still converted by the index as before. It can be off by a hair for decimals with more than about 15 significant digits.
  • Lossy long→double and int→float bounds beyond 2^53 and 2^24 give up the index rather than computing the exact threshold. This is correct, but slower.

🤖 Generated with Claude Code

…222)

IndexOptimizer.ParseSimplePredicate kept the runtime type of the constant
in a comparison such as r.Count < 9.5 or r.Count >= 5L, where the compiler
wraps the int property in a Convert to double or long. Index keys carry no
type tag and compare bytewise, so a double or long bound against an int
index was encoded in another key layout and selected the wrong range:
Count(r => r.Count < 9.5) counted every document, Where(r => r.Count >= 2.5)
returned none, and Max over such a filter could throw.

The bound is now converted to the property's type when the property is
widened by the comparison. For int and long properties an integer bound
that selects the same values is chosen (Count < 9.5 becomes Count <= 9,
Count >= 2.5 becomes Count >= 3, Count > 9.0 stays strict); a bound beyond
the property's range, or NaN, turns the comparison into Count >= MinValue
or Count > MaxValue, so plans stay exact for the count and aggregate
push-downs and nulls of an int? property stay excluded. Pairs whose keys
already share a layout (float/double, DateTime/DateTimeOffset, numeric
bounds on a decimal-keyed index, enums) are kept. Anything else, such as a
narrowing cast like (int)r.Total == 5 or a double bound beyond 2^53 on a
long, plans no index, so the query evaluates the predicate per document.
Only Convert/ConvertChecked unary nodes are treated as the property itself
now, so Negate or ArrayLength over a property no longer becomes a key range.

DynamicCollection does not use this planner and is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mrdevrobot mrdevrobot added this to the 5.3.0 milestone Oct 15, 2026
@mrdevrobot mrdevrobot added bug Something isn't working area/indexing B-Tree and secondary indexes, index lifecycle labels Oct 15, 2026
@mrdevrobot
mrdevrobot merged commit 7e37f33 into main Oct 15, 2026
6 checks passed
@mrdevrobot
mrdevrobot deleted the fix/222-index-bound-types branch October 15, 2026 19:00

This branch was successfully deployed

1 active deployment
Production — 8bfa3934 Deployed Oct 15, 2026 by mrdevrobot via build_and_publish #719
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/indexing B-Tree and secondary indexes, index lifecycle bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Index range with a constant of another type than the property (r.Count < 9.5, r.Count >= 5L) selects the wrong keys

1 participant