Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContract, TImplementation>()` 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<TContract>` 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

Expand Down
9 changes: 9 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,13 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public IReadOnlyList<string> 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);
}
}
5 changes: 5 additions & 0 deletions src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi

return reader.Errors();
}

public IReadOnlyList<string> ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return [settings.GetProperty("target").GetString()!];
}
}
11 changes: 11 additions & 0 deletions src/FluentCMS.Core/Schema/ContentType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ public static Result<ContentType> Create(
: Result<ContentType>.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<ContentType> Revise(ContentTypeDefinition definition, FieldTypeRegistry fieldTypes)
{
Expand Down
10 changes: 10 additions & 0 deletions src/FluentCMS.Core/Schema/ContentTypeDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FieldReference> 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<FieldDefinition> ReadFields(JsonElement root, List<Error> errors)
{
var fields = new List<FieldDefinition>();
Expand Down
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/Schema/ContentTypeSummary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace FluentCMS.Core.Schema;

public sealed record ContentTypeSummary(Guid Id, string Name, string Slug, int Version);
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/Schema/FieldReference.cs
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 7 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypeRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,11 @@ public IReadOnlyList<Error> ValidateSettings(string type, JsonElement settings)

return fieldType.ValidateSettings(settings, this);
}

public IReadOnlyList<string> ReferencedTypes(string type, JsonElement settings)
{
ArgumentNullException.ThrowIfNull(type);

return Find(type)?.ReferencedTypes(settings, this) ?? [];
}
}
23 changes: 23 additions & 0 deletions src/FluentCMS.Core/Schema/IContentTypeDirectory.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<ContentTypeSummary>> ListAsync(CancellationToken cancellationToken = default);

Task<Result<ContentType>> GetAsync(string slug, CancellationToken cancellationToken = default);

// Earlier versions stay readable after a revision.
Task<Result<ContentType>> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default);

Task<Result<ContentType>> 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<Result<ContentType>> ReviseAsync(
string slug,
int baseVersion,
ContentTypeDefinition definition,
CancellationToken cancellationToken = default);
}
7 changes: 7 additions & 0 deletions src/FluentCMS.Core/Schema/IFieldType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Error> 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<string> ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return [];
}
}
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/Schema/NewContentType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace FluentCMS.Core.Schema;

public sealed record NewContentType(string Name, string Slug, ContentTypeDefinition Definition);
5 changes: 5 additions & 0 deletions src/FluentCMS.Core/Schema/SchemaErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
7 changes: 7 additions & 0 deletions src/FluentCMS.Core/Sites/Site.cs
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,6 +23,7 @@ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollect
AddDatabase(services, configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>() ?? new DatabaseOptions());
AddIdentity(services);
AddAudit(services);
AddSchema(services);

services.AddHostedService<DatabaseMigrator>();

Expand Down Expand Up @@ -52,6 +55,12 @@ private static void AddAudit(IServiceCollection services)
services.AddScoped<IAuditReader, AuditReader>();
}

// 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<IContentTypeDirectory, ContentTypeDirectory>();
}

private static void AddIdentity(IServiceCollection services)
{
services.AddIdentityCore<FluentCmsUser>(options => options.User.RequireUniqueEmail = true)
Expand Down
24 changes: 24 additions & 0 deletions src/FluentCMS.Infrastructure/Persistence/DatabaseErrors.cs
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
22 changes: 21 additions & 1 deletion src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,11 +12,20 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC
{
public DbSet<AuditEventRow> AuditEvents => Set<AuditEventRow>();

public DbSet<SiteRow> Sites => Set<SiteRow>();

public DbSet<ContentTypeRow> ContentTypes => Set<ContentTypeRow>();

public DbSet<ContentTypeVersionRow> ContentTypeVersions => Set<ContentTypeVersionRow>();

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<FluentCmsUser>(user =>
{
Expand All @@ -32,4 +43,13 @@ protected override void OnModelCreating(ModelBuilder builder)

public sealed class SqliteFluentCmsDbContext(DbContextOptions<SqliteFluentCmsDbContext> options) : FluentCmsDbContext(options);

public sealed class PostgreSqlFluentCmsDbContext(DbContextOptions<PostgreSqlFluentCmsDbContext> options) : FluentCmsDbContext(options);
public sealed class PostgreSqlFluentCmsDbContext(DbContextOptions<PostgreSqlFluentCmsDbContext> 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<ContentTypeVersionRow>().Property(version => version.Definition).HasColumnType("jsonb");
}
}
Loading
Loading