-
Notifications
You must be signed in to change notification settings - Fork 1
StringMapperBase<T> PK lookup via FirstOrDefaultAsync(x => x.Id == value) always returns null for DocumentCollection<string, T> #21
Description
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.Idis 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