From cb92c87a88320f982e807a55700355a7eaba5a62 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 10:23:48 -0500 Subject: [PATCH 1/2] Add the data provider packages, dialects and system migrations --- Directory.Packages.props | 3 + docs/system-migrations.md | 29 ++ .../DependencyInjection/FluentCmsBuilder.cs | 32 +++ .../FluentCmsConfigurationException.cs | 13 + .../FluentCmsServiceCollectionExtensions.cs | 46 +++ src/FluentCMS.Core/Storage/CmsDbContext.cs | 3 + .../Storage/CmsDbContextOptionsExtensions.cs | 20 +- .../Storage/CmsModelCacheKeyFactory.cs | 9 +- .../Storage/CmsModelOptionsExtension.cs | 4 +- src/FluentCMS.Core/Storage/IDataProvider.cs | 24 ++ .../Storage/IJobClaimDialect.cs | 24 ++ src/FluentCMS.Core/Storage/IJsonSqlDialect.cs | 33 +++ src/FluentCMS.Core/Storage/JsonScalar.cs | 57 ++++ .../Storage/StorageErrorCodes.cs | 8 + src/FluentCMS.Core/Storage/SystemDbContext.cs | 27 ++ .../FluentCMS.Data.PostgreSql.csproj | 12 + .../20261102151140_InitialSystem.Designer.cs | 242 ++++++++++++++++ .../20261102151140_InitialSystem.cs | 120 ++++++++ .../SystemDbContextModelSnapshot.cs | 239 ++++++++++++++++ .../PostgreSqlBuilderExtensions.cs | 14 + .../PostgreSqlDataProvider.cs | 24 ++ .../PostgreSqlJobClaimDialect.cs | 35 +++ .../PostgreSqlJsonDialect.cs | 114 ++++++++ .../PostgreSqlJsonFunctions.cs | 36 +++ .../PostgreSqlStorageConventions.cs | 23 ++ .../SystemDbContextFactory.cs | 18 ++ .../FluentCMS.Data.SqlServer.csproj | 17 ++ .../20261102151146_InitialSystem.Designer.cs | 245 ++++++++++++++++ .../20261102151146_InitialSystem.cs | 121 ++++++++ .../SystemDbContextModelSnapshot.cs | 242 ++++++++++++++++ .../SqlServerBuilderExtensions.cs | 15 + .../SqlServerDataProvider.cs | 24 ++ .../SqlServerJobClaimDialect.cs | 32 +++ .../SqlServerJsonDialect.cs | 122 ++++++++ .../SqlServerJsonFunctions.cs | 26 ++ .../SqlServerStorageConventions.cs | 25 ++ .../SystemDbContextFactory.cs | 18 ++ .../FluentCMS.Data.Sqlite.csproj | 12 + .../20261102151133_InitialSystem.Designer.cs | 240 ++++++++++++++++ .../20261102151133_InitialSystem.cs | 121 ++++++++ .../SystemDbContextModelSnapshot.cs | 237 ++++++++++++++++ .../SqliteBuilderExtensions.cs | 14 + .../SqliteDataProvider.cs | 24 ++ .../SqliteJobClaimDialect.cs | 51 ++++ .../SqliteJsonDialect.cs | 97 +++++++ .../SqliteJsonFunctions.cs | 33 +++ .../SqliteStorageConventions.cs | 24 ++ .../SystemDbContextFactory.cs | 18 ++ .../Providers/OfflineProviderTests.cs | 205 ++++++++++++++ .../Sqlite/ConventionsTests.cs | 150 ++++++++++ .../FluentCMS.Data.Tests/Sqlite/HostTests.cs | 85 ++++++ .../Sqlite/JobClaimTests.cs | 231 +++++++++++++++ .../Sqlite/JsonFilterTests.cs | 263 ++++++++++++++++++ .../FluentCMS.Data.Tests/Sqlite/RowFilter.cs | 25 ++ .../Sqlite/SqliteFileDatabase.cs | 28 ++ .../Sqlite/SystemMigrationTests.cs | 99 +++++++ 56 files changed, 4045 insertions(+), 8 deletions(-) create mode 100644 docs/system-migrations.md create mode 100644 src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs create mode 100644 src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs create mode 100644 src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs create mode 100644 src/FluentCMS.Core/Storage/IDataProvider.cs create mode 100644 src/FluentCMS.Core/Storage/IJobClaimDialect.cs create mode 100644 src/FluentCMS.Core/Storage/IJsonSqlDialect.cs create mode 100644 src/FluentCMS.Core/Storage/JsonScalar.cs create mode 100644 src/FluentCMS.Core/Storage/StorageErrorCodes.cs create mode 100644 src/FluentCMS.Core/Storage/SystemDbContext.cs create mode 100644 src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs create mode 100644 src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs create mode 100644 src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs create mode 100644 src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs create mode 100644 src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs create mode 100644 src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs create mode 100644 src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs create mode 100644 src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs create mode 100644 src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs create mode 100644 src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs create mode 100644 src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs create mode 100644 src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs create mode 100644 src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs create mode 100644 src/FluentCMS.Data.SqlServer/SqlServerBuilderExtensions.cs create mode 100644 src/FluentCMS.Data.SqlServer/SqlServerDataProvider.cs create mode 100644 src/FluentCMS.Data.SqlServer/SqlServerJobClaimDialect.cs create mode 100644 src/FluentCMS.Data.SqlServer/SqlServerJsonDialect.cs create mode 100644 src/FluentCMS.Data.SqlServer/SqlServerJsonFunctions.cs create mode 100644 src/FluentCMS.Data.SqlServer/SqlServerStorageConventions.cs create mode 100644 src/FluentCMS.Data.SqlServer/SystemDbContextFactory.cs create mode 100644 src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.Designer.cs create mode 100644 src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.cs create mode 100644 src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs create mode 100644 src/FluentCMS.Data.Sqlite/SqliteBuilderExtensions.cs create mode 100644 src/FluentCMS.Data.Sqlite/SqliteDataProvider.cs create mode 100644 src/FluentCMS.Data.Sqlite/SqliteJobClaimDialect.cs create mode 100644 src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs create mode 100644 src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs create mode 100644 src/FluentCMS.Data.Sqlite/SqliteStorageConventions.cs create mode 100644 src/FluentCMS.Data.Sqlite/SystemDbContextFactory.cs create mode 100644 tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/ConventionsTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/HostTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/JobClaimTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/RowFilter.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/SqliteFileDatabase.cs create mode 100644 tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7ff98ad9a..af87bfa88 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,9 +10,12 @@ + + + diff --git a/docs/system-migrations.md b/docs/system-migrations.md new file mode 100644 index 000000000..c3c818fbc --- /dev/null +++ b/docs/system-migrations.md @@ -0,0 +1,29 @@ +# System migrations + +`SystemDbContext` is the model of the system tables: what `SystemModelBuilder` maps, with no content tables and with the history table `cms__migrations`. Each data provider package holds the migrations of that model for its database, in its `Migrations` folder. + +## Adding a migration + +Do this in the pull request that changes `SystemModelBuilder`, such as one that adds `cms_site` or `cms_job`. You need the `dotnet-ef` tool (`dotnet tool install --global dotnet-ef`). No database is needed, because the design-time factory in each package never connects. + +1. Map the table in `SystemModelBuilder`. + +2. Scaffold the migration once per provider, with the same name. `dotnet ef` starts a fresh context each time, which matters: scaffolding on a context that has already called `HasPendingModelChanges()` silently emits `CreateTable` for every table. + + ```bash + dotnet ef migrations add AddSites --project src/FluentCMS.Data.Sqlite --context SystemDbContext --output-dir Migrations + ``` + + ```bash + dotnet ef migrations add AddSites --project src/FluentCMS.Data.PostgreSql --context SystemDbContext --output-dir Migrations + ``` + + ```bash + dotnet ef migrations add AddSites --project src/FluentCMS.Data.SqlServer --context SystemDbContext --output-dir Migrations + ``` + +3. Add `// ` as the first line of each new migration file. EF marks only the designer and snapshot files, and the composite index columns in the migration file otherwise fail the build with CA1861. + +4. Run `dotnet test --project tests/FluentCMS.Data.Tests -c Release`. The tests fail if a provider's snapshot no longer matches the model, so a skipped provider is caught. + +Do not edit a migration that has shipped. The PostgreSQL and SQL Server migrations have never been applied to a database; [#2711](https://github.com/fluentcms/FluentCMS/issues/2711) validates them. diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs new file mode 100644 index 000000000..229aa1b32 --- /dev/null +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsBuilder.cs @@ -0,0 +1,32 @@ +using FluentCMS.Core.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.DependencyInjection; + +public sealed class FluentCmsBuilder +{ + internal FluentCmsBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } + + internal IDataProvider? DataProvider { get; private set; } + + // The seam for a data provider package, whose own UseXxx method calls this. + public FluentCmsBuilder UseDataProvider(IDataProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + + if (DataProvider is not null) + { + throw new FluentCmsConfigurationException( + StorageErrorCodes.SeveralDataProviders, + $"AddFluentCms got a second data provider, {provider.Name}, after {DataProvider.Name}; a host uses exactly one."); + } + + DataProvider = provider; + return this; + } +} diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs new file mode 100644 index 000000000..e11e506d6 --- /dev/null +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsConfigurationException.cs @@ -0,0 +1,13 @@ +namespace FluentCMS.Core.DependencyInjection; + +// A host is set up in a way that FluentCMS cannot start with. +public sealed class FluentCmsConfigurationException : InvalidOperationException +{ + internal FluentCmsConfigurationException(string code, string message) + : base($"{code}: {message}") + { + Code = code; + } + + public string Code { get; } +} diff --git a/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs new file mode 100644 index 000000000..16d815943 --- /dev/null +++ b/src/FluentCMS.Core/DependencyInjection/FluentCmsServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Core.Storage; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FluentCMS.Core.DependencyInjection; + +public static class FluentCmsServiceCollectionExtensions +{ + public static IServiceCollection AddFluentCms(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + var builder = new FluentCmsBuilder(services); + configure(builder); + + var provider = builder.DataProvider ?? throw new FluentCmsConfigurationException( + StorageErrorCodes.NoDataProvider, + "AddFluentCms has no data provider. Add the package for your database and call cms.UseSqlite(...), cms.UsePostgreSql(...) or cms.UseSqlServer(...) inside AddFluentCms."); + + services.AddSingleton(provider); + services.AddSingleton(provider.Conventions); + services.AddSingleton(provider.JsonDialect); + services.AddSingleton(provider.JobClaimDialect); + + // Until content types can be added to the builder, a schema comes from the container, and none means system tables only. + services.TryAddSingleton(_ => new SchemaBuilder().Build()); + + services.AddDbContext((serviceProvider, options) => + { + provider.ConfigureDbContext(options); + options.UseCmsModel(serviceProvider.GetRequiredService(), provider.Conventions, provider.JsonDialect); + }); + + services.AddDbContext(options => + { + provider.ConfigureSystemMigrations(options); + options.UseSystemModel(provider.Conventions); + }); + + return services; + } +} diff --git a/src/FluentCMS.Core/Storage/CmsDbContext.cs b/src/FluentCMS.Core/Storage/CmsDbContext.cs index af4bf1bdd..a6b62d470 100644 --- a/src/FluentCMS.Core/Storage/CmsDbContext.cs +++ b/src/FluentCMS.Core/Storage/CmsDbContext.cs @@ -25,6 +25,8 @@ public CmsDbContext(DbContextOptions options) internal string ConventionsKey => _model.Conventions.CacheKey; + internal string? JsonDialectKey => _model.JsonDialect?.GetType().FullName; + // The rows of a content table, such as cms_article or cms_article_i18n. public DbSet> Rows(string table) => Set>(table); @@ -35,5 +37,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { new SystemModelBuilder(_model.Conventions).Apply(modelBuilder); new ContentModelBuilder(_model.Schema, _model.Conventions).Apply(modelBuilder); + _model.JsonDialect?.ConfigureFunctions(modelBuilder); } } diff --git a/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs b/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs index d913c3aca..53a083e8f 100644 --- a/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs +++ b/src/FluentCMS.Core/Storage/CmsDbContextOptionsExtensions.cs @@ -1,3 +1,4 @@ +using FluentCMS.Core.Schema; using FluentCMS.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -6,16 +7,27 @@ namespace FluentCMS.Core.Storage; public static class CmsDbContextOptionsExtensions { - // Gives CmsDbContext the schema to build its model from. Without conventions the model stays neutral; - // a data provider package passes its own. - public static TBuilder UseCmsModel(this TBuilder builder, SchemaModel schema, IStorageConventions? conventions = null) + private static readonly SchemaModel NoContent = new SchemaBuilder().Build(); + + // Gives CmsDbContext the schema to build its model from. Without conventions the model stays neutral, and + // without a dialect it has no JSON functions; a data provider package passes its own. + public static TBuilder UseCmsModel(this TBuilder builder, SchemaModel schema, IStorageConventions? conventions = null, IJsonSqlDialect? jsonDialect = null) where TBuilder : DbContextOptionsBuilder { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(schema); - ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(new CmsModelOptionsExtension(schema, conventions ?? new StorageConventions())); + ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(new CmsModelOptionsExtension(schema, conventions ?? new StorageConventions(), jsonDialect)); builder.ReplaceService(); return builder; } + + // Gives SystemDbContext the conventions to build its model with. + public static TBuilder UseSystemModel(this TBuilder builder, IStorageConventions conventions) + where TBuilder : DbContextOptionsBuilder + { + ArgumentNullException.ThrowIfNull(conventions); + + return builder.UseCmsModel(NoContent, conventions); + } } diff --git a/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs b/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs index 8c4b6bc9f..77fdc27b7 100644 --- a/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs +++ b/src/FluentCMS.Core/Storage/CmsModelCacheKeyFactory.cs @@ -5,7 +5,10 @@ namespace FluentCMS.Core.Storage; internal sealed class CmsModelCacheKeyFactory : IModelCacheKeyFactory { - public object Create(DbContext context, bool designTime) => context is CmsDbContext cms - ? (context.GetType(), cms.ModelKey, cms.ConventionsKey, designTime) - : (context.GetType(), designTime); + public object Create(DbContext context, bool designTime) => context switch + { + CmsDbContext cms => (context.GetType(), cms.ModelKey, cms.ConventionsKey, cms.JsonDialectKey, designTime), + SystemDbContext system => (context.GetType(), system.ConventionsKey, designTime), + _ => (context.GetType(), designTime), + }; } diff --git a/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs b/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs index d1ff471a7..77ff7749c 100644 --- a/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs +++ b/src/FluentCMS.Core/Storage/CmsModelOptionsExtension.cs @@ -6,7 +6,7 @@ namespace FluentCMS.Core.Storage; // Carries the schema to CmsDbContext through its options, so the context has the one constructor that // AddDbContext, pooling and design-time factories expect. -internal sealed class CmsModelOptionsExtension(SchemaModel schema, IStorageConventions conventions) : IDbContextOptionsExtension +internal sealed class CmsModelOptionsExtension(SchemaModel schema, IStorageConventions conventions, IJsonSqlDialect? jsonDialect) : IDbContextOptionsExtension { private ExtensionInfo? _info; @@ -14,6 +14,8 @@ internal sealed class CmsModelOptionsExtension(SchemaModel schema, IStorageConve public IStorageConventions Conventions { get; } = conventions; + public IJsonSqlDialect? JsonDialect { get; } = jsonDialect; + public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this); public void ApplyServices(IServiceCollection services) diff --git a/src/FluentCMS.Core/Storage/IDataProvider.cs b/src/FluentCMS.Core/Storage/IDataProvider.cs new file mode 100644 index 000000000..f9790c794 --- /dev/null +++ b/src/FluentCMS.Core/Storage/IDataProvider.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Storage; + +// Everything a data provider package gives core. Its UseXxx method passes one to FluentCmsBuilder.UseDataProvider. +public interface IDataProvider +{ + string Name { get; } + + // The default of Database:MigrateOnStartup. The migration runner acts on it. + bool MigrateOnStartup { get; } + + IStorageConventions Conventions { get; } + + IJsonSqlDialect JsonDialect { get; } + + IJobClaimDialect JobClaimDialect { get; } + + // Points the options at the database. + void ConfigureDbContext(DbContextOptionsBuilder options); + + // The same, for SystemDbContext: adds the assembly that holds the system migrations and their history table. + void ConfigureSystemMigrations(DbContextOptionsBuilder options); +} diff --git a/src/FluentCMS.Core/Storage/IJobClaimDialect.cs b/src/FluentCMS.Core/Storage/IJobClaimDialect.cs new file mode 100644 index 000000000..c20c0dec6 --- /dev/null +++ b/src/FluentCMS.Core/Storage/IJobClaimDialect.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace FluentCMS.Core.Storage; + +// The next job due on a queue, leased to one worker until LeaseUntil. +public sealed record JobClaim(string Queue, string Owner, DateTimeOffset Now, DateTimeOffset LeaseUntil); + +// The values of cms_job.status that claiming reads and writes. +public static class JobClaimStatus +{ + public const string Pending = "pending"; + + public const string Running = "running"; +} + +// Claims one job from cms_job, so that of any number of concurrent workers exactly one gets each job. A candidate +// is pending or running, due, under max_attempts, and has no lease or an expired one, which is how a job comes back +// after its worker died. The next is the highest priority, then the earliest run_at. The claim sets status to +// running, takes the lease and adds one to attempts. Dates are bound as the provider's conventions store them. +public interface IJobClaimDialect +{ + // The id of the claimed job, or null if there was nothing to claim. + Task ClaimAsync(DatabaseFacade database, JobClaim claim, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Storage/IJsonSqlDialect.cs b/src/FluentCMS.Core/Storage/IJsonSqlDialect.cs new file mode 100644 index 000000000..b8d3b9f46 --- /dev/null +++ b/src/FluentCMS.Core/Storage/IJsonSqlDialect.cs @@ -0,0 +1,33 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Storage; + +// Builds the JSON filters of the query compiler as LINQ expressions over a provider's EF function mappings, +// limited to what Q7 supports. Each method returns a bool expression, so filters compose with And, Or and Not. +// +// A json argument is a string expression holding JSON: EF.Property(row, "sections"), or the block that +// AnyBlock hands to its predicate. A value argument is the compiler's parameter expression; bind it by reading +// a member of a holder object, because a bare constant becomes a SQL literal. +public interface IJsonSqlDialect +{ + // Declares the functions the expressions below call. CmsDbContext applies this to its model. + void ConfigureFunctions(ModelBuilder modelBuilder); + + // The block list holds a block of this type. blockType is a string expression. + Expression HasBlock(Expression json, Expression blockType); + + // The block list holds a block of this type that also satisfies the predicate, so that every condition + // applies to the same block. The predicate receives the block and calls the Scalar and Array methods on it. + Expression AnyBlock(Expression json, Expression blockType, Func predicate); + + // The value at the top-level key has this type and equals the value, whose CLR type is JsonScalar.ClrType(type). + // A JSON number never equals the text '12'. + Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value); + + // The value at the top-level key is JSON text that contains the string value, case-sensitively. + Expression ScalarContains(Expression json, string field, Expression value); + + // The value at the top-level key is an array holding the string value as an element. + Expression ArrayContains(Expression json, string field, Expression value); +} diff --git a/src/FluentCMS.Core/Storage/JsonScalar.cs b/src/FluentCMS.Core/Storage/JsonScalar.cs new file mode 100644 index 000000000..60dd62567 --- /dev/null +++ b/src/FluentCMS.Core/Storage/JsonScalar.cs @@ -0,0 +1,57 @@ +using System.Linq.Expressions; +using FluentCMS.Core.Schema.FieldTypes; + +namespace FluentCMS.Core.Storage; + +// How a scalar inside JSON is compared. A JSON extraction keeps the value's own type, so each kind has its own comparison. +public enum JsonScalarType +{ + Text, + WholeNumber, + DecimalNumber, + Boolean, +} + +public static class JsonScalar +{ + // The comparison for a field type, or null if the type has no scalar value inside JSON. Dates are compared as + // the ISO 8601 text that the JSON holds. + public static JsonScalarType? TypeOf(string fieldType) => fieldType switch + { + FieldTypeNames.Text or FieldTypeNames.LongText or FieldTypeNames.Email or FieldTypeNames.Url or FieldTypeNames.Slug + or FieldTypeNames.Select or FieldTypeNames.Date or FieldTypeNames.DateTime => JsonScalarType.Text, + FieldTypeNames.Integer => JsonScalarType.WholeNumber, + FieldTypeNames.Decimal => JsonScalarType.DecimalNumber, + FieldTypeNames.Boolean => JsonScalarType.Boolean, + _ => null, + }; + + public static Type ClrType(JsonScalarType type) => type switch + { + JsonScalarType.Text => typeof(string), + JsonScalarType.WholeNumber => typeof(long), + JsonScalarType.DecimalNumber => typeof(decimal), + JsonScalarType.Boolean => typeof(bool), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null), + }; + + // Q7 caps paths at one level. Dialects put the result into a path or key literal, so only a plain name may pass. + public static string CheckField(string field) + { + ArgumentException.ThrowIfNullOrEmpty(field); + + return field.All(character => char.IsAsciiLetterOrDigit(character) || character == '_') + ? field + : throw new ArgumentException($"'{field}' is not a one-level JSON path.", nameof(field)); + } + + public static void CheckValue(JsonScalarType type, Expression value) + { + ArgumentNullException.ThrowIfNull(value); + + if (value.Type != ClrType(type)) + { + throw new ArgumentException($"A {type} comparison needs a {ClrType(type).Name} value, not {value.Type.Name}.", nameof(value)); + } + } +} diff --git a/src/FluentCMS.Core/Storage/StorageErrorCodes.cs b/src/FluentCMS.Core/Storage/StorageErrorCodes.cs new file mode 100644 index 000000000..445bbeb25 --- /dev/null +++ b/src/FluentCMS.Core/Storage/StorageErrorCodes.cs @@ -0,0 +1,8 @@ +namespace FluentCMS.Core.Storage; + +// FCMS1301 is the runner's missing column error (#2690). +internal static class StorageErrorCodes +{ + public const string NoDataProvider = "FCMS1302"; + public const string SeveralDataProviders = "FCMS1303"; +} diff --git a/src/FluentCMS.Core/Storage/SystemDbContext.cs b/src/FluentCMS.Core/Storage/SystemDbContext.cs new file mode 100644 index 000000000..fd60e6d6b --- /dev/null +++ b/src/FluentCMS.Core/Storage/SystemDbContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Core.Storage; + +// The system migration stream: only what SystemModelBuilder maps, never a content table. Each data provider +// package holds the migrations of this model for its database; docs/system-migrations.md says how to add one. +public sealed class SystemDbContext : DbContext +{ + public const string HistoryTable = "cms__migrations"; + + private readonly CmsModelOptionsExtension _model; + + public SystemDbContext(DbContextOptions options) + : base(options) + { + _model = options.FindExtension() + ?? throw new InvalidOperationException($"{nameof(SystemDbContext)} needs conventions; call {nameof(CmsDbContextOptionsExtensions.UseSystemModel)} on its options."); + } + + internal string ConventionsKey => _model.Conventions.CacheKey; + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + _model.Conventions.ConfigureConventions(configurationBuilder); + + protected override void OnModelCreating(ModelBuilder modelBuilder) => + new SystemModelBuilder(_model.Conventions).Apply(modelBuilder); +} diff --git a/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj b/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj index daaac7f5b..4c5d1357c 100644 --- a/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj +++ b/src/FluentCMS.Data.PostgreSql/FluentCMS.Data.PostgreSql.csproj @@ -1,5 +1,17 @@ + + Experimental: the PostgreSQL data provider for FluentCMS, not yet validated against a database. Storage conventions, JSON filters, job claiming and the system migrations. + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs new file mode 100644 index 000000000..e60dcb735 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.Designer.cs @@ -0,0 +1,242 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Data.PostgreSql.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20261102151140_InitialSystem")] + partial class InitialSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("integer") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("integer") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("uuid") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs new file mode 100644 index 000000000..bd8c28571 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/Migrations/20261102151140_InitialSystem.cs @@ -0,0 +1,120 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Data.PostgreSql.Migrations +{ + /// + public partial class InitialSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cms_document", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + document_id = table.Column(type: "uuid", nullable: false), + locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + site_id = table.Column(type: "uuid", nullable: true), + status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + draft_version = table.Column(type: "integer", nullable: false), + published_version = table.Column(type: "integer", nullable: true), + published_at = table.Column(type: "timestamp with time zone", nullable: true), + first_published_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_by = table.Column(type: "uuid", nullable: false), + schedule_publish_at = table.Column(type: "timestamp with time zone", nullable: true), + schedule_unpublish_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_document", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "cms_reference", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + source_type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + source_document_id = table.Column(type: "uuid", nullable: false), + state = table.Column(type: "smallint", nullable: false), + locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + field = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + position = table.Column(type: "integer", nullable: false), + target_type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + target_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_reference", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "cms_version", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + document_id = table.Column(type: "uuid", nullable: false), + locale = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + number = table.Column(type: "integer", nullable: false), + snapshot = table.Column(type: "jsonb", nullable: false), + status_after = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + author_id = table.Column(type: "uuid", nullable: false), + origin = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + client_id = table.Column(type: "text", nullable: true), + message = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_version", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "uq_cms_document_document_locale", + table: "cms_document", + columns: new[] { "document_id", "locale" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_cms_reference_target", + table: "cms_reference", + columns: new[] { "target_type", "target_id" }); + + migrationBuilder.CreateIndex( + name: "uq_cms_reference_source", + table: "cms_reference", + columns: new[] { "source_type", "source_document_id", "state", "locale", "field", "position" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "uq_cms_version_document_locale_number", + table: "cms_version", + columns: new[] { "document_id", "locale", "number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cms_document"); + + migrationBuilder.DropTable( + name: "cms_reference"); + + migrationBuilder.DropTable( + name: "cms_version"); + } + } +} diff --git a/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs new file mode 100644 index 000000000..cf305ff9a --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/Migrations/SystemDbContextModelSnapshot.cs @@ -0,0 +1,239 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Data.PostgreSql.Migrations +{ + [DbContext(typeof(SystemDbContext))] + partial class SystemDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("integer") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("integer") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("uuid") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs new file mode 100644 index 000000000..7eeff29c6 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlBuilderExtensions.cs @@ -0,0 +1,14 @@ +using FluentCMS.Core.DependencyInjection; + +namespace FluentCMS.Data.PostgreSql; + +public static class PostgreSqlBuilderExtensions +{ + public static FluentCmsBuilder UsePostgreSql(this FluentCmsBuilder cms, string? connectionString) + { + ArgumentNullException.ThrowIfNull(cms); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + return cms.UseDataProvider(new PostgreSqlDataProvider(connectionString)); + } +} diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs new file mode 100644 index 000000000..20c6cbd76 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlDataProvider.cs @@ -0,0 +1,24 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Data.PostgreSql; + +public sealed class PostgreSqlDataProvider(string connectionString) : IDataProvider +{ + public string Name => "PostgreSQL"; + + public bool MigrateOnStartup => false; + + public IStorageConventions Conventions { get; } = new PostgreSqlStorageConventions(); + + public IJsonSqlDialect JsonDialect { get; } = new PostgreSqlJsonDialect(); + + public IJobClaimDialect JobClaimDialect { get; } = new PostgreSqlJobClaimDialect(); + + public void ConfigureDbContext(DbContextOptionsBuilder options) => options.UseNpgsql(connectionString); + + public void ConfigureSystemMigrations(DbContextOptionsBuilder options) => + options.UseNpgsql(connectionString, npgsql => npgsql + .MigrationsAssembly(typeof(PostgreSqlDataProvider).Assembly) + .MigrationsHistoryTable(SystemDbContext.HistoryTable)); +} diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs new file mode 100644 index 000000000..8825b3c40 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlJobClaimDialect.cs @@ -0,0 +1,35 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace FluentCMS.Data.PostgreSql; + +// Not yet run on a database (#2711). +public sealed class PostgreSqlJobClaimDialect : IJobClaimDialect +{ + public async Task ClaimAsync(DatabaseFacade database, JobClaim claim, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(claim); + + var now = claim.Now.ToUniversalTime(); + var ids = await database.SqlQuery($""" + UPDATE cms_job + SET status = {JobClaimStatus.Running}, lease_owner = {claim.Owner}, lease_until = {claim.LeaseUntil.ToUniversalTime()}, attempts = attempts + 1 + WHERE id = ( + SELECT id + FROM cms_job + WHERE queue = {claim.Queue} + AND status IN ({JobClaimStatus.Pending}, {JobClaimStatus.Running}) + AND run_at <= {now} + AND attempts < max_attempts + AND (lease_until IS NULL OR lease_until < {now}) + ORDER BY priority DESC, run_at, id + LIMIT 1 + FOR UPDATE SKIP LOCKED) + RETURNING id AS "Value" + """).ToListAsync(cancellationToken); + + return ids.Count == 0 ? null : ids[0]; + } +} diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs new file mode 100644 index 000000000..e6ac028cc --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonDialect.cs @@ -0,0 +1,114 @@ +using System.Linq.Expressions; +using System.Reflection; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Data.PostgreSql; + +// Equality is jsonb containment (@>): it keeps the JSON type, so a number never equals the text '12', does not +// throw on an unexpected shape, and is what a GIN index on the column serves. Not yet run on a database (#2711). +public sealed class PostgreSqlJsonDialect : IJsonSqlDialect +{ + private const string BlockTypeField = "type"; + private const string Jsonb = "jsonb"; + + private static readonly MethodInfo Elements = Function(nameof(PostgreSqlJsonFunctions.Elements)); + private static readonly MethodInfo Extract = Function(nameof(PostgreSqlJsonFunctions.Extract)); + private static readonly MethodInfo ExtractText = Function(nameof(PostgreSqlJsonFunctions.ExtractText)); + private static readonly MethodInfo TypeOf = Function(nameof(PostgreSqlJsonFunctions.TypeOf)); + private static readonly MethodInfo ObjectOfText = Function(nameof(PostgreSqlJsonFunctions.ObjectOfText)); + private static readonly MethodInfo ObjectOfBigint = Function(nameof(PostgreSqlJsonFunctions.ObjectOfBigint)); + private static readonly MethodInfo ObjectOfNumeric = Function(nameof(PostgreSqlJsonFunctions.ObjectOfNumeric)); + private static readonly MethodInfo ObjectOfBoolean = Function(nameof(PostgreSqlJsonFunctions.ObjectOfBoolean)); + private static readonly MethodInfo ObjectOfJson = Function(nameof(PostgreSqlJsonFunctions.ObjectOfJson)); + private static readonly MethodInfo ArrayOfText = Function(nameof(PostgreSqlJsonFunctions.ArrayOfText)); + private static readonly MethodInfo ArrayOfJson = Function(nameof(PostgreSqlJsonFunctions.ArrayOfJson)); + private static readonly MethodInfo JsonContains = typeof(NpgsqlJsonDbFunctionsExtensions).GetMethod(nameof(NpgsqlJsonDbFunctionsExtensions.JsonContains))!; + private static readonly MethodInfo StringContains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; + private static readonly MethodInfo AnyElement = typeof(Queryable).GetMethods() + .Single(method => method.Name == nameof(Queryable.Any) && method.GetParameters().Length == 2) + .MakeGenericMethod(typeof(PostgreSqlJsonElement)); + + public void ConfigureFunctions(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasNoKey().ToTable((string?)null); + + modelBuilder.HasDbFunction(Elements).HasName("jsonb_array_elements").IsBuiltIn().HasParameter("json").HasStoreType(Jsonb); + + Declare(modelBuilder, Extract, "jsonb_extract_path", Jsonb).HasParameter("json").HasStoreType(Jsonb); + Declare(modelBuilder, ExtractText, "jsonb_extract_path_text", "text").HasParameter("json").HasStoreType(Jsonb); + Declare(modelBuilder, TypeOf, "jsonb_typeof", "text").HasParameter("json").HasStoreType(Jsonb); + + Declare(modelBuilder, ObjectOfText, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("text"); + Declare(modelBuilder, ObjectOfBigint, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("bigint"); + Declare(modelBuilder, ObjectOfNumeric, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("numeric"); + Declare(modelBuilder, ObjectOfBoolean, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType("boolean"); + Declare(modelBuilder, ObjectOfJson, "jsonb_build_object", Jsonb).HasParameter("value").HasStoreType(Jsonb); + Declare(modelBuilder, ArrayOfText, "jsonb_build_array", Jsonb).HasParameter("value").HasStoreType("text"); + Declare(modelBuilder, ArrayOfJson, "jsonb_build_array", Jsonb).HasParameter("value").HasStoreType(Jsonb); + } + + public Expression HasBlock(Expression json, Expression blockType) => + Contains(json, Expression.Call(ArrayOfJson, BlockOfType(blockType))); + + // The outer containment lets a GIN index narrow the rows before the elements are expanded. + public Expression AnyBlock(Expression json, Expression blockType, Func predicate) + { + var element = Expression.Parameter(typeof(PostgreSqlJsonElement), "element"); + var block = Expression.Property(element, nameof(PostgreSqlJsonElement.Value)); + var body = Expression.AndAlso(Contains(block, BlockOfType(blockType)), predicate(block)); + + return Expression.AndAlso( + HasBlock(json, blockType), + Expression.Call(AnyElement, Expression.Call(Elements, json), Expression.Quote(Expression.Lambda>(body, element)))); + } + + public Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value) + { + JsonScalar.CheckValue(type, value); + var key = Key(field); + + return Contains(json, type switch + { + JsonScalarType.Text => Expression.Call(ObjectOfText, key, value), + JsonScalarType.WholeNumber => Expression.Call(ObjectOfBigint, key, Expression.Convert(value, typeof(long?))), + JsonScalarType.DecimalNumber => Expression.Call(ObjectOfNumeric, key, Expression.Convert(value, typeof(decimal?))), + JsonScalarType.Boolean => Expression.Call(ObjectOfBoolean, key, Expression.Convert(value, typeof(bool?))), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null), + }); + } + + public Expression ScalarContains(Expression json, string field, Expression value) + { + JsonScalar.CheckValue(JsonScalarType.Text, value); + var key = Key(field); + + return Expression.AndAlso( + Expression.Equal(Expression.Call(TypeOf, Expression.Call(Extract, json, key)), Expression.Constant("string")), + Expression.Call(Expression.Call(ExtractText, json, key), StringContains, value)); + } + + public Expression ArrayContains(Expression json, string field, Expression value) + { + JsonScalar.CheckValue(JsonScalarType.Text, value); + + return Contains(json, Expression.Call(ObjectOfJson, Key(field), Expression.Call(ArrayOfText, value))); + } + + private static MethodCallExpression BlockOfType(Expression blockType) => + Expression.Call(ObjectOfText, Key(BlockTypeField), blockType); + + private static MethodCallExpression Contains(Expression json, Expression contained) => Expression.Call( + JsonContains, + Expression.Property(null, typeof(EF), nameof(EF.Functions)), + Expression.Convert(json, typeof(object)), + Expression.Convert(contained, typeof(object))); + + private static ConstantExpression Key(string field) => Expression.Constant(JsonScalar.CheckField(field)); + + private static DbFunctionBuilder Declare(ModelBuilder modelBuilder, MethodInfo function, string name, string storeType) => + modelBuilder.HasDbFunction(function).HasName(name).IsBuiltIn().HasStoreType(storeType); + + private static MethodInfo Function(string name) => typeof(PostgreSqlJsonFunctions).GetMethod(name)!; +} diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs new file mode 100644 index 000000000..83610daf3 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlJsonFunctions.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace FluentCMS.Data.PostgreSql; + +// A row of jsonb_array_elements. +public sealed class PostgreSqlJsonElement +{ + [Column("value", TypeName = "jsonb")] + public string? Value { get; set; } +} + +// EF function mappings; PostgreSqlJsonDialect declares them on the model. They run only as SQL. +public static class PostgreSqlJsonFunctions +{ + public static IQueryable Elements(string? json) => throw new NotSupportedException(); + + public static string? Extract(string? json, string key) => throw new NotSupportedException(); + + public static string? ExtractText(string? json, string key) => throw new NotSupportedException(); + + public static string? TypeOf(string? json) => throw new NotSupportedException(); + + public static string ObjectOfText(string key, string? value) => throw new NotSupportedException(); + + public static string ObjectOfBigint(string key, long? value) => throw new NotSupportedException(); + + public static string ObjectOfNumeric(string key, decimal? value) => throw new NotSupportedException(); + + public static string ObjectOfBoolean(string key, bool? value) => throw new NotSupportedException(); + + public static string ObjectOfJson(string key, string? value) => throw new NotSupportedException(); + + public static string ArrayOfText(string? value) => throw new NotSupportedException(); + + public static string ArrayOfJson(string? value) => throw new NotSupportedException(); +} diff --git a/src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs b/src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs new file mode 100644 index 000000000..34f107128 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/PostgreSqlStorageConventions.cs @@ -0,0 +1,23 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FluentCMS.Data.PostgreSql; + +public sealed class PostgreSqlStorageConventions : StorageConventions +{ + // Npgsql writes a DateTimeOffset to timestamptz only with offset 0. + public override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.Properties().HaveConversion(); + + public override void ConfigureJsonColumn(EntityTypeBuilder entity, PropertyBuilder column) => + column.HasColumnType("jsonb"); + + public override void ConfigureJsonIndex(EntityTypeBuilder entity, string column) => + entity.HasIndex(column).HasMethod("gin").HasDatabaseName($"gin_{entity.Metadata.GetTableName()}_{column}"); + + private sealed class UtcConverter() : ValueConverter( + value => value.ToUniversalTime(), + value => value); +} diff --git a/src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs b/src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs new file mode 100644 index 000000000..24ac0be44 --- /dev/null +++ b/src/FluentCMS.Data.PostgreSql/SystemDbContextFactory.cs @@ -0,0 +1,18 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace FluentCMS.Data.PostgreSql; + +// Used only by `dotnet ef migrations add`, which never opens this database. See docs/system-migrations.md. +public sealed class SystemDbContextFactory : IDesignTimeDbContextFactory +{ + public SystemDbContext CreateDbContext(string[] args) + { + var provider = new PostgreSqlDataProvider("Host=localhost"); + var options = new DbContextOptionsBuilder(); + provider.ConfigureSystemMigrations(options); + options.UseSystemModel(provider.Conventions); + return new SystemDbContext(options.Options); + } +} diff --git a/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj b/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj index daaac7f5b..94ad9cab1 100644 --- a/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj +++ b/src/FluentCMS.Data.SqlServer/FluentCMS.Data.SqlServer.csproj @@ -1,5 +1,22 @@ + + Experimental: the SQL Server data provider for FluentCMS, not yet validated against a database. Storage conventions, JSON filters, job claiming and the system migrations. + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs new file mode 100644 index 000000000..48eace499 --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.Designer.cs @@ -0,0 +1,245 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Data.SqlServer.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20261102151146_InitialSystem")] + partial class InitialSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("nvarchar(max)") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("int") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", null, t => + { + t.HasCheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("int") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("int") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("datetimeoffset") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("datetimeoffset") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("int") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs new file mode 100644 index 000000000..c5892f775 --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/Migrations/20261102151146_InitialSystem.cs @@ -0,0 +1,121 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Data.SqlServer.Migrations +{ + /// + public partial class InitialSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cms_document", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + document_id = table.Column(type: "uniqueidentifier", nullable: false), + locale = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + site_id = table.Column(type: "uniqueidentifier", nullable: true), + status = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + draft_version = table.Column(type: "int", nullable: false), + published_version = table.Column(type: "int", nullable: true), + published_at = table.Column(type: "datetimeoffset", nullable: true), + first_published_at = table.Column(type: "datetimeoffset", nullable: true), + deleted_at = table.Column(type: "datetimeoffset", nullable: true), + deleted_by = table.Column(type: "uniqueidentifier", nullable: true), + created_at = table.Column(type: "datetimeoffset", nullable: false), + created_by = table.Column(type: "uniqueidentifier", nullable: false), + updated_at = table.Column(type: "datetimeoffset", nullable: false), + updated_by = table.Column(type: "uniqueidentifier", nullable: false), + schedule_publish_at = table.Column(type: "datetimeoffset", nullable: true), + schedule_unpublish_at = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_document", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "cms_reference", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + source_type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + source_document_id = table.Column(type: "uniqueidentifier", nullable: false), + state = table.Column(type: "smallint", nullable: false), + locale = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + field = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + position = table.Column(type: "int", nullable: false), + target_type = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + target_id = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_reference", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "cms_version", + columns: table => new + { + id = table.Column(type: "uniqueidentifier", nullable: false), + document_id = table.Column(type: "uniqueidentifier", nullable: false), + locale = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + number = table.Column(type: "int", nullable: false), + snapshot = table.Column(type: "nvarchar(max)", nullable: false), + status_after = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + author_id = table.Column(type: "uniqueidentifier", nullable: false), + origin = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + client_id = table.Column(type: "nvarchar(max)", nullable: true), + message = table.Column(type: "nvarchar(max)", nullable: true), + created_at = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_version", x => x.id); + table.CheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1"); + }); + + migrationBuilder.CreateIndex( + name: "uq_cms_document_document_locale", + table: "cms_document", + columns: new[] { "document_id", "locale" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_cms_reference_target", + table: "cms_reference", + columns: new[] { "target_type", "target_id" }); + + migrationBuilder.CreateIndex( + name: "uq_cms_reference_source", + table: "cms_reference", + columns: new[] { "source_type", "source_document_id", "state", "locale", "field", "position" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "uq_cms_version_document_locale_number", + table: "cms_version", + columns: new[] { "document_id", "locale", "number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cms_document"); + + migrationBuilder.DropTable( + name: "cms_reference"); + + migrationBuilder.DropTable( + name: "cms_version"); + } + } +} diff --git a/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs new file mode 100644 index 000000000..2cdcea3ea --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/Migrations/SystemDbContextModelSnapshot.cs @@ -0,0 +1,242 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Data.SqlServer.Migrations +{ + [DbContext(typeof(SystemDbContext))] + partial class SystemDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("nvarchar(max)") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("int") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", null, t => + { + t.HasCheckConstraint("ck_cms_version_snapshot_json", "ISJSON([snapshot]) = 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("int") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("int") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("datetimeoffset") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("datetimeoffset") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("uniqueidentifier") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("int") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("smallint") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("uniqueidentifier") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.SqlServer/SqlServerBuilderExtensions.cs b/src/FluentCMS.Data.SqlServer/SqlServerBuilderExtensions.cs new file mode 100644 index 000000000..7f2561656 --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SqlServerBuilderExtensions.cs @@ -0,0 +1,15 @@ +using FluentCMS.Core.DependencyInjection; + +namespace FluentCMS.Data.SqlServer; + +public static class SqlServerBuilderExtensions +{ + // nativeJson stores JSON in the json type of SQL Server 2025, instead of nvarchar(max). + public static FluentCmsBuilder UseSqlServer(this FluentCmsBuilder cms, string? connectionString, bool nativeJson = false) + { + ArgumentNullException.ThrowIfNull(cms); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + return cms.UseDataProvider(new SqlServerDataProvider(connectionString, nativeJson)); + } +} diff --git a/src/FluentCMS.Data.SqlServer/SqlServerDataProvider.cs b/src/FluentCMS.Data.SqlServer/SqlServerDataProvider.cs new file mode 100644 index 000000000..7ae39bda7 --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SqlServerDataProvider.cs @@ -0,0 +1,24 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Data.SqlServer; + +public sealed class SqlServerDataProvider(string connectionString, bool nativeJson = false) : IDataProvider +{ + public string Name => "SQL Server"; + + public bool MigrateOnStartup => false; + + public IStorageConventions Conventions { get; } = new SqlServerStorageConventions(nativeJson); + + public IJsonSqlDialect JsonDialect { get; } = new SqlServerJsonDialect(); + + public IJobClaimDialect JobClaimDialect { get; } = new SqlServerJobClaimDialect(); + + public void ConfigureDbContext(DbContextOptionsBuilder options) => options.UseSqlServer(connectionString); + + public void ConfigureSystemMigrations(DbContextOptionsBuilder options) => + options.UseSqlServer(connectionString, sqlServer => sqlServer + .MigrationsAssembly(typeof(SqlServerDataProvider).Assembly) + .MigrationsHistoryTable(SystemDbContext.HistoryTable)); +} diff --git a/src/FluentCMS.Data.SqlServer/SqlServerJobClaimDialect.cs b/src/FluentCMS.Data.SqlServer/SqlServerJobClaimDialect.cs new file mode 100644 index 000000000..9bd1efbfd --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SqlServerJobClaimDialect.cs @@ -0,0 +1,32 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace FluentCMS.Data.SqlServer; + +// Not yet run on a database (#2711). +public sealed class SqlServerJobClaimDialect : IJobClaimDialect +{ + public async Task ClaimAsync(DatabaseFacade database, JobClaim claim, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(claim); + + var ids = await database.SqlQuery($""" + WITH next_job AS ( + SELECT TOP (1) status, lease_owner, lease_until, attempts, id + FROM cms_job WITH (UPDLOCK, READPAST, ROWLOCK) + WHERE queue = {claim.Queue} + AND status IN ({JobClaimStatus.Pending}, {JobClaimStatus.Running}) + AND run_at <= {claim.Now} + AND attempts < max_attempts + AND (lease_until IS NULL OR lease_until < {claim.Now}) + ORDER BY priority DESC, run_at, id) + UPDATE next_job + SET status = {JobClaimStatus.Running}, lease_owner = {claim.Owner}, lease_until = {claim.LeaseUntil}, attempts = attempts + 1 + OUTPUT inserted.id AS [Value]; + """).ToListAsync(cancellationToken); + + return ids.Count == 0 ? null : ids[0]; + } +} diff --git a/src/FluentCMS.Data.SqlServer/SqlServerJsonDialect.cs b/src/FluentCMS.Data.SqlServer/SqlServerJsonDialect.cs new file mode 100644 index 000000000..a8818d4fc --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SqlServerJsonDialect.cs @@ -0,0 +1,122 @@ +using System.Linq.Expressions; +using System.Reflection; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; + +namespace FluentCMS.Data.SqlServer; + +// JSON_VALUE returns every scalar as text, so a number would equal the text '12'. OPENJSON exposes the JSON type of +// each key instead, and works on nvarchar(max) and on the native json type alike. Text compares under a binary +// collation, because the other providers are case-sensitive and a database's default usually is not. +// Not yet run on a database (#2711). +public sealed class SqlServerJsonDialect : IJsonSqlDialect +{ + private const string BlockTypeField = "type"; + private const string BinaryCollation = "Latin1_General_100_BIN2"; + + // The values of the type column of OPENJSON. + private const int JsonString = 1; + private const int JsonNumber = 2; + private const int JsonBoolean = 3; + private const int JsonArray = 4; + + private const string DecimalStoreType = "decimal(38,18)"; + + private static readonly MethodInfo OpenJson = Function(nameof(SqlServerJsonFunctions.OpenJson)); + private static readonly MethodInfo TryBigint = Function(nameof(SqlServerJsonFunctions.TryBigint)); + private static readonly MethodInfo TryDecimal = Function(nameof(SqlServerJsonFunctions.TryDecimal)); + private static readonly MethodInfo Collate = typeof(RelationalDbFunctionsExtensions).GetMethod(nameof(RelationalDbFunctionsExtensions.Collate))!.MakeGenericMethod(typeof(string)); + private static readonly MethodInfo StringContains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; + private static readonly MethodInfo AnyEntry = typeof(Queryable).GetMethods() + .Single(method => method.Name == nameof(Queryable.Any) && method.GetParameters().Length == 2) + .MakeGenericMethod(typeof(SqlServerJsonEntry)); + + public void ConfigureFunctions(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasNoKey().ToTable((string?)null); + modelBuilder.HasDbFunction(OpenJson).HasName("OPENJSON").IsBuiltIn(); + + // TRY_CONVERT takes a type keyword as its first argument, which a plain mapping cannot express. + modelBuilder.HasDbFunction(TryBigint).HasName("TRY_CONVERT").IsBuiltIn() + .HasTranslation(arguments => TryConvert("bigint", arguments[0], typeof(long), new LongTypeMapping("bigint"))); + modelBuilder.HasDbFunction(TryDecimal).HasName("TRY_CONVERT").IsBuiltIn() + .HasTranslation(arguments => TryConvert(DecimalStoreType, arguments[0], typeof(decimal), new DecimalTypeMapping(DecimalStoreType))); + } + + public Expression HasBlock(Expression json, Expression blockType) => + Any(Expression.Call(OpenJson, json), block => BlockTypeIs(Expression.Property(block, nameof(SqlServerJsonEntry.Value)), blockType)); + + public Expression AnyBlock(Expression json, Expression blockType, Func predicate) => + Any(Expression.Call(OpenJson, json), entry => + { + var block = Expression.Property(entry, nameof(SqlServerJsonEntry.Value)); + return Expression.AndAlso(BlockTypeIs(block, blockType), predicate(block)); + }); + + public Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value) + { + JsonScalar.CheckValue(type, value); + + return AtKey(json, field, type switch + { + JsonScalarType.Text => JsonString, + JsonScalarType.Boolean => JsonBoolean, + _ => JsonNumber, + }, text => type switch + { + JsonScalarType.Text => Expression.Equal(Binary(text), value), + JsonScalarType.WholeNumber => Expression.Equal(Expression.Call(TryBigint, text), Expression.Convert(value, typeof(long?))), + JsonScalarType.DecimalNumber => Expression.Equal(Expression.Call(TryDecimal, text), Expression.Convert(value, typeof(decimal?))), + JsonScalarType.Boolean => Expression.Equal(text, Expression.Condition(value, Expression.Constant("true"), Expression.Constant("false"))), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null), + }); + } + + public Expression ScalarContains(Expression json, string field, Expression value) + { + JsonScalar.CheckValue(JsonScalarType.Text, value); + + return AtKey(json, field, JsonString, text => Expression.Call(Binary(text), StringContains, value)); + } + + public Expression ArrayContains(Expression json, string field, Expression value) + { + JsonScalar.CheckValue(JsonScalarType.Text, value); + + return AtKey(json, field, JsonArray, array => Any(Expression.Call(OpenJson, array), element => Expression.AndAlso( + Expression.Equal(Expression.Property(element, nameof(SqlServerJsonEntry.Type)), Expression.Constant(JsonString)), + Expression.Equal(Binary(Expression.Property(element, nameof(SqlServerJsonEntry.Value))), value)))); + } + + private Expression BlockTypeIs(Expression block, Expression blockType) => + ScalarEquals(block, BlockTypeField, JsonScalarType.Text, blockType); + + // A key of the object holds this JSON type and its value, as OPENJSON gives it, satisfies the predicate. + private static MethodCallExpression AtKey(Expression json, string field, int jsonType, Func predicate) => + Any(Expression.Call(OpenJson, json), entry => Expression.AndAlso( + Expression.AndAlso( + Expression.Equal(Expression.Property(entry, nameof(SqlServerJsonEntry.Key)), Expression.Constant(JsonScalar.CheckField(field))), + Expression.Equal(Expression.Property(entry, nameof(SqlServerJsonEntry.Type)), Expression.Constant(jsonType))), + predicate(Expression.Property(entry, nameof(SqlServerJsonEntry.Value))))); + + private static MethodCallExpression Any(Expression entries, Func predicate) + { + var entry = Expression.Parameter(typeof(SqlServerJsonEntry), "entry"); + return Expression.Call(AnyEntry, entries, Expression.Quote(Expression.Lambda>(predicate(entry), entry))); + } + + private static MethodCallExpression Binary(Expression text) => + Expression.Call(Collate, Expression.Property(null, typeof(EF), nameof(EF.Functions)), text, Expression.Constant(BinaryCollation)); + + private static SqlFunctionExpression TryConvert(string storeType, SqlExpression value, Type type, RelationalTypeMapping typeMapping) => new( + "TRY_CONVERT", + [new SqlFragmentExpression(storeType), value], + nullable: true, + argumentsPropagateNullability: [false, true], + type, + typeMapping); + + private static MethodInfo Function(string name) => typeof(SqlServerJsonFunctions).GetMethod(name)!; +} diff --git a/src/FluentCMS.Data.SqlServer/SqlServerJsonFunctions.cs b/src/FluentCMS.Data.SqlServer/SqlServerJsonFunctions.cs new file mode 100644 index 000000000..bf452fda4 --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SqlServerJsonFunctions.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace FluentCMS.Data.SqlServer; + +// A row of OPENJSON without a WITH clause. +public sealed class SqlServerJsonEntry +{ + [Column("key")] + public string? Key { get; set; } + + [Column("value")] + public string? Value { get; set; } + + [Column("type")] + public int Type { get; set; } +} + +// EF function mappings; SqlServerJsonDialect declares them on the model. They run only as SQL. +public static class SqlServerJsonFunctions +{ + public static IQueryable OpenJson(string? json) => throw new NotSupportedException(); + + public static long? TryBigint(string? value) => throw new NotSupportedException(); + + public static decimal? TryDecimal(string? value) => throw new NotSupportedException(); +} diff --git a/src/FluentCMS.Data.SqlServer/SqlServerStorageConventions.cs b/src/FluentCMS.Data.SqlServer/SqlServerStorageConventions.cs new file mode 100644 index 000000000..69d94210b --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SqlServerStorageConventions.cs @@ -0,0 +1,25 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Data.SqlServer; + +// JSON is the native json type on SQL Server 2025 and checked nvarchar(max) text below it. +public sealed class SqlServerStorageConventions(bool nativeJson = false) : StorageConventions +{ + public override string CacheKey => $"{base.CacheKey}|{nativeJson}"; + + public override void ConfigureJsonColumn(EntityTypeBuilder entity, PropertyBuilder column) + { + if (nativeJson) + { + column.HasColumnType("json"); + return; + } + + column.HasColumnType("nvarchar(max)"); + + var name = column.Metadata.GetColumnName(); + entity.ToTable(table => table.HasCheckConstraint($"ck_{entity.Metadata.GetTableName()}_{name}_json", $"ISJSON([{name}]) = 1")); + } +} diff --git a/src/FluentCMS.Data.SqlServer/SystemDbContextFactory.cs b/src/FluentCMS.Data.SqlServer/SystemDbContextFactory.cs new file mode 100644 index 000000000..7924273dd --- /dev/null +++ b/src/FluentCMS.Data.SqlServer/SystemDbContextFactory.cs @@ -0,0 +1,18 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace FluentCMS.Data.SqlServer; + +// Used only by `dotnet ef migrations add`, which never opens this database. See docs/system-migrations.md. +public sealed class SystemDbContextFactory : IDesignTimeDbContextFactory +{ + public SystemDbContext CreateDbContext(string[] args) + { + var provider = new SqlServerDataProvider("Server=localhost"); + var options = new DbContextOptionsBuilder(); + provider.ConfigureSystemMigrations(options); + options.UseSystemModel(provider.Conventions); + return new SystemDbContext(options.Options); + } +} diff --git a/src/FluentCMS.Data.Sqlite/FluentCMS.Data.Sqlite.csproj b/src/FluentCMS.Data.Sqlite/FluentCMS.Data.Sqlite.csproj index daaac7f5b..8e047bf72 100644 --- a/src/FluentCMS.Data.Sqlite/FluentCMS.Data.Sqlite.csproj +++ b/src/FluentCMS.Data.Sqlite/FluentCMS.Data.Sqlite.csproj @@ -1,5 +1,17 @@ + + The SQLite data provider for FluentCMS: storage conventions, JSON filters, job claiming and the system migrations. + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.Designer.cs b/src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.Designer.cs new file mode 100644 index 000000000..fcdfc6638 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.Designer.cs @@ -0,0 +1,240 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Data.Sqlite.Migrations +{ + [DbContext(typeof(SystemDbContext))] + [Migration("20261102151133_InitialSystem")] + partial class InitialSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("TEXT") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("TEXT") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("TEXT") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", null, t => + { + t.HasCheckConstraint("ck_cms_version_snapshot_json", "json_valid(\"snapshot\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("INTEGER") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("TEXT") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("TEXT") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("INTEGER") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("INTEGER") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("INTEGER") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("INTEGER") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("INTEGER") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("INTEGER") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("TEXT") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("INTEGER") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("TEXT") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.cs b/src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.cs new file mode 100644 index 000000000..09b7cd66b --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/Migrations/20261102151133_InitialSystem.cs @@ -0,0 +1,121 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Data.Sqlite.Migrations +{ + /// + public partial class InitialSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cms_document", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + document_id = table.Column(type: "TEXT", nullable: false), + locale = table.Column(type: "TEXT", maxLength: 16, nullable: false), + type = table.Column(type: "TEXT", maxLength: 64, nullable: false), + site_id = table.Column(type: "TEXT", nullable: true), + status = table.Column(type: "TEXT", maxLength: 16, nullable: false), + draft_version = table.Column(type: "INTEGER", nullable: false), + published_version = table.Column(type: "INTEGER", nullable: true), + published_at = table.Column(type: "INTEGER", nullable: true), + first_published_at = table.Column(type: "INTEGER", nullable: true), + deleted_at = table.Column(type: "INTEGER", nullable: true), + deleted_by = table.Column(type: "TEXT", nullable: true), + created_at = table.Column(type: "INTEGER", nullable: false), + created_by = table.Column(type: "TEXT", nullable: false), + updated_at = table.Column(type: "INTEGER", nullable: false), + updated_by = table.Column(type: "TEXT", nullable: false), + schedule_publish_at = table.Column(type: "INTEGER", nullable: true), + schedule_unpublish_at = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_document", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "cms_reference", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + source_type = table.Column(type: "TEXT", maxLength: 64, nullable: false), + source_document_id = table.Column(type: "TEXT", nullable: false), + state = table.Column(type: "INTEGER", nullable: false), + locale = table.Column(type: "TEXT", maxLength: 16, nullable: false), + field = table.Column(type: "TEXT", maxLength: 64, nullable: false), + position = table.Column(type: "INTEGER", nullable: false), + target_type = table.Column(type: "TEXT", maxLength: 64, nullable: false), + target_id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_reference", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "cms_version", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + document_id = table.Column(type: "TEXT", nullable: false), + locale = table.Column(type: "TEXT", maxLength: 16, nullable: false), + number = table.Column(type: "INTEGER", nullable: false), + snapshot = table.Column(type: "TEXT", nullable: false), + status_after = table.Column(type: "TEXT", maxLength: 16, nullable: false), + author_id = table.Column(type: "TEXT", nullable: false), + origin = table.Column(type: "TEXT", maxLength: 16, nullable: false), + client_id = table.Column(type: "TEXT", nullable: true), + message = table.Column(type: "TEXT", nullable: true), + created_at = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cms_version", x => x.id); + table.CheckConstraint("ck_cms_version_snapshot_json", "json_valid(\"snapshot\")"); + }); + + migrationBuilder.CreateIndex( + name: "uq_cms_document_document_locale", + table: "cms_document", + columns: new[] { "document_id", "locale" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_cms_reference_target", + table: "cms_reference", + columns: new[] { "target_type", "target_id" }); + + migrationBuilder.CreateIndex( + name: "uq_cms_reference_source", + table: "cms_reference", + columns: new[] { "source_type", "source_document_id", "state", "locale", "field", "position" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "uq_cms_version_document_locale_number", + table: "cms_version", + columns: new[] { "document_id", "locale", "number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cms_document"); + + migrationBuilder.DropTable( + name: "cms_reference"); + + migrationBuilder.DropTable( + name: "cms_version"); + } + } +} diff --git a/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs b/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs new file mode 100644 index 000000000..e7cfb33b8 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/Migrations/SystemDbContextModelSnapshot.cs @@ -0,0 +1,237 @@ +// +using System; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Data.Sqlite.Migrations +{ + [DbContext(typeof(SystemDbContext))] + partial class SystemDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.14"); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.ContentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("TEXT") + .HasColumnName("author_id"); + + b.Property("ClientId") + .HasColumnType("TEXT") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("DocumentId") + .HasColumnType("TEXT") + .HasColumnName("document_id"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("Message") + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("origin"); + + b.Property("Snapshot") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("snapshot"); + + b.Property("StatusAfter") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status_after"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale", "Number") + .IsUnique() + .HasDatabaseName("uq_cms_version_document_locale_number"); + + b.ToTable("cms_version", null, t => + { + t.HasCheckConstraint("ck_cms_version_snapshot_json", "json_valid(\"snapshot\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("INTEGER") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("TEXT") + .HasColumnName("deleted_by"); + + b.Property("DocumentId") + .HasColumnType("TEXT") + .HasColumnName("document_id"); + + b.Property("DraftVersion") + .HasColumnType("INTEGER") + .HasColumnName("draft_version"); + + b.Property("FirstPublishedAt") + .HasColumnType("INTEGER") + .HasColumnName("first_published_at"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("PublishedAt") + .HasColumnType("INTEGER") + .HasColumnName("published_at"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER") + .HasColumnName("published_version"); + + b.Property("SchedulePublishAt") + .HasColumnType("INTEGER") + .HasColumnName("schedule_publish_at"); + + b.Property("ScheduleUnpublishAt") + .HasColumnType("INTEGER") + .HasColumnName("schedule_unpublish_at"); + + b.Property("SiteId") + .HasColumnType("TEXT") + .HasColumnName("site_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "Locale") + .IsUnique() + .HasDatabaseName("uq_cms_document_document_locale"); + + b.ToTable("cms_document", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Core.Storage.Entities.Reference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("field"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("locale"); + + b.Property("Position") + .HasColumnType("INTEGER") + .HasColumnName("position"); + + b.Property("SourceDocumentId") + .HasColumnType("TEXT") + .HasColumnName("source_document_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("source_type"); + + b.Property("State") + .HasColumnType("INTEGER") + .HasColumnName("state"); + + b.Property("TargetId") + .HasColumnType("TEXT") + .HasColumnName("target_id"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId") + .HasDatabaseName("ix_cms_reference_target"); + + b.HasIndex("SourceType", "SourceDocumentId", "State", "Locale", "Field", "Position") + .IsUnique() + .HasDatabaseName("uq_cms_reference_source"); + + b.ToTable("cms_reference", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Data.Sqlite/SqliteBuilderExtensions.cs b/src/FluentCMS.Data.Sqlite/SqliteBuilderExtensions.cs new file mode 100644 index 000000000..3371d6183 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SqliteBuilderExtensions.cs @@ -0,0 +1,14 @@ +using FluentCMS.Core.DependencyInjection; + +namespace FluentCMS.Data.Sqlite; + +public static class SqliteBuilderExtensions +{ + public static FluentCmsBuilder UseSqlite(this FluentCmsBuilder cms, string? connectionString) + { + ArgumentNullException.ThrowIfNull(cms); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + return cms.UseDataProvider(new SqliteDataProvider(connectionString)); + } +} diff --git a/src/FluentCMS.Data.Sqlite/SqliteDataProvider.cs b/src/FluentCMS.Data.Sqlite/SqliteDataProvider.cs new file mode 100644 index 000000000..d3ff2d20c --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SqliteDataProvider.cs @@ -0,0 +1,24 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Data.Sqlite; + +public sealed class SqliteDataProvider(string connectionString) : IDataProvider +{ + public string Name => "SQLite"; + + public bool MigrateOnStartup => true; + + public IStorageConventions Conventions { get; } = new SqliteStorageConventions(); + + public IJsonSqlDialect JsonDialect { get; } = new SqliteJsonDialect(); + + public IJobClaimDialect JobClaimDialect { get; } = new SqliteJobClaimDialect(); + + public void ConfigureDbContext(DbContextOptionsBuilder options) => options.UseSqlite(connectionString); + + public void ConfigureSystemMigrations(DbContextOptionsBuilder options) => + options.UseSqlite(connectionString, sqlite => sqlite + .MigrationsAssembly(typeof(SqliteDataProvider).Assembly) + .MigrationsHistoryTable(SystemDbContext.HistoryTable)); +} diff --git a/src/FluentCMS.Data.Sqlite/SqliteJobClaimDialect.cs b/src/FluentCMS.Data.Sqlite/SqliteJobClaimDialect.cs new file mode 100644 index 000000000..c44a98a0e --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SqliteJobClaimDialect.cs @@ -0,0 +1,51 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace FluentCMS.Data.Sqlite; + +// Optimistic: read the next job, then update it only if its lease is still free. A worker that loses the race +// updates nothing and reads again. The two steps are separate because UPDATE ... RETURNING commits on a later step +// than the one Microsoft.Data.Sqlite retries while the database is busy, so under load it throws 'database is +// locked'. Dates are UTC ticks, as SqliteStorageConventions stores them. +public sealed class SqliteJobClaimDialect : IJobClaimDialect +{ + public async Task ClaimAsync(DatabaseFacade database, JobClaim claim, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(claim); + + var now = claim.Now.UtcTicks; + + while (true) + { + var candidates = await database.SqlQuery($""" + SELECT id AS "Value" + FROM cms_job + WHERE queue = {claim.Queue} + AND status IN ({JobClaimStatus.Pending}, {JobClaimStatus.Running}) + AND run_at <= {now} + AND attempts < max_attempts + AND (lease_until IS NULL OR lease_until < {now}) + ORDER BY priority DESC, run_at, id + LIMIT 1 + """).ToListAsync(cancellationToken); + + if (candidates.Count == 0) + { + return null; + } + + var claimed = await database.ExecuteSqlAsync($""" + UPDATE cms_job + SET status = {JobClaimStatus.Running}, lease_owner = {claim.Owner}, lease_until = {claim.LeaseUntil.UtcTicks}, attempts = attempts + 1 + WHERE id = {candidates[0]} AND (lease_until IS NULL OR lease_until < {now}) + """, cancellationToken); + + if (claimed == 1) + { + return candidates[0]; + } + } + } +} diff --git a/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs b/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs new file mode 100644 index 000000000..c48c44da1 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs @@ -0,0 +1,97 @@ +using System.Linq.Expressions; +using System.Reflection; +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Data.Sqlite; + +public sealed class SqliteJsonDialect : IJsonSqlDialect +{ + private const string BlockTypeField = "type"; + + private static readonly MethodInfo Each = Function(nameof(SqliteJsonFunctions.Each)); + private static readonly MethodInfo EachAt = Function(nameof(SqliteJsonFunctions.EachAt)); + private static readonly MethodInfo ExtractText = Function(nameof(SqliteJsonFunctions.ExtractText)); + private static readonly MethodInfo ExtractInteger = Function(nameof(SqliteJsonFunctions.ExtractInteger)); + private static readonly MethodInfo ExtractReal = Function(nameof(SqliteJsonFunctions.ExtractReal)); + private static readonly MethodInfo TypeAt = Function(nameof(SqliteJsonFunctions.TypeAt)); + private static readonly MethodInfo StringContains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; + private static readonly MethodInfo AnyEntry = typeof(Queryable).GetMethods() + .Single(method => method.Name == nameof(Queryable.Any) && method.GetParameters().Length == 2); + + public void ConfigureFunctions(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasNoKey().ToTable((string?)null); + modelBuilder.Entity().HasNoKey().ToTable((string?)null); + modelBuilder.HasDbFunction(Each).HasName("json_each").IsBuiltIn(); + modelBuilder.HasDbFunction(EachAt).HasName("json_each").IsBuiltIn(); + modelBuilder.HasDbFunction(ExtractText).HasName("json_extract").IsBuiltIn(); + modelBuilder.HasDbFunction(ExtractInteger).HasName("json_extract").IsBuiltIn(); + modelBuilder.HasDbFunction(ExtractReal).HasName("json_extract").IsBuiltIn(); + modelBuilder.HasDbFunction(TypeAt).HasName("json_type").IsBuiltIn(); + } + + public Expression HasBlock(Expression json, Expression blockType) => + Any(Expression.Call(Each, json), block => BlockTypeIs(block, blockType)); + + public Expression AnyBlock(Expression json, Expression blockType, Func predicate) => + Any(Expression.Call(Each, json), block => Expression.AndAlso(BlockTypeIs(block, blockType), predicate(block))); + + public Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value) + { + JsonScalar.CheckValue(type, value); + var path = Path(field); + + return type switch + { + JsonScalarType.Text => Expression.Equal(Expression.Call(ExtractText, json, path), value), + JsonScalarType.WholeNumber => Expression.Equal(Expression.Call(ExtractInteger, json, path), Expression.Convert(value, typeof(long?))), + + // A JSON number is INTEGER or REAL, while EF binds a decimal as text, which never equals a number. + JsonScalarType.DecimalNumber => Expression.Equal(Expression.Call(ExtractReal, json, path), Expression.Convert(value, typeof(double?))), + + // json_extract gives 1 for true, which would also equal the number 1. + JsonScalarType.Boolean => Expression.Equal( + Expression.Call(TypeAt, json, path), + Expression.Condition(value, Expression.Constant("true"), Expression.Constant("false"))), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null), + }; + } + + public Expression ScalarContains(Expression json, string field, Expression value) + { + JsonScalar.CheckValue(JsonScalarType.Text, value); + var path = Path(field); + + return Expression.AndAlso( + IsOfType(json, path, "text"), + Expression.Call(Expression.Call(ExtractText, json, path), StringContains, value)); + } + + public Expression ArrayContains(Expression json, string field, Expression value) + { + JsonScalar.CheckValue(JsonScalarType.Text, value); + var path = Path(field); + + return Expression.AndAlso( + IsOfType(json, path, "array"), + Any(Expression.Call(EachAt, json, path), element => Expression.Equal(element, value))); + } + + private BinaryExpression BlockTypeIs(Expression block, Expression blockType) => + (BinaryExpression)ScalarEquals(block, BlockTypeField, JsonScalarType.Text, blockType); + + private static BinaryExpression IsOfType(Expression json, ConstantExpression path, string type) => + Expression.Equal(Expression.Call(TypeAt, json, path), Expression.Constant(type)); + + private static MethodCallExpression Any(Expression entries, Func predicate) + { + var entry = Expression.Parameter(typeof(TEntry), "entry"); + var body = predicate(Expression.Property(entry, nameof(SqliteJsonEntry.Value))); + return Expression.Call(AnyEntry.MakeGenericMethod(typeof(TEntry)), entries, Expression.Quote(Expression.Lambda>(body, entry))); + } + + private static ConstantExpression Path(string field) => Expression.Constant($"$.{JsonScalar.CheckField(field)}"); + + private static MethodInfo Function(string name) => typeof(SqliteJsonFunctions).GetMethod(name)!; +} diff --git a/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs b/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs new file mode 100644 index 000000000..63fa567d3 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace FluentCMS.Data.Sqlite; + +// A row of json_each. +public sealed class SqliteJsonEntry +{ + [Column("value")] + public string? Value { get; set; } +} + +// A row of json_each with a path. EF cannot map one row type to both forms of the function. +public sealed class SqliteJsonPathEntry +{ + [Column("value")] + public string? Value { get; set; } +} + +// EF function mappings; SqliteJsonDialect declares them on the model. They run only as SQL. +public static class SqliteJsonFunctions +{ + public static IQueryable Each(string? json) => throw new NotSupportedException(); + + public static IQueryable EachAt(string? json, string path) => throw new NotSupportedException(); + + public static string? ExtractText(string? json, string path) => throw new NotSupportedException(); + + public static long? ExtractInteger(string? json, string path) => throw new NotSupportedException(); + + public static double? ExtractReal(string? json, string path) => throw new NotSupportedException(); + + public static string? TypeAt(string? json, string path) => throw new NotSupportedException(); +} diff --git a/src/FluentCMS.Data.Sqlite/SqliteStorageConventions.cs b/src/FluentCMS.Data.Sqlite/SqliteStorageConventions.cs new file mode 100644 index 000000000..ced654285 --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SqliteStorageConventions.cs @@ -0,0 +1,24 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FluentCMS.Data.Sqlite; + +public sealed class SqliteStorageConventions : StorageConventions +{ + // SQLite compares DateTimeOffset only for equality, so every date is UTC ticks, which also drops the offset. + public override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.Properties().HaveConversion(); + + // One row of malformed JSON makes every JSON filter over the table throw, so it must not get in. + public override void ConfigureJsonColumn(EntityTypeBuilder entity, PropertyBuilder column) + { + var name = column.Metadata.GetColumnName(); + entity.ToTable(table => table.HasCheckConstraint($"ck_{entity.Metadata.GetTableName()}_{name}_json", $"json_valid(\"{name}\")")); + } + + private sealed class UtcTicksConverter() : ValueConverter( + value => value.UtcTicks, + value => new DateTimeOffset(value, TimeSpan.Zero)); +} diff --git a/src/FluentCMS.Data.Sqlite/SystemDbContextFactory.cs b/src/FluentCMS.Data.Sqlite/SystemDbContextFactory.cs new file mode 100644 index 000000000..58e52fbeb --- /dev/null +++ b/src/FluentCMS.Data.Sqlite/SystemDbContextFactory.cs @@ -0,0 +1,18 @@ +using FluentCMS.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace FluentCMS.Data.Sqlite; + +// Used only by `dotnet ef migrations add`, which never opens this database. See docs/system-migrations.md. +public sealed class SystemDbContextFactory : IDesignTimeDbContextFactory +{ + public SystemDbContext CreateDbContext(string[] args) + { + var provider = new SqliteDataProvider("Data Source=:memory:"); + var options = new DbContextOptionsBuilder(); + provider.ConfigureSystemMigrations(options); + options.UseSystemModel(provider.Conventions); + return new SystemDbContext(options.Options); + } +} diff --git a/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs b/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs new file mode 100644 index 000000000..50ac01156 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Providers/OfflineProviderTests.cs @@ -0,0 +1,205 @@ +using System.Linq.Expressions; +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.PostgreSql; +using FluentCMS.Data.SqlServer; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Sqlite.RowFilter; + +namespace FluentCMS.Data.Tests.Providers; + +// PostgreSQL and SQL Server run on a database only in #2711. These tests never open a connection: they check +// the model and the SQL text that EF produces, not how a server behaves. +public class OfflineProviderTests +{ + private static readonly SchemaModel Schema = SampleSchema.NewBuilder() + .AddBlock("hero", block => block.Field("heading", "text")) + .AddCollection("post", type => type + .Field("publishedOn", "dateTime") + .Field("sections", "blocks", field => field.Setting(FieldSettings.Blocks, new[] { "hero" })) + .Field("metadata", "json", field => field.Indexed())) + .Build(); + + private static ServiceProvider PostgreSql() => Host(cms => cms.UsePostgreSql("Host=localhost;Database=never_opened")); + + private static ServiceProvider SqlServer(bool nativeJson = false) => Host(cms => cms.UseSqlServer("Server=localhost;Database=never_opened", nativeJson)); + + private static ServiceProvider Host(Action provider) => + new ServiceCollection().AddSingleton(Schema).AddFluentCms(provider).BuildServiceProvider(); + + private static IModel DesignModel(IServiceProvider services) => + services.GetRequiredService().GetService().Model; + + private static string? ColumnType(IModel model, string entity, string property) => + model.FindEntityType(entity)!.FindProperty(property)!.GetColumnType(); + + private static string Sql(IServiceProvider services, Func filter) + { + var dialect = services.GetRequiredService(); + return services.GetRequiredService().Rows("cms_post").Where(Where(row => filter(dialect, row))).ToQueryString(); + } + + private static MethodCallExpression Sections(ParameterExpression row) => Column(row, "sections"); + + private static MethodCallExpression Metadata(ParameterExpression row) => Column(row, "metadata"); + + [Fact] + public void PostgreSqlStoresJsonAsJsonbAndDatesAsTimestamptz() + { + using var services = PostgreSql(); + using var scope = services.CreateScope(); + var model = DesignModel(scope.ServiceProvider); + + ColumnType(model, "cms_post", "metadata").ShouldBe("jsonb"); + ColumnType(model, "cms_post", "sections").ShouldBe("jsonb"); + ColumnType(model, "cms_post", "published_on").ShouldBe("timestamp with time zone"); + ColumnType(model, typeof(ContentVersion).FullName!, nameof(ContentVersion.Snapshot)).ShouldBe("jsonb"); + ColumnType(model, typeof(Document).FullName!, nameof(Document.SchedulePublishAt)).ShouldBe("timestamp with time zone"); + } + + [Fact] + public void PostgreSqlPutsAGinIndexOnAnIndexedJsonField() + { + using var services = PostgreSql(); + using var scope = services.CreateScope(); + + var index = DesignModel(scope.ServiceProvider).FindEntityType("cms_post")!.GetIndexes() + .Single(index => index.Properties.Any(property => property.Name == "metadata")); + + index.GetMethod().ShouldBe("gin"); + } + + [Fact] + public void SqlServerStoresCheckedJsonAsNvarcharMax() + { + using var services = SqlServer(); + using var scope = services.CreateScope(); + var model = DesignModel(scope.ServiceProvider); + + ColumnType(model, "cms_post", "metadata").ShouldBe("nvarchar(max)"); + ColumnType(model, "cms_post", "published_on").ShouldBe("datetimeoffset"); + ColumnType(model, typeof(ContentVersion).FullName!, nameof(ContentVersion.Snapshot)).ShouldBe("nvarchar(max)"); + ColumnType(model, typeof(Document).FullName!, nameof(Document.SchedulePublishAt)).ShouldBe("datetimeoffset"); + model.FindEntityType("cms_post")!.GetCheckConstraints().Select(check => check.Sql).ShouldContain("ISJSON([metadata]) = 1"); + } + + [Fact] + public void SqlServerCanUseTheNativeJsonType() + { + using var services = SqlServer(nativeJson: true); + using var scope = services.CreateScope(); + var model = DesignModel(scope.ServiceProvider); + + ColumnType(model, "cms_post", "metadata").ShouldBe("json"); + model.FindEntityType("cms_post")!.GetCheckConstraints().ShouldBeEmpty(); + } + + [Fact] + public void NeitherProviderMigratesOnStartupByDefault() + { + using var postgreSql = PostgreSql(); + using var sqlServer = SqlServer(); + + postgreSql.GetRequiredService().MigrateOnStartup.ShouldBeFalse(); + sqlServer.GetRequiredService().MigrateOnStartup.ShouldBeFalse(); + } + + [Fact] + public void ThePostgreSqlSystemMigrationsMatchTheModel() + { + using var services = PostgreSql(); + using var scope = services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService().Database; + + database.GetMigrations().ShouldHaveSingleItem(); + database.HasPendingModelChanges().ShouldBeFalse(); + } + + [Fact] + public void TheSqlServerSystemMigrationsMatchTheModel() + { + using var services = SqlServer(); + using var scope = services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService().Database; + + database.GetMigrations().ShouldHaveSingleItem(); + database.HasPendingModelChanges().ShouldBeFalse(); + } + + [Fact] + public void PostgreSqlFiltersTranslate() + { + using var host = PostgreSql(); + using var scope = host.CreateScope(); + var services = scope.ServiceProvider; + + Sql(services, (dialect, row) => dialect.HasBlock(Sections(row), Bind("hero"))) + .ShouldContain("c.sections @> jsonb_build_array(jsonb_build_object('type', @"); + + Sql(services, (dialect, row) => dialect.AnyBlock(Sections(row), Bind("hero"), block => dialect.ScalarEquals(block, "heading", JsonScalarType.Text, Bind("x")))) + .ShouldContain("jsonb_array_elements("); + + Sql(services, (dialect, row) => dialect.ScalarEquals(Metadata(row), "minutes", JsonScalarType.WholeNumber, Bind(12L))) + .ShouldContain("c.metadata @> jsonb_build_object('minutes', @"); + Sql(services, (dialect, row) => dialect.ScalarEquals(Metadata(row), "rating", JsonScalarType.DecimalNumber, Bind(4.5m))) + .ShouldContain("c.metadata @> jsonb_build_object('rating', @"); + Sql(services, (dialect, row) => dialect.ScalarEquals(Metadata(row), "featured", JsonScalarType.Boolean, Bind(true))) + .ShouldContain("c.metadata @> jsonb_build_object('featured', @"); + + var contains = Sql(services, (dialect, row) => dialect.ScalarContains(Metadata(row), "audience", Bind("x"))); + contains.ShouldContain("jsonb_typeof(jsonb_extract_path("); + contains.ShouldContain("jsonb_extract_path_text(c.metadata, 'audience') LIKE @"); + + Sql(services, (dialect, row) => dialect.ArrayContains(Metadata(row), "labels", Bind("x"))) + .ShouldContain("@> jsonb_build_object('labels', jsonb_build_array(@"); + } + + [Fact] + public void SqlServerFiltersTranslate() + { + using var host = SqlServer(); + using var scope = host.CreateScope(); + var services = scope.ServiceProvider; + + Sql(services, (dialect, row) => dialect.HasBlock(Sections(row), Bind("hero"))) + .ShouldContain("OPENJSON([c].[sections])"); + + Sql(services, (dialect, row) => dialect.AnyBlock(Sections(row), Bind("hero"), block => dialect.ScalarEquals(block, "heading", JsonScalarType.Text, Bind("x")))) + .ShouldContain("COLLATE Latin1_General_100_BIN2"); + + Sql(services, (dialect, row) => dialect.ScalarEquals(Metadata(row), "minutes", JsonScalarType.WholeNumber, Bind(12L))) + .ShouldContain("TRY_CONVERT(bigint, "); + Sql(services, (dialect, row) => dialect.ScalarEquals(Metadata(row), "rating", JsonScalarType.DecimalNumber, Bind(4.5m))) + .ShouldContain("TRY_CONVERT(decimal(38,18), "); + Sql(services, (dialect, row) => dialect.ScalarEquals(Metadata(row), "featured", JsonScalarType.Boolean, Bind(true))) + .ShouldContain("N'true'"); + + Sql(services, (dialect, row) => dialect.ScalarContains(Metadata(row), "audience", Bind("x"))) + .ShouldContain("COLLATE Latin1_General_100_BIN2"); + Sql(services, (dialect, row) => dialect.ArrayContains(Metadata(row), "labels", Bind("x"))) + .ShouldContain("[key] = N'labels'"); + } + + [Fact] + public void TheSystemModelIsSeparatePerSqlServerJsonMode() + { + using var text = SqlServer(); + using var native = SqlServer(nativeJson: true); + using var textScope = text.CreateScope(); + using var nativeScope = native.CreateScope(); + + var textModel = textScope.ServiceProvider.GetRequiredService().GetService().Model; + var nativeModel = nativeScope.ServiceProvider.GetRequiredService().GetService().Model; + + ColumnType(textModel, typeof(ContentVersion).FullName!, nameof(ContentVersion.Snapshot)).ShouldBe("nvarchar(max)"); + ColumnType(nativeModel, typeof(ContentVersion).FullName!, nameof(ContentVersion.Snapshot)).ShouldBe("json"); + } +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/ConventionsTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/ConventionsTests.cs new file mode 100644 index 000000000..a5ba8be9e --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/ConventionsTests.cs @@ -0,0 +1,150 @@ +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Publishing; +using FluentCMS.Schema; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Sqlite; + +public sealed class ConventionsTests : IDisposable +{ + private const string Post = "cms_post"; + + // SQLITE_CONSTRAINT_CHECK. + private const int CheckConstraintFailed = 275; + + private static readonly SchemaModel Schema = SampleSchema.NewBuilder() + .AddCollection("post", type => type + .Field("publishedOn", "dateTime") + .Field("metadata", "json")) + .Build(); + + // In UTC order, which is not the order of their local times. + private static readonly DateTimeOffset Earliest = new(2026, 11, 1, 12, 0, 0, TimeSpan.FromHours(5)); + private static readonly DateTimeOffset Middle = new(2026, 11, 1, 8, 30, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Latest = new(2026, 11, 1, 6, 0, 0, TimeSpan.FromHours(-5)); + + private readonly SqliteFileDatabase _database = new(); + private readonly ServiceProvider _services; + private readonly IServiceScope _scope; + private readonly CmsDbContext _context; + + public ConventionsTests() + { + _services = _database.CreateServices(Schema); + _scope = _services.CreateScope(); + _context = _scope.ServiceProvider.GetRequiredService(); + _context.Database.EnsureCreated(); + } + + public void Dispose() + { + _scope.Dispose(); + _services.Dispose(); + _database.Dispose(); + } + + private static Document NewDocument(DateTimeOffset schedule) => new() + { + DocumentId = Guid.CreateVersion7(), + Type = "post", + Status = EntryStatus.Draft, + CreatedAt = schedule, + UpdatedAt = schedule, + SchedulePublishAt = schedule, + }; + + private void AddPost(DateTimeOffset publishedOn, string? metadata = null) => + _context.Rows(Post).Add(new Dictionary + { + ["document_id"] = Guid.CreateVersion7(), + ["site_id"] = Guid.Empty, + ["state"] = ContentState.Published, + ["published_on"] = publishedOn, + ["metadata"] = metadata!, + }); + + private string ColumnType(string table, string column) + { + _context.Database.OpenConnection(); + using var command = _context.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"select type from pragma_table_info('{table}') where name = '{column}'"; + return (string)command.ExecuteScalar()!; + } + + [Fact] + public void ContentDatesCompareAndSortAcrossOffsets() + { + AddPost(Latest); + AddPost(Earliest); + AddPost(Middle); + _context.SaveChanges(); + + var sorted = _context.Rows(Post) + .OrderBy(row => EF.Property(row, "published_on")) + .Select(row => EF.Property(row, "published_on")) + .ToList(); + sorted.ShouldBe([Earliest, Middle, Latest]); + + _context.Rows(Post).Count(row => EF.Property(row, "published_on") > Middle).ShouldBe(1); + ColumnType(Post, "published_on").ShouldBe("INTEGER"); + } + + [Fact] + public void SystemDatesCompareAndSortAcrossOffsets() + { + _context.Documents.AddRange(NewDocument(Latest), NewDocument(Earliest), NewDocument(Middle)); + _context.SaveChanges(); + + var sorted = _context.Documents.OrderByDescending(document => document.SchedulePublishAt).Select(document => document.SchedulePublishAt).ToList(); + sorted.ShouldBe([Latest, Middle, Earliest]); + + _context.Documents.Count(document => document.SchedulePublishAt <= Middle).ShouldBe(2); + ColumnType("cms_document", "schedule_publish_at").ShouldBe("INTEGER"); + ColumnType("cms_version", "created_at").ShouldBe("INTEGER"); + } + + [Fact] + public void ADateReadsBackAsTheSameInstantInUtc() + { + _context.Documents.Add(NewDocument(Earliest)); + _context.SaveChanges(); + _context.ChangeTracker.Clear(); + + var read = _context.Documents.Single().SchedulePublishAt!.Value; + read.ShouldBe(Earliest); + read.Offset.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void AContentJsonColumnRejectsMalformedJson() + { + AddPost(Middle, "{not json"); + + var exception = Should.Throw(() => _context.SaveChanges()); + exception.InnerException.ShouldBeOfType().SqliteExtendedErrorCode.ShouldBe(CheckConstraintFailed); + } + + [Fact] + public void ASystemJsonColumnRejectsMalformedJson() + { + _context.Versions.Add(new ContentVersion { DocumentId = Guid.CreateVersion7(), Number = 1, Snapshot = "{not json", CreatedAt = Middle }); + + var exception = Should.Throw(() => _context.SaveChanges()); + exception.InnerException.ShouldBeOfType().SqliteExtendedErrorCode.ShouldBe(CheckConstraintFailed); + } + + [Fact] + public void AJsonColumnAcceptsValidJsonAndNull() + { + AddPost(Middle, """{"a":1}"""); + AddPost(Middle); + + _context.SaveChanges().ShouldBe(2); + } +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/HostTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/HostTests.cs new file mode 100644 index 000000000..ff0afdbc4 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/HostTests.cs @@ -0,0 +1,85 @@ +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Data.Sqlite; +using FluentCMS.Publishing; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Sqlite; + +public class HostTests +{ + [Fact] + public void AHostWithNoProviderFailsWithAClearError() + { + var exception = Should.Throw(() => new ServiceCollection().AddFluentCms(_ => { })); + + exception.Code.ShouldBe("FCMS1302"); + exception.Message.ShouldStartWith("FCMS1302: "); + exception.Message.ShouldContain("cms.UseSqlite(...)"); + } + + [Fact] + public void AHostWithTwoProvidersFailsWithAClearError() + { + var exception = Should.Throw(() => new ServiceCollection().AddFluentCms(cms => cms + .UseSqlite("Data Source=a.db") + .UseSqlite("Data Source=b.db"))); + + exception.Code.ShouldBe("FCMS1303"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void AMissingConnectionStringIsRejected(string? connectionString) => + Should.Throw(() => new ServiceCollection().AddFluentCms(cms => cms.UseSqlite(connectionString))); + + [Fact] + public void AHostWithUseSqliteResolvesAWorkingContext() + { + using var database = new SqliteFileDatabase(); + using var services = database.CreateServices(); + var at = new DateTimeOffset(2026, 11, 1, 12, 0, 0, TimeSpan.Zero); + + using (var scope = services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + context.Documents.Add(new Document { DocumentId = Guid.CreateVersion7(), Type = "post", Status = EntryStatus.Draft, CreatedAt = at, UpdatedAt = at }); + context.SaveChanges(); + } + + using (var scope = services.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Documents.Single().Type.ShouldBe("post"); + } + } + + [Fact] + public void AHostSchemaReachesTheContext() + { + using var database = new SqliteFileDatabase(); + using var services = database.CreateServices(SampleSchema.FromClasses().Build()); + using var scope = services.CreateScope(); + + scope.ServiceProvider.GetRequiredService().Model.FindEntityType("cms_article").ShouldNotBeNull(); + } + + [Fact] + public void TheProviderRegistersItsPartsAndDefaults() + { + using var database = new SqliteFileDatabase(); + using var services = database.CreateServices(); + + var provider = services.GetRequiredService(); + provider.ShouldBeOfType(); + provider.MigrateOnStartup.ShouldBeTrue(); + services.GetRequiredService().ShouldBeOfType(); + services.GetRequiredService().ShouldBeOfType(); + services.GetRequiredService().ShouldBeOfType(); + } +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/JobClaimTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/JobClaimTests.cs new file mode 100644 index 000000000..2e2859a11 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/JobClaimTests.cs @@ -0,0 +1,231 @@ +using FluentCMS.Core.Storage; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Sqlite; + +// cms_job arrives with #2698, so this is a scratch table with the columns of spec section 4.12. +public sealed class JobClaimTests : IDisposable +{ + private const string Queue = "default"; + + private static readonly DateTimeOffset Now = new(2026, 11, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset LeaseUntil = Now.AddMinutes(5); + private static readonly string[] Workers = ["worker-1", "worker-2"]; + + private readonly SqliteFileDatabase _database = new(); + private readonly ServiceProvider _services; + + public JobClaimTests() + { + _services = _database.CreateServices(); + Execute(""" + CREATE TABLE cms_job ( + id TEXT NOT NULL PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT NOT NULL CHECK (json_valid(payload)), + queue TEXT NOT NULL, + site_id TEXT NULL, + run_at INTEGER NOT NULL, + priority INTEGER NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL, + max_attempts INTEGER NOT NULL, + lease_until INTEGER NULL, + lease_owner TEXT NULL, + last_error TEXT NULL, + created_at INTEGER NOT NULL, + created_by TEXT NULL, + origin TEXT NOT NULL, + completed_at INTEGER NULL) + """); + } + + public void Dispose() + { + _services.Dispose(); + _database.Dispose(); + } + + private static string StoredId(Guid id) => id.ToString().ToUpperInvariant(); + + private void Execute(string sql, params (string Name, object? Value)[] parameters) + { + using var connection = new SqliteConnection(_database.ConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value ?? DBNull.Value); + } + + command.ExecuteNonQuery(); + } + + private object? Read(Guid job, string column) + { + using var connection = new SqliteConnection(_database.ConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT {column} FROM cms_job WHERE id = $id"; + command.Parameters.AddWithValue("$id", StoredId(job)); + return command.ExecuteScalar(); + } + + private Guid AddJob( + string queue = Queue, + string status = JobClaimStatus.Pending, + DateTimeOffset? runAt = null, + int priority = 0, + int attempts = 0, + int maxAttempts = 3, + DateTimeOffset? leaseUntil = null, + string? leaseOwner = null) + { + var id = Guid.CreateVersion7(); + Execute( + """ + INSERT INTO cms_job (id, type, payload, queue, run_at, priority, status, attempts, max_attempts, lease_until, lease_owner, created_at, origin) + VALUES ($id, 'test', '{}', $queue, $runAt, $priority, $status, $attempts, $maxAttempts, $leaseUntil, $leaseOwner, 0, 'Job') + """, + ("$id", StoredId(id)), + ("$queue", queue), + ("$runAt", (runAt ?? Now.AddMinutes(-1)).UtcTicks), + ("$priority", priority), + ("$status", status), + ("$attempts", attempts), + ("$maxAttempts", maxAttempts), + ("$leaseUntil", leaseUntil?.UtcTicks), + ("$leaseOwner", leaseOwner)); + + return id; + } + + private async Task ClaimAsync(string owner = "worker", string queue = Queue) + { + using var scope = _services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var dialect = scope.ServiceProvider.GetRequiredService(); + return await dialect.ClaimAsync(context.Database, new JobClaim(queue, owner, Now, LeaseUntil), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ClaimingTakesTheLeaseAndCountsTheAttempt() + { + var job = AddJob(); + + (await ClaimAsync("worker-1")).ShouldBe(job); + + Read(job, "status").ShouldBe(JobClaimStatus.Running); + Read(job, "lease_owner").ShouldBe("worker-1"); + Read(job, "lease_until").ShouldBe(LeaseUntil.UtcTicks); + Read(job, "attempts").ShouldBe(1L); + } + + [Fact] + public async Task NothingToClaimGivesNull() => (await ClaimAsync()).ShouldBeNull(); + + [Fact] + public async Task AClaimedJobIsNotClaimedAgain() + { + var job = AddJob(); + + (await ClaimAsync("worker-1")).ShouldBe(job); + (await ClaimAsync("worker-2")).ShouldBeNull(); + + Read(job, "lease_owner").ShouldBe("worker-1"); + } + + [Fact] + public async Task OnlyAJobThatIsDueOnTheQueueAndStillHasAttemptsIsClaimed() + { + AddJob(runAt: Now.AddSeconds(1)); + AddJob(queue: "other"); + AddJob(attempts: 3, maxAttempts: 3); + AddJob(status: "succeeded"); + AddJob(status: JobClaimStatus.Running, leaseUntil: LeaseUntil, leaseOwner: "worker-0"); + + (await ClaimAsync()).ShouldBeNull(); + } + + [Fact] + public async Task AnExpiredLeaseIsClaimedAgain() + { + var job = AddJob(status: JobClaimStatus.Running, attempts: 1, leaseUntil: Now.AddTicks(-1), leaseOwner: "dead-worker"); + + (await ClaimAsync("worker-1")).ShouldBe(job); + + Read(job, "lease_owner").ShouldBe("worker-1"); + Read(job, "attempts").ShouldBe(2L); + } + + [Fact] + public async Task TheHighestPriorityGoesFirstThenTheEarliestRunAt() + { + var later = AddJob(runAt: Now.AddMinutes(-1)); + var earlier = AddJob(runAt: Now.AddMinutes(-2)); + var urgent = AddJob(runAt: Now.AddMinutes(-1), priority: 10); + + (await ClaimAsync()).ShouldBe(urgent); + (await ClaimAsync()).ShouldBe(earlier); + (await ClaimAsync()).ShouldBe(later); + } + + // EF creates a database in WAL mode; one created by other means keeps the rollback journal. + [Theory] + [InlineData("delete")] + [InlineData("wal")] + public async Task OfTwoConcurrentClaimersOnlyOneWins(string journalMode) + { + Execute($"PRAGMA journal_mode = {journalMode}"); + + for (var round = 0; round < 25; round++) + { + var queue = $"round-{round}"; + var job = AddJob(queue); + using var start = new ManualResetEventSlim(); + + var claimers = Workers.Select(owner => Task.Run(async () => + { + start.Wait(TestContext.Current.CancellationToken); + return (Owner: owner, Job: await ClaimAsync(owner, queue)); + }, TestContext.Current.CancellationToken)).ToArray(); + start.Set(); + + var results = await Task.WhenAll(claimers); + + var winner = results.Where(result => result.Job is not null).ShouldHaveSingleItem(); + winner.Job.ShouldBe(job); + Read(job, "lease_owner").ShouldBe(winner.Owner); + Read(job, "attempts").ShouldBe(1L); + } + } + + [Theory] + [InlineData("delete")] + [InlineData("wal")] + public async Task ConcurrentClaimersGetEachJobOnce(string journalMode) + { + Execute($"PRAGMA journal_mode = {journalMode}"); + + var jobs = Enumerable.Range(0, 20).Select(_ => AddJob()).ToList(); + + var claimers = Enumerable.Range(0, 8).Select(worker => Task.Run(async () => + { + var claimed = new List(); + while (await ClaimAsync($"worker-{worker}") is { } job) + { + claimed.Add(job); + } + + return claimed; + }, TestContext.Current.CancellationToken)).ToArray(); + + var claimed = (await Task.WhenAll(claimers)).SelectMany(list => list).ToList(); + + claimed.ShouldBe(jobs, ignoreOrder: true); + } +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs new file mode 100644 index 000000000..034c21376 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs @@ -0,0 +1,263 @@ +using System.Linq.Expressions; +using FluentCMS.Core.Storage; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using static FluentCMS.Data.Tests.Sqlite.RowFilter; + +namespace FluentCMS.Data.Tests.Sqlite; + +public sealed class JsonFilterTests : IDisposable +{ + private const string Post = "cms_post"; + + private static readonly SchemaModel Schema = SampleSchema.NewBuilder() + .AddBlock("hero", block => block.Field("heading", "text")) + .AddBlock("quote", block => block.Field("text", "text")) + .AddCollection("post", type => type + .Field("category", "text") + .Field("sections", "blocks", field => field.Setting(FieldSettings.Blocks, new[] { "hero", "quote" })) + .Field("metadata", "json")) + .Build(); + + private readonly SqliteFileDatabase _database = new(); + private readonly ServiceProvider _services; + private readonly IServiceScope _scope; + private readonly CmsDbContext _context; + private readonly IJsonSqlDialect _dialect; + private readonly Guid _hero; + private readonly Guid _quote; + private readonly Guid _empty; + + public JsonFilterTests() + { + _services = _database.CreateServices(Schema); + _scope = _services.CreateScope(); + _context = _scope.ServiceProvider.GetRequiredService(); + _dialect = _scope.ServiceProvider.GetRequiredService(); + _context.Database.EnsureCreated(); + + _hero = Add( + """[{"type":"hero","heading":"Welcome","level":2,"tags":["net"]},{"type":"quote","text":"Hi"}]""", + """{"audience":"developers","minutes":12,"rating":4.5,"featured":true,"code":"12","labels":["net","sql"],"mixed":[12]}"""); + _quote = Add( + """[{"type":"quote","text":"Welcome","heading":"Other"}]""", + """{"audience":"o'brien","minutes":3,"rating":4,"featured":false,"flag":1,"labels":"net"}"""); + _empty = Add("[]", null); + _context.SaveChanges(); + } + + public void Dispose() + { + _scope.Dispose(); + _services.Dispose(); + _database.Dispose(); + } + + private Guid Add(string sections, string? metadata) + { + var id = Guid.CreateVersion7(); + _context.Rows(Post).Add(new Dictionary + { + ["document_id"] = id, + ["site_id"] = Guid.Empty, + ["state"] = ContentState.Published, + ["category"] = "news", + ["sections"] = sections, + ["metadata"] = metadata!, + }); + + return id; + } + + private IQueryable> Filter(Func filter) => + _context.Rows(Post).Where(Where(filter)); + + private List Ids(Func filter) => + [.. Filter(filter).Select(row => EF.Property(row, "document_id"))]; + + private static MethodCallExpression Sections(ParameterExpression row) => Column(row, "sections"); + + private static MethodCallExpression Metadata(ParameterExpression row) => Column(row, "metadata"); + + [Fact] + public void HasBlockFindsARowHoldingABlockOfTheType() + { + Ids(row => _dialect.HasBlock(Sections(row), Bind("hero"))).ShouldBe([_hero]); + Ids(row => _dialect.HasBlock(Sections(row), Bind("quote"))).ShouldBe([_hero, _quote], ignoreOrder: true); + Ids(row => _dialect.HasBlock(Sections(row), Bind("video"))).ShouldBeEmpty(); + } + + [Fact] + public void HasBlockTranslatesToExistsOverJsonEach() + { + var sql = Filter(row => _dialect.HasBlock(Sections(row), Bind("hero"))).ToQueryString(); + + sql.ShouldContain("EXISTS"); + sql.ShouldContain("json_each(\"c\".\"sections\")"); + sql.ShouldContain("json_extract(\"j\".\"value\", '$.type') = @"); + } + + [Fact] + public void AnyBlockAppliesEveryConditionToTheSameBlock() + { + Ids(row => _dialect.AnyBlock(Sections(row), Bind("hero"), block => + _dialect.ScalarEquals(block, "heading", JsonScalarType.Text, Bind("Welcome")))).ShouldBe([_hero]); + + // The hero document has a quote block and a heading, but no quote block with that heading. + Ids(row => _dialect.AnyBlock(Sections(row), Bind("quote"), block => + _dialect.ScalarEquals(block, "heading", JsonScalarType.Text, Bind("Welcome")))).ShouldBeEmpty(); + } + + [Fact] + public void AnyBlockComparesInsideABlockByType() + { + Ids(row => _dialect.AnyBlock(Sections(row), Bind("hero"), block => + _dialect.ScalarEquals(block, "level", JsonScalarType.WholeNumber, Bind(2L)))).ShouldBe([_hero]); + Ids(row => _dialect.AnyBlock(Sections(row), Bind("hero"), block => + _dialect.ScalarEquals(block, "level", JsonScalarType.Text, Bind("2")))).ShouldBeEmpty(); + } + + [Fact] + public void AnyBlockSupportsContainment() + { + Ids(row => _dialect.AnyBlock(Sections(row), Bind("quote"), block => + _dialect.ScalarContains(block, "text", Bind("elcom")))).ShouldBe([_quote]); + Ids(row => _dialect.AnyBlock(Sections(row), Bind("hero"), block => + _dialect.ArrayContains(block, "tags", Bind("net")))).ShouldBe([_hero]); + } + + [Fact] + public void ScalarEqualsComparesText() + { + Ids(row => _dialect.ScalarEquals(Metadata(row), "audience", JsonScalarType.Text, Bind("developers"))).ShouldBe([_hero]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "audience", JsonScalarType.Text, Bind("o'brien"))).ShouldBe([_quote]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "audience", JsonScalarType.Text, Bind("DEVELOPERS"))).ShouldBeEmpty(); + } + + [Fact] + public void ScalarEqualsComparesNumbers() + { + Ids(row => _dialect.ScalarEquals(Metadata(row), "minutes", JsonScalarType.WholeNumber, Bind(12L))).ShouldBe([_hero]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "rating", JsonScalarType.DecimalNumber, Bind(4.5m))).ShouldBe([_hero]); + + // A JSON integer and a decimal of the same value are equal. + Ids(row => _dialect.ScalarEquals(Metadata(row), "rating", JsonScalarType.DecimalNumber, Bind(4m))).ShouldBe([_quote]); + } + + [Fact] + public void ScalarEqualsComparesBooleans() + { + Ids(row => _dialect.ScalarEquals(Metadata(row), "featured", JsonScalarType.Boolean, Bind(true))).ShouldBe([_hero]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "featured", JsonScalarType.Boolean, Bind(false))).ShouldBe([_quote]); + } + + [Fact] + public void AComparisonIsSpecificToTheJsonType() + { + // minutes is the number 12, code is the text "12", flag is the number 1. + Ids(row => _dialect.ScalarEquals(Metadata(row), "minutes", JsonScalarType.Text, Bind("12"))).ShouldBeEmpty(); + Ids(row => _dialect.ScalarEquals(Metadata(row), "code", JsonScalarType.Text, Bind("12"))).ShouldBe([_hero]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "code", JsonScalarType.WholeNumber, Bind(12L))).ShouldBeEmpty(); + Ids(row => _dialect.ScalarEquals(Metadata(row), "code", JsonScalarType.DecimalNumber, Bind(12m))).ShouldBeEmpty(); + Ids(row => _dialect.ScalarEquals(Metadata(row), "flag", JsonScalarType.Boolean, Bind(true))).ShouldBeEmpty(); + } + + [Fact] + public void ScalarContainsMatchesInsideTextCaseSensitively() + { + Ids(row => _dialect.ScalarContains(Metadata(row), "audience", Bind("velop"))).ShouldBe([_hero]); + Ids(row => _dialect.ScalarContains(Metadata(row), "audience", Bind("VELOP"))).ShouldBeEmpty(); + } + + [Fact] + public void ScalarContainsLooksOnlyAtText() + { + Ids(row => _dialect.ScalarContains(Metadata(row), "minutes", Bind("1"))).ShouldBeEmpty(); + Ids(row => _dialect.ScalarContains(Metadata(row), "mixed", Bind("1"))).ShouldBeEmpty(); + } + + [Fact] + public void ArrayContainsMatchesAWholeElement() + { + Ids(row => _dialect.ArrayContains(Metadata(row), "labels", Bind("sql"))).ShouldBe([_hero]); + Ids(row => _dialect.ArrayContains(Metadata(row), "labels", Bind("sq"))).ShouldBeEmpty(); + } + + [Fact] + public void ArrayContainsLooksOnlyAtArraysOfText() + { + // In the quote document labels is the text "net", and mixed holds the number 12. + Ids(row => _dialect.ArrayContains(Metadata(row), "labels", Bind("net"))).ShouldBe([_hero]); + Ids(row => _dialect.ArrayContains(Metadata(row), "mixed", Bind("12"))).ShouldBeEmpty(); + Ids(row => _dialect.ArrayContains(Metadata(row), "absent", Bind("net"))).ShouldBeEmpty(); + } + + [Fact] + public void AFilterNegatesIncludingRowsWithoutThePath() + { + Ids(row => Expression.Not(_dialect.ScalarEquals(Metadata(row), "audience", JsonScalarType.Text, Bind("developers")))) + .ShouldBe([_quote, _empty], ignoreOrder: true); + } + + [Fact] + public void AFilterComposesWithOrdinaryColumnsAndAnInlinedState() + { + var query = Filter(row => Expression.AndAlso( + Expression.AndAlso( + Expression.Equal(Column(row, "state"), Expression.Constant(ContentState.Published)), + Expression.Equal(Column(row, "category"), Bind("news"))), + _dialect.HasBlock(Sections(row), Bind("hero")))); + + query.Select(row => EF.Property(row, "document_id")).ToList().ShouldBe([_hero]); + query.ToQueryString().ShouldContain("\"c\".\"state\" = 1"); + } + + [Fact] + public void UserValuesAreBoundAsParameters() + { + var sql = Filter(row => _dialect.AnyBlock(Sections(row), Bind("hero"), block => + _dialect.ScalarEquals(block, "heading", JsonScalarType.Text, Bind("Welcome")))).ToQueryString(); + + sql.ShouldContain(".param set"); + sql.ShouldNotContain("= 'Welcome'"); + } + + [Theory] + [InlineData("a.b")] + [InlineData("a[0]")] + [InlineData("x') OR ('1'='1")] + [InlineData("")] + public void APathDeeperThanOneLevelOrNotAPlainNameIsRejected(string field) + { + Should.Throw(() => _dialect.ScalarEquals(Expression.Constant("{}"), field, JsonScalarType.Text, Bind("x"))); + Should.Throw(() => _dialect.ScalarContains(Expression.Constant("{}"), field, Bind("x"))); + Should.Throw(() => _dialect.ArrayContains(Expression.Constant("{}"), field, Bind("x"))); + } + + [Fact] + public void AValueOfTheWrongClrTypeIsRejected() => + Should.Throw(() => _dialect.ScalarEquals(Expression.Constant("{}"), "minutes", JsonScalarType.WholeNumber, Bind("12"))); + + [Theory] + [InlineData("text", JsonScalarType.Text)] + [InlineData("longText", JsonScalarType.Text)] + [InlineData("email", JsonScalarType.Text)] + [InlineData("url", JsonScalarType.Text)] + [InlineData("slug", JsonScalarType.Text)] + [InlineData("select", JsonScalarType.Text)] + [InlineData("date", JsonScalarType.Text)] + [InlineData("dateTime", JsonScalarType.Text)] + [InlineData("integer", JsonScalarType.WholeNumber)] + [InlineData("decimal", JsonScalarType.DecimalNumber)] + [InlineData("boolean", JsonScalarType.Boolean)] + [InlineData("json", null)] + [InlineData("blocks", null)] + [InlineData("richText", null)] + [InlineData("relation", null)] + public void EachFieldTypeHasOneComparison(string fieldType, JsonScalarType? expected) => + JsonScalar.TypeOf(fieldType).ShouldBe(expected); +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/RowFilter.cs b/tests/FluentCMS.Data.Tests/Sqlite/RowFilter.cs new file mode 100644 index 000000000..8f6c596f4 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/RowFilter.cs @@ -0,0 +1,25 @@ +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Data.Tests.Sqlite; + +// What the query compiler of #2692 hands to IJsonSqlDialect: columns of a property bag and values on a holder. +internal static class RowFilter +{ + private static readonly MethodInfo EfProperty = typeof(EF).GetMethod(nameof(EF.Property))!; + + public static Expression, bool>> Where(Func filter) + { + var row = Expression.Parameter(typeof(Dictionary), "row"); + return Expression.Lambda, bool>>(filter(row), row); + } + + public static MethodCallExpression Column(ParameterExpression row, string column) => + Expression.Call(EfProperty.MakeGenericMethod(typeof(T)), row, Expression.Constant(column)); + + public static MemberExpression Bind(T value) => + Expression.Property(Expression.Constant(new Holder(value)), nameof(Holder.Value)); + + private sealed record Holder(T Value); +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/SqliteFileDatabase.cs b/tests/FluentCMS.Data.Tests/Sqlite/SqliteFileDatabase.cs new file mode 100644 index 000000000..df4337cad --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/SqliteFileDatabase.cs @@ -0,0 +1,28 @@ +using FluentCMS.Core.DependencyInjection; +using FluentCMS.Data.Sqlite; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Data.Tests.Sqlite; + +// A database file of its own, reached through the real package. Pooling is off so that the file can be deleted. +internal sealed class SqliteFileDatabase : IDisposable +{ + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("fluentcms-data-tests-"); + + public string ConnectionString => $"Data Source={Path.Combine(_directory.FullName, "cms.db")};Pooling=False"; + + public ServiceProvider CreateServices(SchemaModel? schema = null) + { + var services = new ServiceCollection(); + if (schema is not null) + { + services.AddSingleton(schema); + } + + services.AddFluentCms(cms => cms.UseSqlite(ConnectionString)); + return services.BuildServiceProvider(); + } + + public void Dispose() => _directory.Delete(recursive: true); +} diff --git a/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs new file mode 100644 index 000000000..131f0a59f --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Sqlite/SystemMigrationTests.cs @@ -0,0 +1,99 @@ +using FluentCMS.Core.Storage; +using FluentCMS.Core.Storage.Entities; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Publishing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Sqlite; + +public sealed class SystemMigrationTests : IDisposable +{ + private readonly SqliteFileDatabase _database = new(); + + public void Dispose() => _database.Dispose(); + + private static List Tables(DbContext context) + { + context.Database.OpenConnection(); + using var command = context.Database.GetDbConnection().CreateCommand(); + command.CommandText = "select name from sqlite_master where type = 'table' and name not like 'sqlite_%' order by name"; + using var reader = command.ExecuteReader(); + + var tables = new List(); + while (reader.Read()) + { + tables.Add(reader.GetString(0)); + } + + return tables; + } + + private static long Count(DbContext context, string table) + { + context.Database.OpenConnection(); + using var command = context.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"select count(*) from {table}"; + return (long)command.ExecuteScalar()!; + } + + [Fact] + public void AppliesToAFreshFileDatabaseWithItsOwnHistoryTable() + { + using var services = _database.CreateServices(SampleSchema.FromClasses().Build()); + using var scope = services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + context.Database.GetPendingMigrations().ShouldHaveSingleItem(); + context.Database.Migrate(); + + Tables(context).ShouldBe(["__EFMigrationsLock", "cms__migrations", "cms_document", "cms_reference", "cms_version"], ignoreOrder: true); + Count(context, SystemDbContext.HistoryTable).ShouldBe(1); + context.Database.GetPendingMigrations().ShouldBeEmpty(); + } + + [Fact] + public void TheModelHasNoPendingChanges() + { + using var services = _database.CreateServices(); + using var scope = services.CreateScope(); + + scope.ServiceProvider.GetRequiredService().Database.HasPendingModelChanges().ShouldBeFalse(); + } + + [Fact] + public void TheModelHoldsOnlySystemTablesWhateverTheSchema() + { + using var services = _database.CreateServices(SampleSchema.FromClasses().Build()); + using var scope = services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + context.Model.GetEntityTypes().Select(type => type.GetTableName()).ShouldBe(["cms_document", "cms_reference", "cms_version"], ignoreOrder: true); + } + + [Fact] + public void CmsDbContextReadsAndWritesTheMigratedTables() + { + using var services = _database.CreateServices(); + using (var scope = services.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Database.Migrate(); + } + + var at = new DateTimeOffset(2026, 11, 1, 12, 0, 0, TimeSpan.Zero); + using (var scope = services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Documents.Add(new Document { DocumentId = Guid.CreateVersion7(), Type = "post", Status = EntryStatus.Draft, CreatedAt = at, UpdatedAt = at }); + context.Versions.Add(new ContentVersion { DocumentId = Guid.CreateVersion7(), Number = 1, Snapshot = "{}", CreatedAt = at }); + context.SaveChanges(); + } + + using (var scope = services.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Documents.Single().CreatedAt.ShouldBe(at); + } + } +} From d2efa5025132ce22ae2f6081a4a9d332d87b968d Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 10:29:31 -0500 Subject: [PATCH 2/2] Check the JSON type in SQLite comparisons --- .../SqliteJsonDialect.cs | 28 +++++--- .../SqliteJsonFunctions.cs | 3 + .../Sqlite/JsonFilterTests.cs | 68 +++++++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs b/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs index c48c44da1..f51ccd338 100644 --- a/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs +++ b/src/FluentCMS.Data.Sqlite/SqliteJsonDialect.cs @@ -32,25 +32,25 @@ public void ConfigureFunctions(ModelBuilder modelBuilder) } public Expression HasBlock(Expression json, Expression blockType) => - Any(Expression.Call(Each, json), block => BlockTypeIs(block, blockType)); + Any(Expression.Call(Each, json), entry => BlockTypeIs(EntryValue(entry), blockType)); public Expression AnyBlock(Expression json, Expression blockType, Func predicate) => - Any(Expression.Call(Each, json), block => Expression.AndAlso(BlockTypeIs(block, blockType), predicate(block))); + Any(Expression.Call(Each, json), entry => Expression.AndAlso(BlockTypeIs(EntryValue(entry), blockType), predicate(EntryValue(entry)))); public Expression ScalarEquals(Expression json, string field, JsonScalarType type, Expression value) { JsonScalar.CheckValue(type, value); var path = Path(field); + // json_extract alone is not enough: it gives 1 for true, which would equal the number 1, and an array or + // object as its JSON text, which would equal that text. return type switch { - JsonScalarType.Text => Expression.Equal(Expression.Call(ExtractText, json, path), value), - JsonScalarType.WholeNumber => Expression.Equal(Expression.Call(ExtractInteger, json, path), Expression.Convert(value, typeof(long?))), + JsonScalarType.Text => Expression.AndAlso(IsOfType(json, path, "text"), Expression.Equal(Expression.Call(ExtractText, json, path), value)), + JsonScalarType.WholeNumber => Expression.AndAlso(IsNumber(json, path), Expression.Equal(Expression.Call(ExtractInteger, json, path), Expression.Convert(value, typeof(long?)))), // A JSON number is INTEGER or REAL, while EF binds a decimal as text, which never equals a number. - JsonScalarType.DecimalNumber => Expression.Equal(Expression.Call(ExtractReal, json, path), Expression.Convert(value, typeof(double?))), - - // json_extract gives 1 for true, which would also equal the number 1. + JsonScalarType.DecimalNumber => Expression.AndAlso(IsNumber(json, path), Expression.Equal(Expression.Call(ExtractReal, json, path), Expression.Convert(value, typeof(double?)))), JsonScalarType.Boolean => Expression.Equal( Expression.Call(TypeAt, json, path), Expression.Condition(value, Expression.Constant("true"), Expression.Constant("false"))), @@ -75,7 +75,9 @@ public Expression ArrayContains(Expression json, string field, Expression value) return Expression.AndAlso( IsOfType(json, path, "array"), - Any(Expression.Call(EachAt, json, path), element => Expression.Equal(element, value))); + Any(Expression.Call(EachAt, json, path), element => Expression.AndAlso( + Expression.Equal(Expression.Property(element, nameof(SqliteJsonPathEntry.Type)), Expression.Constant("text")), + Expression.Equal(EntryValue(element), value)))); } private BinaryExpression BlockTypeIs(Expression block, Expression blockType) => @@ -84,11 +86,15 @@ private BinaryExpression BlockTypeIs(Expression block, Expression blockType) => private static BinaryExpression IsOfType(Expression json, ConstantExpression path, string type) => Expression.Equal(Expression.Call(TypeAt, json, path), Expression.Constant(type)); - private static MethodCallExpression Any(Expression entries, Func predicate) + private static BinaryExpression IsNumber(Expression json, ConstantExpression path) => + Expression.OrElse(IsOfType(json, path, "integer"), IsOfType(json, path, "real")); + + private static MemberExpression EntryValue(ParameterExpression entry) => Expression.Property(entry, nameof(SqliteJsonEntry.Value)); + + private static MethodCallExpression Any(Expression entries, Func predicate) { var entry = Expression.Parameter(typeof(TEntry), "entry"); - var body = predicate(Expression.Property(entry, nameof(SqliteJsonEntry.Value))); - return Expression.Call(AnyEntry.MakeGenericMethod(typeof(TEntry)), entries, Expression.Quote(Expression.Lambda>(body, entry))); + return Expression.Call(AnyEntry.MakeGenericMethod(typeof(TEntry)), entries, Expression.Quote(Expression.Lambda>(predicate(entry), entry))); } private static ConstantExpression Path(string field) => Expression.Constant($"$.{JsonScalar.CheckField(field)}"); diff --git a/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs b/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs index 63fa567d3..adc18c482 100644 --- a/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs +++ b/src/FluentCMS.Data.Sqlite/SqliteJsonFunctions.cs @@ -14,6 +14,9 @@ public sealed class SqliteJsonPathEntry { [Column("value")] public string? Value { get; set; } + + [Column("type")] + public string? Type { get; set; } } // EF function mappings; SqliteJsonDialect declares them on the model. They run only as SQL. diff --git a/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs b/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs index 034c21376..94d36f542 100644 --- a/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs +++ b/tests/FluentCMS.Data.Tests/Sqlite/JsonFilterTests.cs @@ -166,6 +166,74 @@ public void AComparisonIsSpecificToTheJsonType() Ids(row => _dialect.ScalarEquals(Metadata(row), "flag", JsonScalarType.Boolean, Bind(true))).ShouldBeEmpty(); } + private Guid AddShapes() + { + var id = Add("[]", """{"flag":true,"count":1,"name":"x","tags":["x"],"nested":[["x"]],"data":{"a":1}}"""); + _context.SaveChanges(); + return id; + } + + [Fact] + public void ABooleanDoesNotEqualAWholeNumber() + { + AddShapes(); + + // The quote document holds the number 1 at flag, which still matches. + Ids(row => _dialect.ScalarEquals(Metadata(row), "flag", JsonScalarType.WholeNumber, Bind(1L))).ShouldBe([_quote]); + } + + [Fact] + public void ABooleanDoesNotEqualADecimalNumber() + { + AddShapes(); + + Ids(row => _dialect.ScalarEquals(Metadata(row), "flag", JsonScalarType.DecimalNumber, Bind(1.0m))).ShouldBe([_quote]); + } + + [Fact] + public void AnArrayDoesNotEqualItsJsonText() + { + AddShapes(); + + Ids(row => _dialect.ScalarEquals(Metadata(row), "tags", JsonScalarType.Text, Bind("""["x"]"""))).ShouldBeEmpty(); + } + + [Fact] + public void AnObjectDoesNotEqualItsJsonText() + { + AddShapes(); + + Ids(row => _dialect.ScalarEquals(Metadata(row), "data", JsonScalarType.Text, Bind("""{"a":1}"""))).ShouldBeEmpty(); + } + + [Fact] + public void ANestedArrayIsNotAnElementOfText() + { + AddShapes(); + + Ids(row => _dialect.ArrayContains(Metadata(row), "nested", Bind("""["x"]"""))).ShouldBeEmpty(); + } + + [Fact] + public void OrdinaryValuesStillMatchNextToTheOtherShapes() + { + var shapes = AddShapes(); + + Ids(row => _dialect.ScalarEquals(Metadata(row), "count", JsonScalarType.WholeNumber, Bind(1L))).ShouldBe([shapes]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "count", JsonScalarType.DecimalNumber, Bind(1.0m))).ShouldBe([shapes]); + Ids(row => _dialect.ScalarEquals(Metadata(row), "name", JsonScalarType.Text, Bind("x"))).ShouldBe([shapes]); + Ids(row => _dialect.ArrayContains(Metadata(row), "tags", Bind("x"))).ShouldBe([shapes]); + } + + [Fact] + public void AWholeNumberEqualsARealOfTheSameValue() + { + var real = Add("[]", """{"count":12.0}"""); + _context.SaveChanges(); + + Ids(row => _dialect.ScalarEquals(Metadata(row), "count", JsonScalarType.WholeNumber, Bind(12L))).ShouldBe([real]); + } + [Fact] public void ScalarContainsMatchesInsideTextCaseSensitively() {