Skip to content

StringMapperBase<T> PK lookup via FirstOrDefaultAsync(x => x.Id == value) always returns null for DocumentCollection<string, T> #21

@LeoYang06

Description

@LeoYang06

Package version

4.0.1

Affected package

BLite (client SDK)

.NET version

10.0

Description

Queries using FirstOrDefaultAsync(x => x.Id == value) on a DocumentCollection<string, T> always return null, even when the document exists and the Id value is an exact match. However, converting it to case-insensitive comparison (e.g., by adding .ToLowerInvariant()) returns the correct result.

Minimal reproduction

1. Entity Definition

// EntityBase<TId> — base class with Id property
public abstract class EntityBase<TId> : IEntity<TId> where TId : notnull
{
    protected EntityBase(TId id)
    {
        ArgumentNullException.ThrowIfNull(id, "entity id");
        Id = id;
    }

    public virtual TId Id { get; init; }
}

// PoBase<TId> — persistence object base
public abstract class PoBase<TId>(TId id) : EntityBase<TId>(id), IPoEntity where TId : notnull;

// DevicePo — the entity being queried
public class DevicePo(string id) : PoBase<string>(id)
{
    public required string Name { get; init; }
    public DeviceType Type { get; set; }
    public required string Identifier { get; init; }
    public DateTime AddedAt { get; set; }
    public DateTime LastSeen { get; set; }
    public required List<LinkedFolderPo> LinkedFolders { get; set; } = [];
}

2. DbContext & Index Configuration

Note: A secondary unique index on x.Id is defined. It's unclear whether this affects the bug.

public sealed partial class PhotoDeviceSourceBLiteDbContext : DocumentDbContext
{
    public DocumentCollection<string, DevicePo> Devices { get; set; } = null!;
    public DocumentCollection<string, LinkedFolderPo> LinkedFolders { get; set; } = null!;

    public PhotoDeviceSourceBLiteDbContext(string path) : base(path)
    {
        InitializeCollections();
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DevicePo>()
            .ToCollection("Device")
            .HasIndex(x => x.Id, unique: true)         // secondary index on primary key
            .HasIndex(x => x.Identifier, unique: true);
    
        modelBuilder.Entity<LinkedFolderPo>()
            .ToCollection("LinkedFolder")
            .HasIndex(x => x.Id, unique: true)
            .HasIndex(x => x.DeviceId);
    }
}

3. Generated Mapper (source generator output)

The source generator produces a StringMapperBase<DevicePo>:

public class DevicePoMapper : StringMapperBase<DevicePo>
{
    public override string CollectionName => "Device";

    public void SerializeFields(DevicePo entity, ref BsonSpanWriter writer)
    {
        // ... other fields ...
        writer.WriteString("_id", entity.Id);
    }
    
    public DevicePo Deserialize(ref BsonSpanReader reader)
    {
        string? id = default;
        // ... read other fields ...
        case "_id":
            id = reader.ReadString();
            break;
        // ...
        _setter_Id(entity, id ?? default!);
        return entity;
    }
    
    public override string GetId(DevicePo entity) => entity.Id;
    public override void SetId(DevicePo entity, string id) => _setter_Id(entity, id);
    // ^ _setter_Id is a generated delegate to bypass the 'init' accessor restriction
}

Expected behavior

FirstOrDefaultAsync(x => x.Id == "CC3Rgw-i") should return the matching document.

Actual behavior

Test Results (from Rider Immediate Window)

A single DevicePo record exists in the database with Id = "CC3Rgw-i".

// ✅ Data exists
ReadOnlyDb.Devices.CountAsync().Result
// → 1

// ✅ Deserialization works — entity is correctly retrieved without predicate
ReadOnlyDb.Devices.AsQueryable().FirstOrDefaultAsync().Result
// → DevicePo [Id=CC3Rgw-i], Name="LEO-PC", Identifier="TKFGF6F6NHHDGFYFBV2FB0HJW2YJDFMZ8JAHQ00T3E3A10JEC5CG"

// ❌ Direct equality on primary key — returns null
ReadOnlyDb.Devices.AsQueryable().FirstOrDefaultAsync(x => x.Id == "CC3Rgw-i").Result
// → null

// ❌ Same result with a variable
ReadOnlyDb.Devices.AsQueryable().FirstOrDefaultAsync(x => x.Id == id, ct).Result
// → null   (id = "CC3Rgw-i")

// ✅ Forcing full scan via ToLowerInvariant() — returns correct result
ReadOnlyDb.Devices.AsQueryable()
    .FirstOrDefaultAsync(x => x.Id.ToLowerInvariant() == "CC3Rgw-i".ToLowerInvariant(), ct).Result
// → DevicePo [Id=CC3Rgw-i]

// ✅ Forcing full scan via ToUpperInvariant() — also works
ReadOnlyDb.Devices.AsQueryable()
    .FirstOrDefaultAsync(x => x.Id.ToUpperInvariant() == "CC3Rgw-i".ToUpperInvariant(), ct).Result
// → DevicePo [Id=CC3Rgw-i]

// ✅ Forcing full scan via ToLower() — also works
ReadOnlyDb.Devices.AsQueryable()
    .FirstOrDefaultAsync(x => x.Id.ToLower() == "CC3Rgw-i".ToLower(), ct).Result
// → DevicePo [Id=CC3Rgw-i]

Observed Behavior

Expression Result
x.Id == "CC3Rgw-i" ❌ null
x.Id.ToLowerInvariant() == "CC3Rgw-i".ToLowerInvariant() ✅ found
x.Id.ToUpperInvariant() == "CC3Rgw-i".ToUpperInvariant() ✅ found
x.Id.ToLower() == "CC3Rgw-i".ToLower() ✅ found

The direct equality comparison x.Id == value fails to find the document, while any form of case transformation on x.Id before comparison succeeds.
This indicates that direct equality comparison x.Id == value and method-based comparison x.Id.ToLower() == ... follow different code paths in BLite's LINQ provider, and the former path appears to have a defect.

Additional context

No response

Metadata

Metadata

Labels

bugSomething isn't workingenhancementNew feature or requestpackage/clientAffects BLite client SDK

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions