diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a7b245df..2fd057358 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ Nothing in the code updates or deletes an audit row. On PostgreSQL the `AuditEve Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass. -Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject; a type outside Core also overrides `AddFieldTypes` to register its assembly. +Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type also implements `ReferencedTypes`, which returns the slugs it targets; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, so a plugin type gets the same check as the built-in `reference`. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject; a type outside Core also overrides `AddFieldTypes` to register its assembly. ## Code style diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs index 78e4fac80..2e9351955 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs @@ -29,4 +29,13 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object + ? value + : FieldDefinition.EmptySettings; + + return fieldTypes.ReferencedTypes(settings.GetProperty("itemType").GetString()!, itemSettings); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs index 564c83508..313962603 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs @@ -21,4 +21,9 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return [settings.GetProperty("target").GetString()!]; + } } diff --git a/src/FluentCMS.Core/Schema/ContentType.cs b/src/FluentCMS.Core/Schema/ContentType.cs index 595b15257..1b299bf39 100644 --- a/src/FluentCMS.Core/Schema/ContentType.cs +++ b/src/FluentCMS.Core/Schema/ContentType.cs @@ -68,6 +68,17 @@ public static Result Create( : Result.Failure(errors); } + // Rebuilds a stored type as it was written, without validating again: a type stays readable after a field type it uses is gone. + public static ContentType Restore(Guid id, Guid siteId, string name, string slug, int version, ContentTypeDefinition definition) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(slug); + ArgumentNullException.ThrowIfNull(definition); + ArgumentOutOfRangeException.ThrowIfLessThan(version, 1); + + return new ContentType(id, siteId, name, slug, version, definition); + } + // The current instance is left as it is, so a failed revision changes nothing. public Result Revise(ContentTypeDefinition definition, FieldTypeRegistry fieldTypes) { diff --git a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs index ef6783edd..c6025126a 100644 --- a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs +++ b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs @@ -111,6 +111,16 @@ public Result Validate(FieldTypeRegistry fieldTypes) return errors.Count == 0 ? Result.Success() : Result.Failure(errors); } + // Only meaningful for a definition that passed Validate. + public IReadOnlyList References(FieldTypeRegistry fieldTypes) + { + ArgumentNullException.ThrowIfNull(fieldTypes); + + return Fields + .SelectMany(field => fieldTypes.ReferencedTypes(field.Type, field.Settings).Select(target => new FieldReference(field.Name, target))) + .ToList(); + } + private static List ReadFields(JsonElement root, List errors) { var fields = new List(); diff --git a/src/FluentCMS.Core/Schema/ContentTypeSummary.cs b/src/FluentCMS.Core/Schema/ContentTypeSummary.cs new file mode 100644 index 000000000..fdd31765a --- /dev/null +++ b/src/FluentCMS.Core/Schema/ContentTypeSummary.cs @@ -0,0 +1,3 @@ +namespace FluentCMS.Core.Schema; + +public sealed record ContentTypeSummary(Guid Id, string Name, string Slug, int Version); diff --git a/src/FluentCMS.Core/Schema/FieldReference.cs b/src/FluentCMS.Core/Schema/FieldReference.cs new file mode 100644 index 000000000..0823a5d11 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldReference.cs @@ -0,0 +1,4 @@ +namespace FluentCMS.Core.Schema; + +// A field whose values point at entries of the content type with the target slug. +public sealed record FieldReference(string Field, string Target); diff --git a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs index d58763815..120142046 100644 --- a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs +++ b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs @@ -55,4 +55,11 @@ public IReadOnlyList ValidateSettings(string type, JsonElement settings) return fieldType.ValidateSettings(settings, this); } + + public IReadOnlyList ReferencedTypes(string type, JsonElement settings) + { + ArgumentNullException.ThrowIfNull(type); + + return Find(type)?.ReferencedTypes(settings, this) ?? []; + } } diff --git a/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs b/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs new file mode 100644 index 000000000..daba41a07 --- /dev/null +++ b/src/FluentCMS.Core/Schema/IContentTypeDirectory.cs @@ -0,0 +1,23 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Schema; + +// The stored content types of the site (SCH-01, SCH-07). Every write appends its audit event in the same unit of work (AUD-N1). +public interface IContentTypeDirectory +{ + Task> ListAsync(CancellationToken cancellationToken = default); + + Task> GetAsync(string slug, CancellationToken cancellationToken = default); + + // Earlier versions stay readable after a revision. + Task> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default); + + Task> CreateAsync(NewContentType contentType, CancellationToken cancellationToken = default); + + // The caller names the version it read, so a revision never silently replaces one it has not seen. + Task> ReviseAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Schema/IFieldType.cs b/src/FluentCMS.Core/Schema/IFieldType.cs index 4349b6043..7a0c6e58f 100644 --- a/src/FluentCMS.Core/Schema/IFieldType.cs +++ b/src/FluentCMS.Core/Schema/IFieldType.cs @@ -16,4 +16,11 @@ public interface IFieldType // A setting the type does not know is an error with SchemaErrorCodes.SettingUnknown; FieldSettingsReader does that bookkeeping. // Nested types, such as the items of a list, are checked through the registry. IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegistry fieldTypes); + + // The slugs of the content types a field of this type points at, so the schema can check that they exist. + // Only called with settings that passed ValidateSettings. + IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return []; + } } diff --git a/src/FluentCMS.Core/Schema/NewContentType.cs b/src/FluentCMS.Core/Schema/NewContentType.cs new file mode 100644 index 000000000..36ae7a560 --- /dev/null +++ b/src/FluentCMS.Core/Schema/NewContentType.cs @@ -0,0 +1,3 @@ +namespace FluentCMS.Core.Schema; + +public sealed record NewContentType(string Name, string Slug, ContentTypeDefinition Definition); diff --git a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs index aaa31a179..eba50402c 100644 --- a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs +++ b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs @@ -12,4 +12,9 @@ public static class SchemaErrorCodes public const string NameInvalid = "name_invalid"; public const string SlugInvalid = "slug_invalid"; public const string SiteMissing = "site_missing"; + public const string SlugTaken = "slug_taken"; + public const string ReferenceTargetUnknown = "reference_target_unknown"; + public const string ContentTypeNotFound = "content_type_not_found"; + public const string VersionNotFound = "version_not_found"; + public const string VersionConflict = "version_conflict"; } diff --git a/src/FluentCMS.Core/Sites/Site.cs b/src/FluentCMS.Core/Sites/Site.cs new file mode 100644 index 000000000..6abda1d69 --- /dev/null +++ b/src/FluentCMS.Core/Sites/Site.cs @@ -0,0 +1,7 @@ +namespace FluentCMS.Core.Sites; + +// One site per instance until multi-site (MST-01, M3); every site-scoped record carries its id from the start (SCH-07). +public sealed record Site(Guid Id, string Name) +{ + public static Site Default { get; } = new(new Guid("00000000-0000-0000-0000-000000000001"), "Default"); +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 8acd7e06b..baa760c14 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,9 +1,11 @@ using FluentCMS.Core.Audit; using FluentCMS.Core.Identity; using FluentCMS.Core.Persistence; +using FluentCMS.Core.Schema; using FluentCMS.Infrastructure.Audit; using FluentCMS.Infrastructure.Identity; using FluentCMS.Infrastructure.Persistence; +using FluentCMS.Infrastructure.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +23,7 @@ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollect AddDatabase(services, configuration.GetSection(DatabaseOptions.SectionName).Get() ?? new DatabaseOptions()); AddIdentity(services); AddAudit(services); + AddSchema(services); services.AddHostedService(); @@ -52,6 +55,12 @@ private static void AddAudit(IServiceCollection services) services.AddScoped(); } + // FieldTypeRegistry comes from AddFluentCmsCore, so built-in and plugin field types reach the directory the same way. + private static void AddSchema(IServiceCollection services) + { + services.AddScoped(); + } + private static void AddIdentity(IServiceCollection services) { services.AddIdentityCore(options => options.User.RequireUniqueEmail = true) diff --git a/src/FluentCMS.Infrastructure/Persistence/DatabaseErrors.cs b/src/FluentCMS.Infrastructure/Persistence/DatabaseErrors.cs new file mode 100644 index 000000000..b363171cf --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/DatabaseErrors.cs @@ -0,0 +1,24 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace FluentCMS.Infrastructure.Persistence; + +internal static class DatabaseErrors +{ + private const int SqliteConstraint = 19; + private const int SqliteConstraintPrimaryKey = 1555; + private const int SqliteConstraintUnique = 2067; + + // A concurrent writer passed the same check first and committed; the caller answers with the result its own check would give. + public static bool IsUniqueViolation(DbUpdateException exception) + { + return exception.InnerException switch + { + PostgresException postgres => postgres.SqlState == PostgresErrorCodes.UniqueViolation, + SqliteException sqlite => sqlite.SqliteErrorCode == SqliteConstraint + && sqlite.SqliteExtendedErrorCode is SqliteConstraintUnique or SqliteConstraintPrimaryKey, + _ => false, + }; + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index a52cf3519..8f1e356bb 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -1,4 +1,6 @@ using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Schema; +using FluentCMS.Infrastructure.Sites; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -10,11 +12,20 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC { public DbSet AuditEvents => Set(); + public DbSet Sites => Set(); + + public DbSet ContentTypes => Set(); + + public DbSet ContentTypeVersions => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new AuditEventConfiguration()); + builder.ApplyConfiguration(new SiteConfiguration()); + builder.ApplyConfiguration(new ContentTypeConfiguration()); + builder.ApplyConfiguration(new ContentTypeVersionConfiguration()); builder.Entity(user => { @@ -32,4 +43,13 @@ protected override void OnModelCreating(ModelBuilder builder) public sealed class SqliteFluentCmsDbContext(DbContextOptions options) : FluentCmsDbContext(options); -public sealed class PostgreSqlFluentCmsDbContext(DbContextOptions options) : FluentCmsDbContext(options); +public sealed class PostgreSqlFluentCmsDbContext(DbContextOptions options) : FluentCmsDbContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // Definitions are JSON documents, stored as jsonb where the provider has it (ADR-0005). + builder.Entity().Property(version => version.Definition).HasColumnType("jsonb"); + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018152618_ContentTypes.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018152618_ContentTypes.Designer.cs new file mode 100644 index 000000000..3f30b4673 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018152618_ContentTypes.Designer.cs @@ -0,0 +1,453 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +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.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261018152618_ContentTypes")] + partial class ContentTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018152618_ContentTypes.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018152618_ContentTypes.cs new file mode 100644 index 000000000..7237a2310 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261018152618_ContentTypes.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class ContentTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Sites", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sites", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ContentTypes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SiteId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Slug = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Version = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ContentTypes", x => x.Id); + table.CheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_ContentTypes_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ContentTypeVersions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContentTypeId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "integer", nullable: false), + Definition = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ContentTypeVersions", x => x.Id); + table.CheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_ContentTypeVersions_ContentTypes_ContentTypeId", + column: x => x.ContentTypeId, + principalTable: "ContentTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + table: "Sites", + columns: new[] { "Id", "Name" }, + values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "Default" }); + + migrationBuilder.CreateIndex( + name: "IX_ContentTypes_SiteId_Slug", + table: "ContentTypes", + columns: new[] { "SiteId", "Slug" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ContentTypeVersions_ContentTypeId_Version", + table: "ContentTypeVersions", + columns: new[] { "ContentTypeId", "Version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ContentTypeVersions"); + + migrationBuilder.DropTable( + name: "ContentTypes"); + + migrationBuilder.DropTable( + name: "Sites"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index cbc3fb059..2ecd89528 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -193,6 +193,86 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users", (string)null); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -296,6 +376,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserTokens", (string)null); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018152552_ContentTypes.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018152552_ContentTypes.Designer.cs new file mode 100644 index 000000000..b71e64827 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018152552_ContentTypes.Designer.cs @@ -0,0 +1,443 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261018152552_ContentTypes")] + partial class ContentTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018152552_ContentTypes.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018152552_ContentTypes.cs new file mode 100644 index 000000000..846feab92 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261018152552_ContentTypes.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class ContentTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Sites", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sites", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ContentTypes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SiteId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Slug = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ContentTypes", x => x.Id); + table.CheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_ContentTypes_Sites_SiteId", + column: x => x.SiteId, + principalTable: "Sites", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ContentTypeVersions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ContentTypeId = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false), + Definition = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ContentTypeVersions", x => x.Id); + table.CheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_ContentTypeVersions_ContentTypes_ContentTypeId", + column: x => x.ContentTypeId, + principalTable: "ContentTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + table: "Sites", + columns: new[] { "Id", "Name" }, + values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "Default" }); + + migrationBuilder.CreateIndex( + name: "IX_ContentTypes_SiteId_Slug", + table: "ContentTypes", + columns: new[] { "SiteId", "Slug" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ContentTypeVersions_ContentTypeId_Version", + table: "ContentTypeVersions", + columns: new[] { "ContentTypeId", "Version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ContentTypeVersions"); + + migrationBuilder.DropTable( + name: "ContentTypes"); + + migrationBuilder.DropTable( + name: "Sites"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 23f5f0107..f90476e0d 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -187,6 +187,86 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users", (string)null); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -286,6 +366,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserTokens", (string)null); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs new file mode 100644 index 000000000..3b1dfaae8 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeConfiguration.cs @@ -0,0 +1,23 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Schema; + +internal sealed class ContentTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("ContentTypes", table => table.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1")); + row.HasKey(type => type.Id); + row.Property(type => type.Id).ValueGeneratedNever(); + row.Property(type => type.Name).HasMaxLength(ContentType.MaxNameLength).IsRequired(); + row.Property(type => type.Slug).HasMaxLength(SchemaNames.MaxLength).IsRequired(); + + row.HasOne().WithMany().HasForeignKey(type => type.SiteId).OnDelete(DeleteBehavior.Restrict); + + // Also what stops two concurrent creates of one slug when both passed the check (SCH-07). + row.HasIndex(type => new { type.SiteId, type.Slug }).IsUnique(); + } +} diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs new file mode 100644 index 000000000..861e9d67a --- /dev/null +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -0,0 +1,252 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Schema; + +// Reads never track rows, so a long-lived context (a Blazor circuit) always sees what is stored, not what it saw before. +internal sealed class ContentTypeDirectory( + FluentCmsDbContext context, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + FieldTypeRegistry fieldTypes) : IContentTypeDirectory +{ + private const string SubjectType = "content_type"; + + // Until multi-site (MST-01), every type belongs to the one site (SCH-07). + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + return await context.ContentTypes + .AsNoTracking() + .Where(type => type.SiteId == SiteId) + .OrderBy(type => type.Slug) + .Select(type => new ContentTypeSummary(type.Id, type.Name, type.Slug, type.Version)) + .ToListAsync(cancellationToken); + } + + public Task> GetAsync(string slug, CancellationToken cancellationToken = default) + { + return ReadAsync(slug, version: null, cancellationToken); + } + + public Task> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default) + { + return ReadAsync(slug, version, cancellationToken); + } + + public async Task> CreateAsync(NewContentType contentType, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(contentType); + + var created = ContentType.Create(SiteId, contentType.Name, contentType.Slug, contentType.Definition, fieldTypes); + if (!created.IsSuccess) + { + return created; + } + + var type = created.Value; + var errors = new List(); + + // Checked before the unit of work begins, so an expected failure leaves a caller's outer unit of work able to commit. + if (await context.ContentTypes.AnyAsync(row => row.SiteId == SiteId && row.Slug == type.Slug, cancellationToken)) + { + errors.Add(SlugTaken(type.Slug)); + } + + errors.AddRange(await UnknownTargetsAsync(type, cancellationToken)); + if (errors.Count > 0) + { + return Result.Failure(errors); + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + context.ContentTypes.Add(new ContentTypeRow + { + Id = type.Id, + SiteId = type.SiteId, + Name = type.Name, + Slug = type.Slug, + Version = type.Version, + }); + context.ContentTypeVersions.Add(VersionRow(type)); + await auditLog.AppendAsync(new NewAuditEvent("content_type.created") { SubjectType = SubjectType, SubjectId = type.Id }, cancellationToken); + + return await CommitAsync(scope, type, SlugTaken(type.Slug), cancellationToken); + } + + public async Task> ReviseAsync( + string slug, + int baseVersion, + ContentTypeDefinition definition, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(slug); + ArgumentNullException.ThrowIfNull(definition); + + var row = await FindAsync(slug, cancellationToken); + if (row is null) + { + return NotFound(slug); + } + + if (row.Version != baseVersion) + { + return Result.Failure([VersionConflict(slug, baseVersion)]); + } + + var current = await context.ContentTypeVersions + .AsNoTracking() + .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == row.Version, cancellationToken); + + var revised = ToContentType(row, current).Revise(definition, fieldTypes); + if (!revised.IsSuccess) + { + return revised; + } + + var type = revised.Value; + var unknownTargets = await UnknownTargetsAsync(type, cancellationToken); + if (unknownTargets.Count > 0) + { + return Result.Failure(unknownTargets); + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + // Moves the pointer only from the version the caller read; a concurrent revision that committed first leaves nothing to move. + var moved = await context.ContentTypes + .Where(stored => stored.Id == row.Id && stored.Version == baseVersion) + .ExecuteUpdateAsync(setters => setters.SetProperty(stored => stored.Version, type.Version), cancellationToken); + if (moved == 0) + { + return Result.Failure([VersionConflict(slug, baseVersion)]); + } + + context.ContentTypeVersions.Add(VersionRow(type)); + await auditLog.AppendAsync( + new NewAuditEvent("content_type.revised") { SubjectType = SubjectType, SubjectId = type.Id, PreviousVersionId = current.Id }, + cancellationToken); + + return await CommitAsync(scope, type, VersionConflict(slug, baseVersion), cancellationToken); + } + + private async Task> ReadAsync(string slug, int? version, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(slug); + + var row = await FindAsync(slug, cancellationToken); + if (row is null) + { + return NotFound(slug); + } + + var number = version ?? row.Version; + var stored = await context.ContentTypeVersions + .AsNoTracking() + .SingleOrDefaultAsync(candidate => candidate.ContentTypeId == row.Id && candidate.Version == number, cancellationToken); + + return stored is null + ? Result.Failure( + SchemaErrorCodes.VersionNotFound, + $"The content type '{slug}' has no version {number}; its current version is {row.Version}.") + : Result.Success(ToContentType(row, stored)); + } + + private Task FindAsync(string slug, CancellationToken cancellationToken) + { + return context.ContentTypes + .AsNoTracking() + .SingleOrDefaultAsync(row => row.SiteId == SiteId && row.Slug == slug, cancellationToken); + } + + // A reference to the type being written is allowed, so a category can have a parent category. + private async Task> UnknownTargetsAsync(ContentType type, CancellationToken cancellationToken) + { + var references = type.Definition.References(fieldTypes).Where(reference => reference.Target != type.Slug).ToList(); + if (references.Count == 0) + { + return []; + } + + var targets = references.Select(reference => reference.Target).Distinct().ToList(); + var existing = await context.ContentTypes + .AsNoTracking() + .Where(row => row.SiteId == type.SiteId && targets.Contains(row.Slug)) + .Select(row => row.Slug) + .ToListAsync(cancellationToken); + + return references + .Where(reference => !existing.Contains(reference.Target)) + .Select(reference => new Error( + SchemaErrorCodes.ReferenceTargetUnknown, + $"Field '{reference.Field}': there is no content type '{reference.Target}' in this site to reference.")) + .ToList(); + } + + private static async Task> CommitAsync( + IUnitOfWorkScope scope, + ContentType type, + Error conflict, + CancellationToken cancellationToken) + { + try + { + await scope.CommitAsync(cancellationToken); + } + catch (DbUpdateException exception) when (DatabaseErrors.IsUniqueViolation(exception)) + { + // The scope rolls back when the caller disposes it. + return Result.Failure([conflict]); + } + + return Result.Success(type); + } + + private static ContentTypeVersionRow VersionRow(ContentType type) + { + return new ContentTypeVersionRow + { + Id = Guid.CreateVersion7(), + ContentTypeId = type.Id, + Version = type.Version, + Definition = type.Definition.ToJson(), + }; + } + + private static ContentType ToContentType(ContentTypeRow row, ContentTypeVersionRow version) + { + var definition = ContentTypeDefinition.Parse(version.Definition); + if (!definition.IsSuccess) + { + throw new InvalidOperationException( + $"Version {version.Version} of content type '{row.Slug}' is stored in a form that cannot be read: " + + string.Join("; ", definition.Errors.Select(error => error.Message))); + } + + return ContentType.Restore(row.Id, row.SiteId, row.Name, row.Slug, version.Version, definition.Value); + } + + private static Result NotFound(string slug) + { + return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{slug}' in this site."); + } + + private static Error SlugTaken(string slug) + { + return new Error(SchemaErrorCodes.SlugTaken, $"The slug '{slug}' is already used by another content type in this site."); + } + + private static Error VersionConflict(string slug, int baseVersion) + { + return new Error( + SchemaErrorCodes.VersionConflict, + $"Version {baseVersion} is not the current version of the content type '{slug}'; read it again and revise the current version."); + } +} diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs new file mode 100644 index 000000000..0963666b6 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeRow.cs @@ -0,0 +1,15 @@ +namespace FluentCMS.Infrastructure.Schema; + +// A content type and the number of its current version; each version's definition is its own ContentTypeVersionRow. +public sealed class ContentTypeRow +{ + public Guid Id { get; set; } + + public Guid SiteId { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Slug { get; set; } = string.Empty; + + public int Version { get; set; } +} diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeVersionConfiguration.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeVersionConfiguration.cs new file mode 100644 index 000000000..89e4aba38 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeVersionConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Schema; + +internal sealed class ContentTypeVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("ContentTypeVersions", table => table.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1")); + row.HasKey(version => version.Id); + row.Property(version => version.Id).ValueGeneratedNever(); + row.Property(version => version.Definition).IsRequired(); + + row.HasOne().WithMany().HasForeignKey(version => version.ContentTypeId).OnDelete(DeleteBehavior.Restrict); + row.HasIndex(version => new { version.ContentTypeId, version.Version }).IsUnique(); + } +} diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeVersionRow.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeVersionRow.cs new file mode 100644 index 000000000..67525c492 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeVersionRow.cs @@ -0,0 +1,13 @@ +namespace FluentCMS.Infrastructure.Schema; + +// One version of a content type definition; inserted once and never changed, so earlier versions stay readable. +public sealed class ContentTypeVersionRow +{ + public Guid Id { get; set; } + + public Guid ContentTypeId { get; set; } + + public int Version { get; set; } + + public string Definition { get; set; } = string.Empty; +} diff --git a/src/FluentCMS.Infrastructure/Sites/SiteConfiguration.cs b/src/FluentCMS.Infrastructure/Sites/SiteConfiguration.cs new file mode 100644 index 000000000..06a5c8d9a --- /dev/null +++ b/src/FluentCMS.Infrastructure/Sites/SiteConfiguration.cs @@ -0,0 +1,19 @@ +using FluentCMS.Core.Sites; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Sites; + +internal sealed class SiteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("Sites"); + row.HasKey(site => site.Id); + row.Property(site => site.Id).ValueGeneratedNever(); + row.Property(site => site.Name).HasMaxLength(128).IsRequired(); + + // The one site exists from the migration on, so every site-scoped row has a site to point at (SCH-07). + row.HasData(new SiteRow { Id = Site.Default.Id, Name = Site.Default.Name }); + } +} diff --git a/src/FluentCMS.Infrastructure/Sites/SiteRow.cs b/src/FluentCMS.Infrastructure/Sites/SiteRow.cs new file mode 100644 index 000000000..8c4e6c35f --- /dev/null +++ b/src/FluentCMS.Infrastructure/Sites/SiteRow.cs @@ -0,0 +1,8 @@ +namespace FluentCMS.Infrastructure.Sites; + +public sealed class SiteRow +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; +} diff --git a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs new file mode 100644 index 000000000..776c6909b --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs @@ -0,0 +1,328 @@ +using System.Text.Json; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Schema.SchemaScenario; + +namespace FluentCMS.IntegrationTests.Schema; + +public sealed class ContentTypeDirectoryTests +{ + private static readonly FieldDefinition[] ProductFields = + [ + Field("name", "text", """{"maxLength": 200}""", required: true), + Field("price", "number", """{"min": 0, "max": 99999.99}""", required: true), + Field("tags", "list", """{"itemType": "text"}"""), + ]; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_created_type_belongs_to_the_one_site_starts_at_version_one_and_is_audited(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + var created = (await scenario.CreateAsync("product", ProductFields)).Value; + var read = (await scenario.RunAsync(directory => directory.GetAsync("product"))).Value; + + Assert.Equal(Site.Default.Id, created.SiteId); + Assert.Equal(1, created.Version); + Assert.Equal(created.Id, read.Id); + Assert.Equal(Site.Default.Id, read.SiteId); + Assert.Equal("Product", read.Name); + Assert.Equal("product", read.Slug); + Assert.Equal(1, read.Version); + AssertSameDefinition(created.Definition, read.Definition); + + Assert.Equal( + [new ContentTypeSummary(created.Id, "Product", "product", 1)], + await scenario.RunAsync(directory => directory.ListAsync())); + Assert.Equal(Site.Default.Id, Assert.Single(await scenario.ContentTypeRowsAsync()).SiteId); + Assert.Equal( + [Site.Default.Id], + await scenario.QueryAsync(context => context.Sites.Select(site => site.Id).ToListAsync())); + + var auditEvent = Assert.Single(await scenario.AuditRowsAsync()); + Assert.Equal("content_type.created", auditEvent.Operation); + Assert.Equal("content_type", auditEvent.SubjectType); + Assert.Equal(created.Id, auditEvent.SubjectId); + Assert.Equal(Amir.UserId, auditEvent.InitiatorId); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_stores_the_next_version_and_the_earlier_one_stays_readable(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + var original = (await scenario.CreateAsync("product", ProductFields)).Value; + var revisedDefinition = new ContentTypeDefinition([.. ProductFields, Field("releasedOn", "dateTime", """{"dateOnly": true}""")]); + + var revised = (await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, revisedDefinition))).Value; + + Assert.Equal(2, revised.Version); + Assert.Equal(original.Id, revised.Id); + var current = (await scenario.RunAsync(directory => directory.GetAsync("product"))).Value; + var first = (await scenario.RunAsync(directory => directory.GetVersionAsync("product", 1))).Value; + Assert.Equal(2, current.Version); + AssertSameDefinition(revisedDefinition, current.Definition); + Assert.Equal(1, first.Version); + AssertSameDefinition(original.Definition, first.Definition); + Assert.Equal(2, Assert.Single(await scenario.ContentTypeRowsAsync()).Version); + + var missing = await scenario.RunAsync(directory => directory.GetVersionAsync("product", 3)); + Assert.Equal(SchemaErrorCodes.VersionNotFound, Assert.Single(missing.Errors).Code); + + var versions = await scenario.VersionRowsAsync(); + var revision = Assert.Single(await scenario.AuditRowsAsync(), row => row.Operation == "content_type.revised"); + Assert.Equal([1, 2], versions.Select(version => version.Version)); + Assert.Equal(original.Id, revision.SubjectId); + Assert.Equal(versions[0].Id, revision.PreviousVersionId); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_slug_is_unique_within_the_site(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateAsync("product", ProductFields); + + var second = await scenario.CreateAsync("product", Field("title", "text")); + + Assert.Equal(SchemaErrorCodes.SlugTaken, Assert.Single(second.Errors).Code); + await AssertStoredAsync(scenario, types: 1, versions: 1, events: 1); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_reference_targets_a_stored_type_of_the_site_or_the_type_itself(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + var tooEarly = await scenario.CreateAsync("product-page", Reference("product", "product"), References("related", "category")); + + Assert.Equal( + [ + "Field 'product': there is no content type 'product' in this site to reference.", + "Field 'related': there is no content type 'category' in this site to reference.", + ], + tooEarly.Errors.Select(error => error.Message)); + Assert.All(tooEarly.Errors, error => Assert.Equal(SchemaErrorCodes.ReferenceTargetUnknown, error.Code)); + await AssertStoredAsync(scenario, types: 0, versions: 0, events: 0); + + Assert.True((await scenario.CreateAsync("category", Field("name", "text"), Reference("parent", "category"))).IsSuccess); + Assert.True((await scenario.CreateAsync("product", ProductFields)).IsSuccess); + Assert.True((await scenario.CreateAsync("product-page", Reference("product", "product"), References("related", "category"))).IsSuccess); + + var revision = await scenario.RunAsync(directory => directory.ReviseAsync( + "category", + 1, + new ContentTypeDefinition([Field("name", "text"), References("regions", "region")]))); + + Assert.Equal(SchemaErrorCodes.ReferenceTargetUnknown, Assert.Single(revision.Errors).Code); + await AssertStoredAsync(scenario, types: 3, versions: 3, events: 3); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_starts_from_the_current_version(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateAsync("product", ProductFields); + var definition = new ContentTypeDefinition([.. ProductFields, Field("summary", "text")]); + + var first = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, definition)); + var stale = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, definition)); + var ahead = await scenario.RunAsync(directory => directory.ReviseAsync("product", 5, definition)); + + Assert.Equal(2, first.Value.Version); + Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(stale.Errors).Code); + Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(ahead.Errors).Code); + await AssertStoredAsync(scenario, types: 1, versions: 2, events: 2); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Invalid_definitions_and_unknown_types_fail_without_writing(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + var invalid = await scenario.CreateAsync("product", Field("price", "money"), Field("price", "number")); + var unknownRead = await scenario.RunAsync(directory => directory.GetAsync("product")); + var unknownRevision = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([]))); + + Assert.Equal([SchemaErrorCodes.FieldTypeUnknown, SchemaErrorCodes.FieldNameDuplicate], invalid.Errors.Select(error => error.Code)); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Assert.Single(unknownRead.Errors).Code); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Assert.Single(unknownRevision.Errors).Code); + Assert.Empty(await scenario.RunAsync(directory => directory.ListAsync())); + + await scenario.CreateAsync("product", ProductFields); + var invalidRevision = await scenario.RunAsync(directory => directory.ReviseAsync("product", 1, new ContentTypeDefinition([Field("Name", "text")]))); + + Assert.Equal(SchemaErrorCodes.FieldNameInvalid, Assert.Single(invalidRevision.Errors).Code); + await AssertStoredAsync(scenario, types: 1, versions: 1, events: 1); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_create_in_an_abandoned_unit_of_work_leaves_neither_the_type_nor_its_event(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + await using (await scope.ServiceProvider.GetRequiredService().BeginAsync()) + { + var created = await scope.ServiceProvider.GetRequiredService() + .CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields))); + Assert.True(created.IsSuccess); + } + } + + await AssertStoredAsync(scenario, types: 0, versions: 0, events: 0); + } + + // Expected failures are found before the directory opens its own unit of work, so they never doom the caller's. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_failed_create_leaves_the_callers_unit_of_work_able_to_commit(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var directory = scope.ServiceProvider.GetRequiredService(); + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + Assert.True((await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields)))).IsSuccess); + var duplicate = await directory.CreateAsync(new NewContentType("Product again", "product", new ContentTypeDefinition(ProductFields))); + var dangling = await directory.CreateAsync(new NewContentType("Page", "page", new ContentTypeDefinition([Reference("about", "nothing")]))); + + Assert.Equal(SchemaErrorCodes.SlugTaken, Assert.Single(duplicate.Errors).Code); + Assert.Equal(SchemaErrorCodes.ReferenceTargetUnknown, Assert.Single(dangling.Errors).Code); + await work.CommitAsync(); + } + + await AssertStoredAsync(scenario, types: 1, versions: 1, events: 1); + } + + // The second writer checks while the first has not committed, so its check passes and the unique index has the last word. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_two_concurrent_creates_of_one_slug_one_wins_and_the_other_gets_slug_taken(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + var product = new NewContentType("Product", "product", new ContentTypeDefinition(ProductFields)); + + var lost = await RaceAsync(scenario, directory => directory.CreateAsync(product), directory => directory.CreateAsync(product)); + + Assert.Equal(SchemaErrorCodes.SlugTaken, Assert.Single(lost.Errors).Code); + await AssertStoredAsync(scenario, types: 1, versions: 1, events: 1); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_two_concurrent_revisions_of_one_version_one_wins_and_the_other_gets_a_conflict(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateAsync("product", ProductFields); + var definition = new ContentTypeDefinition([.. ProductFields, Field("summary", "text")]); + + var lost = await RaceAsync( + scenario, + directory => directory.ReviseAsync("product", 1, definition), + directory => directory.ReviseAsync("product", 1, definition)); + + Assert.Equal(SchemaErrorCodes.VersionConflict, Assert.Single(lost.Errors).Code); + Assert.Equal(2, Assert.Single(await scenario.ContentTypeRowsAsync()).Version); + await AssertStoredAsync(scenario, types: 1, versions: 2, events: 2); + } + + // The model behind the reference scenario (02-personas-and-scenarios), without the entity flag and field references (SCH-03, SCH-04). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_reference_scenario_model_can_be_defined(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + (string Slug, FieldDefinition[] Fields)[] model = + [ + ("category", [Field("name", "text", required: true), Reference("parent", "category")]), + ("region", [Field("name", "text", required: true), Field("code", "text", """{"maxLength": 8}""")]), + ("spec", [Field("name", "text", required: true), Field("value", "text"), Field("unit", "enum", """{"values": ["mm", "kg", "W"]}""")]), + ("product", [.. ProductFields, Reference("category", "category"), References("specs", "spec"), References("regions", "region"), Field("hero", "media")]), + ("product-page", [Reference("product", "product"), Field("headline", "text"), Field("body", "richText")]), + ("category-page", [Reference("category", "category"), Field("intro", "richText")]), + ("comparison-page", [References("products", "product"), Field("verdict", "richText"), Field("extra", "json")]), + ("blog-post", [Field("title", "text", required: true), Field("body", "richText"), Field("publishedOn", "dateTime"), Field("featured", "boolean"), References("related", "product")]), + ]; + + foreach (var (slug, fields) in model) + { + var created = await scenario.CreateAsync(slug, fields); + Assert.True(created.IsSuccess, $"{slug}: {string.Join("; ", created.Errors.Select(error => error.Message))}"); + } + + Assert.Equal( + model.Select(type => type.Slug).Order(StringComparer.Ordinal), + (await scenario.RunAsync(directory => directory.ListAsync())).Select(summary => summary.Slug)); + } + + [PostgresFact] + public async Task Definitions_are_stored_as_jsonb_on_postgresql() + { + await using var scenario = await SchemaScenario.CreateAsync(DatabaseProviders.PostgreSql); + + var type = await scenario.QueryAsync(context => context.Database + .SqlQueryRaw( + "SELECT data_type AS \"Value\" FROM information_schema.columns WHERE table_name = 'ContentTypeVersions' AND column_name = 'Definition'") + .SingleAsync()); + + Assert.Equal("jsonb", type); + } + + private static async Task> RaceAsync( + SchemaScenario scenario, + Func>> winner, + Func>> loser) + { + await using var scope = scenario.Services.CreateAsyncScope(); + var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + + Assert.True((await winner(scope.ServiceProvider.GetRequiredService())).IsSuccess); + + var second = Task.Run(() => scenario.RunAsync(loser)); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + await work.CommitAsync(); + await work.DisposeAsync(); + + return await second; + } + + private static async Task AssertStoredAsync(SchemaScenario scenario, int types, int versions, int events) + { + Assert.Equal(types, (await scenario.ContentTypeRowsAsync()).Count); + Assert.Equal(versions, (await scenario.VersionRowsAsync()).Count); + Assert.Equal(events, (await scenario.AuditRowsAsync()).Count); + } + + // PostgreSQL's jsonb keeps the meaning of a document, not its text, so definitions are compared as JSON. + private static void AssertSameDefinition(ContentTypeDefinition expected, ContentTypeDefinition actual) + { + Assert.True( + JsonElement.DeepEquals(JsonSerializer.Deserialize(expected.ToJson()), JsonSerializer.Deserialize(actual.ToJson())), + $"Expected {expected.ToJson()} but read {actual.ToJson()}"); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs new file mode 100644 index 000000000..0c952e0e9 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.Infrastructure.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Schema; + +// A migrated database on one provider with the content type directory acting as one user. +internal sealed class SchemaScenario : IAsyncDisposable +{ + public static readonly UserInitiator Amir = new(Guid.NewGuid(), "Amir"); + + private readonly TemporaryDatabase _database; + + private SchemaScenario(TemporaryDatabase database, ServiceProvider services) + { + _database = database; + Services = services; + } + + public ServiceProvider Services { get; } + + public static async Task CreateAsync(string provider) + { + var database = await TemporaryDatabase.CreateAsync(provider); + var services = TestServices.Build( + database.Configuration, + collection => collection.AddScoped(_ => new TestInitiator(Amir))); + + await TestServices.MigrateAsync(services); + return new SchemaScenario(database, services); + } + + // Each call gets its own scope and DbContext, as separate requests would. + public async Task RunAsync(Func> action) + { + await using var scope = Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + public Task> CreateAsync(string slug, params FieldDefinition[] fields) + { + return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields)))); + } + + public async Task> ContentTypeRowsAsync() + { + return await QueryAsync(context => context.ContentTypes.AsNoTracking().ToListAsync()); + } + + public async Task> VersionRowsAsync() + { + return await QueryAsync(context => context.ContentTypeVersions.AsNoTracking().OrderBy(version => version.Version).ToListAsync()); + } + + public async Task> AuditRowsAsync() + { + return await QueryAsync(context => context.AuditEvents.AsNoTracking().ToListAsync()); + } + + public async Task QueryAsync(Func> query) + { + await using var scope = Services.CreateAsyncScope(); + return await query(scope.ServiceProvider.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + await Services.DisposeAsync(); + await _database.DisposeAsync(); + } + + public static FieldDefinition Field(string name, string type, string settings = "{}", bool required = false) + { + return new FieldDefinition(name, type, required, JsonSerializer.Deserialize(settings)); + } + + public static FieldDefinition Reference(string name, string target) + { + return Field(name, "reference", $$"""{"target": "{{target}}"}"""); + } + + public static FieldDefinition References(string name, string target) + { + return Field(name, "list", $$$"""{"itemType": "reference", "itemSettings": {"target": "{{{target}}}"}}"""); + } + + private static string Title(string slug) + { + return string.Join(' ', slug.Split('-').Select(word => char.ToUpperInvariant(word[0]) + word[1..])); + } +} diff --git a/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs b/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs index bce867643..89ffc2128 100644 --- a/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs @@ -113,6 +113,12 @@ public sealed class ReferenceFieldTypeTests : FieldTypeConformanceTests protected override IEnumerable RejectedSettings => ["{}", """{"target": "Product"}""", """{"target": "product page"}""", """{"target": ""}""", """{"target": 5}"""]; + + [Fact] + public void Declares_its_target_as_referenced() + { + Assert.Equal(["product"], Registry.ReferencedTypes(FieldTypeName, Parse("""{"target": "product"}"""))); + } } public sealed class ListFieldTypeTests : FieldTypeConformanceTests @@ -146,6 +152,13 @@ public void A_list_of_lists_is_rejected() Assert.Contains("cannot be 'list'", error.Message); } + [Fact] + public void Declares_what_its_item_type_references() + { + Assert.Equal(["product"], Registry.ReferencedTypes(FieldTypeName, Parse("""{"itemType": "reference", "itemSettings": {"target": "product"}}"""))); + Assert.Empty(Registry.ReferencedTypes(FieldTypeName, Parse("""{"itemType": "text"}"""))); + } + [Fact] public void The_item_type_checks_the_item_settings() { diff --git a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs index fcfbe4820..6b5946741 100644 --- a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs +++ b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs @@ -175,6 +175,14 @@ public void Validation_reports_every_problem_and_names_its_field() result.Errors.Select(error => error.Message)); } + [Fact] + public void References_list_each_field_that_points_at_a_content_type() + { + var definition = ContentTypeDefinition.Parse(EveryFieldType).Value; + + Assert.Equal([new FieldReference("category", "category")], definition.References(FieldTypes)); + } + [Fact] public void Field_settings_must_be_an_object() { diff --git a/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs b/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs index 9e2c4e002..40680067d 100644 --- a/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs @@ -113,6 +113,23 @@ public void An_invalid_revision_fails_and_leaves_the_type_as_it_was() Assert.Same(definition, original.Definition); } + [Fact] + public void A_restored_type_keeps_what_was_stored_without_validating_it_again() + { + var id = Guid.NewGuid(); + var stored = new ContentTypeDefinition([new FieldDefinition("rating", "sample.removed")]); + + var restored = ContentType.Restore(id, Site, "Review", "review", 4, stored); + + Assert.Equal(id, restored.Id); + Assert.Equal(Site, restored.SiteId); + Assert.Equal("Review", restored.Name); + Assert.Equal("review", restored.Slug); + Assert.Equal(4, restored.Version); + Assert.Same(stored, restored.Definition); + Assert.False(restored.Revise(stored, FieldTypes).IsSuccess); + } + public void Dispose() { _provider.Dispose(); diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs index 2d01889df..ded686115 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs @@ -86,6 +86,17 @@ public void Rejects_a_setting_it_does_not_know() } } + [Fact] + public void Declares_only_slugs_as_referenced_types() + { + foreach (var json in AcceptedSettings) + { + var referenced = Registry.ReferencedTypes(FieldTypeName, Parse(json)); + + Assert.All(referenced, slug => Assert.True(SchemaNames.IsSlug(slug), $"{json} declared '{slug}', which is not a slug.")); + } + } + // A definition comes from an agent, so a setting can hold anything; the answer is an error, never an exception. [Fact] public void Never_throws_whatever_a_setting_holds() diff --git a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs index 1f6585cc3..67b140a62 100644 --- a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs @@ -93,3 +93,54 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } } + +public sealed class LinkFieldTypeTests : FieldTypeConformanceTests +{ + protected override string FieldTypeName => "sample.link"; + + protected override IEnumerable AcceptedSettings => ["""{"to": "product"}"""]; + + protected override IEnumerable RejectedSettings => ["{}", """{"to": "Product"}"""]; + + protected override void AddFieldTypes(IServiceCollection services) + { + services.AddFluentCmsExtensions(typeof(LinkFieldType).Assembly); + } + + // A plugin type that points at entries declares its targets like the built-in reference does, so the same existence check applies. + [Fact] + public void Its_targets_count_as_references_of_a_definition() + { + var definition = new ContentTypeDefinition( + [ + new FieldDefinition("seeAlso", "sample.link", settings: JsonSerializer.Deserialize("""{"to": "product"}""")), + new FieldDefinition("category", "reference", settings: JsonSerializer.Deserialize("""{"target": "category"}""")), + ]); + + Assert.Equal( + [new FieldReference("seeAlso", "product"), new FieldReference("category", "category")], + definition.References(Registry)); + } +} + +public sealed class LinkFieldType : IFieldType +{ + public string Name => "sample.link"; + + public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var reader = new FieldSettingsReader(Name, settings); + + if (reader.RequiredString("to") is { } to && !SchemaNames.IsSlug(to)) + { + reader.Invalid("to", "must be the slug of a content type"); + } + + return reader.Errors(); + } + + public IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return [settings.GetProperty("to").GetString()!]; + } +}