From d1e665852a5155e49739d2c1a2ce060398f7f81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 18 Oct 2026 12:54:27 +0200 Subject: [PATCH] fix(generator): discover a DocumentDbContext whatever its class name (#300) The generator's syntax predicate only passed classes whose name ends in "Context". A DocumentDbContext called Shop, AppDb or Store got no generated code and no diagnostic, so its collection properties stayed null and the first call threw NullReferenceException far from the cause. The predicate now passes every class with a base list; the semantic check in GetDbContextInfo (InheritsFrom DocumentDbContext) already decides, and it is what makes the pre-filter safe to widen. The unused DbContextClassSuffix convention is removed. Tests: UnsuffixedNameFixtures (BLite.Shared) declare a context named Shop and a derived OnlineShop; UnsuffixedNameGeneratorTests check their collections are generated and round-trip. All three fail on upstream/main (collections null, no mapper namespace) and pass with the fix. Co-Authored-By: Claude Fable 5.1 --- .../Helpers/BLiteConventions.cs | 2 - src/BLite.SourceGenerators/MapperGenerator.cs | 9 ++- tests/BLite.Shared/UnsuffixedNameFixtures.cs | 44 ++++++++++++ .../UnsuffixedNameGeneratorTests.cs | 69 +++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 tests/BLite.Shared/UnsuffixedNameFixtures.cs create mode 100644 tests/BLite.Tests/UnsuffixedNameGeneratorTests.cs diff --git a/src/BLite.SourceGenerators/Helpers/BLiteConventions.cs b/src/BLite.SourceGenerators/Helpers/BLiteConventions.cs index 8b0a66f9..081ec6e3 100644 --- a/src/BLite.SourceGenerators/Helpers/BLiteConventions.cs +++ b/src/BLite.SourceGenerators/Helpers/BLiteConventions.cs @@ -9,8 +9,6 @@ internal static class BLiteConventions // ── DbContext discovery ──────────────────────────────────────────────── /// Base class name that marks a user class as a BLite DbContext. public const string DocumentDbContextBaseName = "DocumentDbContext"; - /// Suffix expected on DbContext class names to filter candidates early. - public const string DbContextClassSuffix = "Context"; /// File suffix used to skip already-generated files. public const string GeneratedFileSuffix = ".g.cs"; diff --git a/src/BLite.SourceGenerators/MapperGenerator.cs b/src/BLite.SourceGenerators/MapperGenerator.cs index 7e7cfa7f..45cd94d6 100644 --- a/src/BLite.SourceGenerators/MapperGenerator.cs +++ b/src/BLite.SourceGenerators/MapperGenerator.cs @@ -489,13 +489,18 @@ private static void PrintNestedTypes(StringBuilder sb, Dictionary + /// Syntactic pre-filter: any class with a base list. Whether it really derives from + /// DocumentDbContext is decided semantically in . + /// The class name is deliberately not part of the filter: a context may be called + /// anything (Shop, AppDb), and a name filter would skip it silently (#300). + /// private static bool IsPotentialDbContext(SyntaxNode node) { if (node.SyntaxTree.FilePath.EndsWith(BLiteConventions.GeneratedFileSuffix)) return false; return node is ClassDeclarationSyntax classDecl && - classDecl.BaseList != null && - classDecl.Identifier.Text.EndsWith(BLiteConventions.DbContextClassSuffix); + classDecl.BaseList != null; } // ── Interceptor helpers ─────────────────────────────────────────────────── diff --git a/tests/BLite.Shared/UnsuffixedNameFixtures.cs b/tests/BLite.Shared/UnsuffixedNameFixtures.cs new file mode 100644 index 00000000..a361113e --- /dev/null +++ b/tests/BLite.Shared/UnsuffixedNameFixtures.cs @@ -0,0 +1,44 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Collections; + +namespace BLite.Shared; + +// Regression fixtures for EntglDb/BLite#300: the mapper generator only looked at classes +// whose name ends in "Context", so a DocumentDbContext called anything else got no generated +// code and no diagnostic, and its collections stayed null. These classes compiling at all +// (InitializeCollections is generated) already proves the generator found them. + +/// A context whose name does not end in "Context". +public partial class Shop : DocumentDbContext +{ + public DocumentCollection Customers { get; set; } = null!; + + public Shop(string databasePath) : base(databasePath) + { + InitializeCollections(); + } +} + +/// A context that derives from another context, neither named with the suffix. +public partial class OnlineShop : Shop +{ + public DocumentCollection Orders { get; set; } = null!; + + public OnlineShop(string databasePath) : base(databasePath) + { + InitializeCollections(); + } +} + +public class ShopCustomer +{ + public ObjectId Id { get; set; } + public string Name { get; set; } = ""; +} + +public class ShopOrder +{ + public ObjectId Id { get; set; } + public decimal Total { get; set; } +} diff --git a/tests/BLite.Tests/UnsuffixedNameGeneratorTests.cs b/tests/BLite.Tests/UnsuffixedNameGeneratorTests.cs new file mode 100644 index 00000000..cd478bde --- /dev/null +++ b/tests/BLite.Tests/UnsuffixedNameGeneratorTests.cs @@ -0,0 +1,69 @@ +using BLite.Bson; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Regression coverage for a DocumentDbContext whose class name does not end in +/// "Context" (see UnsuffixedNameFixtures.cs in BLite.Shared). The generator's syntax +/// filter used to require that suffix, so such a context got no generated code and no +/// diagnostic; its collection properties stayed null (EntglDb/BLite#300). +/// +public class UnsuffixedNameGeneratorTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"blite_unsuffixed_{Guid.NewGuid():N}.db"); + + public void Dispose() + { + TryDelete(_dbPath); + TryDelete(Path.ChangeExtension(_dbPath, ".wal")); + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ } + } + + [Fact] + public async Task Context_NamedWithoutSuffix_HasGeneratedCollections_AndRoundTrips() + { + using var db = new Shop(_dbPath); + + Assert.NotNull(db.Customers); + + var id = ObjectId.NewObjectId(); + await db.Customers.InsertAsync(new ShopCustomer { Id = id, Name = "Ada" }); + + var stored = await db.Customers.FindByIdAsync(id); + Assert.NotNull(stored); + Assert.Equal("Ada", stored.Name); + } + + [Fact] + public async Task DerivedContext_NamedWithoutSuffix_HasBothCollections() + { + using var db = new OnlineShop(_dbPath); + + Assert.NotNull(db.Customers); + Assert.NotNull(db.Orders); + + var id = ObjectId.NewObjectId(); + await db.Orders.InsertAsync(new ShopOrder { Id = id, Total = 12.5m }); + var stored = await db.Orders.FindByIdAsync(id); + Assert.NotNull(stored); + Assert.Equal(12.5m, stored.Total); + } + + [Fact] + public void Generated_Mappers_Exist_ForTheUnsuffixedContext() + { + var mapperTypes = typeof(Shop).Assembly + .GetTypes() + // "{namespace}.{class}_{file}_Mappers", as for every context (see GlobalNamespaceGeneratorTests). + .Where(t => t.Namespace == $"{typeof(Shop).Namespace}.{nameof(Shop)}_UnsuffixedNameFixtures_Mappers") + .ToArray(); + + Assert.NotEmpty(mapperTypes); + Assert.Contains(mapperTypes, t => t.Name.Contains(nameof(ShopCustomer))); + } +}