Skip to content

Select followed by Any / Count(predicate) / Max / Sum returns false or 0: value-type default is taken as the push-down result #181

Closed

Description

@CaffeinatedCoder

Version: BLite 5.2.1 (main at 45dbc8c), .NET 10.0.12, macOS 27 arm64 (the code path is platform-independent)

What happens

A LINQ query that ends in a Select followed by a terminal returning a value type (bool, int, long, decimal, ...) silently returns that type's default. Select(p => p.Age).Any() returns false and Select(p => p.Age).Max() returns 0 on a collection full of matching documents. No exception is thrown.

The provider tries a SELECT push-down before the general path (BTreeQueryProvider.cs:257-267):

var pushed = TryPushDownSelect<TResult>(model.SelectClause, model.WhereClause);
if (pushed is not null) return pushed;

TryPushDownSelect only applies to sequence results. For anything else it returns default to mean "not applicable" (BTreeQueryProvider.cs:599-605):

if (!resultType.IsGenericType) return default;

For a reference-type result such as string, default is null, so First() falls through and works. For a value-type result, TResult? on an unconstrained generic is just TResult. So default is false or 0, which is not null, and the caller returns it as the query result.

Count() is unaffected only because an earlier fast path handles it. Where(...).Select(...).Any() has the same problem.

Repro

Runs as-is in tests/BLite.Tests against the existing TestDbContext (PeopleV2).

[Fact]
public async Task Select_Then_Scalar_Terminal()
{
    await _db.PeopleV2.InsertBulkAsync(
        Enumerable.Range(0, 300).Select(i => new PersonV2 { Name = $"p{i}", Age = i % 50 }).ToList());
    var q = _db.PeopleV2.AsQueryable();

    Assert.True(q.Select(p => p.Age).Any());
    Assert.True(q.Select(p => p.Age).Any(a => a == 7));
    Assert.Equal(6, q.Select(p => p.Age).Count(a => a == 7));
    Assert.Equal(49, q.Select(p => p.Age).Max());
    Assert.Equal(7350, q.Select(p => p.Age).Sum());
    Assert.True(q.Where(p => p.Age == 7).Select(p => p.Name).Any());
}

Output

Select(p => p.Age).Any()                         False
Select(p => p.Age).Any(a => a == 7)              False
Select(p => p.Age).Count(a => a == 7)            0
Select(p => p.Age).Max()                         0
Select(p => p.Age).Sum()                         0
Where(p => p.Age == 7).Select(p => p.Name).Any() False

Select(p => p.Age).Count()                       300   (correct)
Select(p => p.Name).First()                      p..   (correct)
Select(p => p.Age).ToList().Count                300   (correct)

Expected: True, True, 6, 49, 7350, True.

Possible fix (for discussion)

Only try the push-down when TResult is a sequence, or have TryPushDownSelect report success with a bool and an out value instead of using default as a sentinel. Any other early-return helper that uses default to mean "not applicable" would have the same problem for value-type results; I haven't checked them all.

Happy to send a PR for this if you would like one.

Activity

  1. mrdevrobot commented on Oct 12, 2026

    @mrdevrobot
    Contributor

    Confirmed on main: Select(p => p.Age).Any(), .Any(pred), .Count(pred) and .First(), and Where(...).Select(...).Any(), return false or 0. Element terminals after a Select go through TryPushDownSelect (BTreeQueryProvider.cs ~L287-294), which ignores HasComplexOperators and returns default. A PR would be welcome. Please coordinate with #180, which touches the same terminal handling.

  2. moved this from Todo to In Progress in BLiteon Oct 12, 2026
  3. moved this from In Progress to Done in BLiteon Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions