From 84d306c0ef8a5f140d84ea41aa2349284f01fdf9 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 10:04:36 -0500 Subject: [PATCH] Map fields by their registry storage kind and validate storage at build time --- src/FluentCMS.Core/Schema/SchemaBuilder.cs | 2 +- .../Schema/SchemaModelFactory.cs | 6 +- src/FluentCMS.Core/Schema/SchemaValidator.cs | 100 +++++++++++ .../Storage/ContentModelBuilder.cs | 78 ++------- src/FluentCMS.Core/Storage/FieldStorage.cs | 77 ++++---- src/FluentCMS.Core/Storage/StorageNames.cs | 33 +++- src/FluentCMS.Sdk/Schema/SchemaModel.cs | 6 +- .../Schema/StorageSchemaValidationTests.cs | 164 ++++++++++++++++++ .../Storage/FieldStorageTests.cs | 29 ++-- .../Storage/ContentModelTests.cs | 66 ------- .../Storage/PluginFieldStorageTests.cs | 128 ++++++++++++++ .../Storage/PluginFieldTypes.cs | 36 ++++ 12 files changed, 535 insertions(+), 190 deletions(-) create mode 100644 tests/FluentCMS.Core.Tests/Schema/StorageSchemaValidationTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Storage/PluginFieldStorageTests.cs create mode 100644 tests/FluentCMS.Data.Tests/Storage/PluginFieldTypes.cs diff --git a/src/FluentCMS.Core/Schema/SchemaBuilder.cs b/src/FluentCMS.Core/Schema/SchemaBuilder.cs index d1aa473ff..49dd1a77c 100644 --- a/src/FluentCMS.Core/Schema/SchemaBuilder.cs +++ b/src/FluentCMS.Core/Schema/SchemaBuilder.cs @@ -80,7 +80,7 @@ public SchemaModel Build() throw new SchemaValidationException(errors); } - return SchemaModelFactory.Create(_types); + return SchemaModelFactory.Create(_types, _fieldTypes); } ISchemaBuilder ISchemaBuilder.AddContentType() => AddContentType(); diff --git a/src/FluentCMS.Core/Schema/SchemaModelFactory.cs b/src/FluentCMS.Core/Schema/SchemaModelFactory.cs index 0cbaa335c..33cce517c 100644 --- a/src/FluentCMS.Core/Schema/SchemaModelFactory.cs +++ b/src/FluentCMS.Core/Schema/SchemaModelFactory.cs @@ -6,7 +6,7 @@ namespace FluentCMS.Core.Schema; internal static class SchemaModelFactory { // Expects drafts that passed validation: unique names, relation targets that exist. - public static SchemaModel Create(IReadOnlyList drafts) + public static SchemaModel Create(IReadOnlyList drafts, IFieldTypeRegistry fieldTypes) { var reverseRelations = drafts .Where(type => type.IsContentType) @@ -24,10 +24,10 @@ public static SchemaModel Create(IReadOnlyList drafts) ClrType = draft.ClrType, Fields = draft.Fields.Select(Create).ToList().AsReadOnly(), ReverseRelations = (draft.IsContentType ? reverseRelations[draft.Name] : []).OrderBy(relation => relation.Name, StringComparer.Ordinal).ToList().AsReadOnly(), - })); + }), fieldTypes); } - private static SchemaField Create(FieldDraft draft) => new() + public static SchemaField Create(FieldDraft draft) => new() { Name = draft.Name, FieldType = draft.FieldType, diff --git a/src/FluentCMS.Core/Schema/SchemaValidator.cs b/src/FluentCMS.Core/Schema/SchemaValidator.cs index dd337c0e0..e85e81bbe 100644 --- a/src/FluentCMS.Core/Schema/SchemaValidator.cs +++ b/src/FluentCMS.Core/Schema/SchemaValidator.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.RegularExpressions; using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Storage; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -51,6 +52,12 @@ public IReadOnlyList Validate(IReadOnlyList types) ValidateReverseRelations(unique.Where(type => type.IsContentType), contentTypes); ValidateNesting(unique, components, blocks); + ValidateTables(unique.Where(type => type.IsContentType)); + + foreach (var type in unique.Where(type => type.IsContentType)) + { + ValidateColumns(type, components); + } return [.. _errors]; } @@ -369,6 +376,99 @@ private void ValidateReverseRelations(IEnumerable contentTypes, Dicti } } + // A type's tables must not be a system table, nor the locale table of another type: 'page-i18n' beside a localized 'page'. + private void ValidateTables(IEnumerable contentTypes) + { + var types = contentTypes.Where(type => SchemaNaming.IsValidTypeName(type.Name)).ToList(); + var localeTables = types.Where(type => type.Localized).ToDictionary(type => StorageNames.LocaleTable(type.Name), StringComparer.Ordinal); + + foreach (var type in types) + { + var table = StorageNames.Table(type.Name); + if (StorageNames.SystemTables.Contains(table)) + { + Report(SchemaErrorCodes.ReservedName, $"{Describe(type)} needs table {table}, which a system table already uses"); + } + else if (localeTables.TryGetValue(table, out var owner)) + { + Report(SchemaErrorCodes.ReservedName, $"{Describe(type)} needs table {table}, which is the locale table of {Describe(owner)}"); + } + } + } + + // Walks the fields that become columns, single components flattened, as the storage mapping does. + private void ValidateColumns(TypeDraft type, Dictionary components) + { + Dictionary<(bool Localized, string Column), string> holders = []; + + foreach (var field in DistinctFields(type)) + { + ValidateColumn(type, field, type.Name, "", type.Localized && field.Localizable, holders, components, []); + } + } + + // A repeated name is reported once, by ValidateType; the repeat would only add a second error about the same column. + private static IEnumerable DistinctFields(TypeDraft type) => + type.Fields.DistinctBy(field => field.Name, StringComparer.OrdinalIgnoreCase); + + private void ValidateColumn(TypeDraft type, FieldDraft field, string owner, string prefix, bool localized, Dictionary<(bool Localized, string Column), string> holders, Dictionary components, HashSet path) + { + var fieldType = fieldTypes.Find(field.FieldType); + if (fieldType is null || !SchemaNaming.IsValidFieldName(field.Name)) + { + return; + } + + var location = $"{owner}.{field.Name}"; + var column = prefix + StorageNames.Column(field.Name); + var kind = fieldType.StorageKind(SchemaModelFactory.Create(field)); + + if (field.FieldType == FieldTypeNames.Component && kind == FieldStorageKind.Column) + { + if (SettingText(field, FieldSettings.Component) is { } name && components.TryGetValue(name, out var component) && path.Add(name)) + { + foreach (var inner in DistinctFields(component)) + { + ValidateColumn(type, inner, location, column + "_", localized, holders, components, path); + } + + path.Remove(name); + } + + return; + } + + if (field.Unique && kind != FieldStorageKind.Column) + { + Report(SchemaErrorCodes.InvalidField, kind == FieldStorageKind.Reference + ? $"field {location} is unique, but a {field.FieldType} field has no column to index" + : $"field {location} is unique, but a {field.FieldType} field is stored as JSON"); + } + + if (kind == FieldStorageKind.Reference) + { + return; + } + + var table = localized ? StorageNames.LocaleTable(type.Name) : StorageNames.Table(type.Name); + var systemColumns = localized ? StorageNames.LocaleTableColumns : StorageNames.SystemColumns; + + // A top-level field named like a system field was reported already, by name. + var reportedByName = prefix.Length == 0 && ContentItem.ReservedFieldNames.Contains(field.Name); + + if (systemColumns.Contains(column)) + { + if (!reportedByName) + { + Report(SchemaErrorCodes.ReservedName, $"field {location} maps to column {column} of {table}, which is a system column"); + } + } + else if (!holders.TryAdd((localized, column), location)) + { + Report(SchemaErrorCodes.DuplicateField, $"fields {holders[(localized, column)]} and {location} both map to column {column} of {table}"); + } + } + // Depth counts the components and blocks between a content type and its deepest field. A cycle has no depth, so it is reported as such. private void ValidateNesting(IReadOnlyList types, Dictionary components, Dictionary blocks) { diff --git a/src/FluentCMS.Core/Storage/ContentModelBuilder.cs b/src/FluentCMS.Core/Storage/ContentModelBuilder.cs index 847ddb433..a056a8edd 100644 --- a/src/FluentCMS.Core/Storage/ContentModelBuilder.cs +++ b/src/FluentCMS.Core/Storage/ContentModelBuilder.cs @@ -1,4 +1,3 @@ -using FluentCMS.Core.Schema; using FluentCMS.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -9,41 +8,23 @@ namespace FluentCMS.Core.Storage; // cms_{type}_i18n for a localized type. Property names are the column names. internal sealed class ContentModelBuilder(SchemaModel schema, IStorageConventions conventions) { - private readonly List _errors = []; - - // System entities must already be mapped, so a type whose table is taken is reported here. + // The schema was validated when it was built, so names and columns cannot clash here. public void Apply(ModelBuilder modelBuilder) { - var tables = modelBuilder.Model.GetEntityTypes() - .Select(entity => entity.GetTableName()) - .OfType() - .ToHashSet(StringComparer.Ordinal); - foreach (var type in schema.ContentTypes) { var fields = FieldStorage.Of(schema, type).ToList(); - MapTable(modelBuilder, tables, type, StorageNames.Table(type), fields.Where(field => !field.Localized).ToList(), hasLocale: false); + MapTable(modelBuilder, type, StorageNames.Table(type), fields.Where(field => !field.Localized).ToList(), hasLocale: false); if (type.Localized) { - MapTable(modelBuilder, tables, type, StorageNames.LocaleTable(type), fields.Where(field => field.Localized).ToList(), hasLocale: true); + MapTable(modelBuilder, type, StorageNames.LocaleTable(type), fields.Where(field => field.Localized).ToList(), hasLocale: true); } } - - if (_errors.Count > 0) - { - throw new SchemaValidationException(_errors); - } } - private void MapTable(ModelBuilder modelBuilder, HashSet tables, SchemaType type, string table, List fields, bool hasLocale) + private void MapTable(ModelBuilder modelBuilder, SchemaType type, string table, List fields, bool hasLocale) { - if (!tables.Add(table)) - { - Report(SchemaErrorCodes.ReservedName, $"{type.Kind.ToString().ToLowerInvariant()} '{type.Name}' needs table {table}, which is already taken"); - return; - } - modelBuilder.SharedTypeEntity>(table, entity => { entity.ToTable(table); @@ -53,19 +34,9 @@ private void MapTable(ModelBuilder modelBuilder, HashSet tables, SchemaT entity.Property(type.Scope == ContentScope.Global ? typeof(Guid?) : typeof(Guid), StorageNames.SiteId); entity.Property(StorageNames.State); - // Column to the field that holds it; a system column has no field. - Dictionary columns = new(StringComparer.Ordinal) - { - [StorageNames.Id] = null, - [StorageNames.DocumentId] = null, - [StorageNames.SiteId] = null, - [StorageNames.State] = null, - }; - if (hasLocale) { entity.Property(StorageNames.Locale).HasMaxLength(StorageNames.LocaleLength).IsRequired(); - columns[StorageNames.Locale] = null; entity.HasIndex(StorageNames.DocumentId, StorageNames.Locale, StorageNames.State).IsUnique().HasDatabaseName($"uq_{table}_document_locale_state"); } else @@ -75,40 +46,19 @@ private void MapTable(ModelBuilder modelBuilder, HashSet tables, SchemaT foreach (var field in fields) { - MapField(entity, type, table, field, columns, hasLocale); + MapField(entity, type, table, field, hasLocale); } }); } - private void MapField(EntityTypeBuilder entity, SchemaType type, string table, StoredField field, Dictionary columns, bool hasLocale) + private void MapField(EntityTypeBuilder entity, SchemaType type, string table, StoredField field, bool hasLocale) { - if (field.Kind == StorageKind.Reference) + // [Indexed] on a reference needs nothing: cms_reference already has its index on the target. + if (field.Kind == FieldStorageKind.Reference) { - // [Indexed] needs nothing: cms_reference already has its index on the target. - if (field.Field.Unique) - { - Report(SchemaErrorCodes.InvalidField, $"field {field.Location} is unique, but a {field.Field.FieldType} field has no column to index"); - } - return; } - if (columns.TryGetValue(field.Column, out var holder)) - { - if (holder is null) - { - Report(SchemaErrorCodes.ReservedName, $"field {field.Location} maps to column {field.Column} of {table}, which is a system column"); - } - else - { - Report(SchemaErrorCodes.DuplicateField, $"fields {holder} and {field.Location} both map to column {field.Column} of {table}"); - } - - return; - } - - columns[field.Column] = field.Location; - var property = entity.Property(field.ClrType!, field.Column); if (field.ClrType == typeof(string)) { @@ -130,7 +80,7 @@ private void MapField(EntityTypeBuilder entity, SchemaType type, string table, S property.HasPrecision(precision, field.Scale ?? 0); } - if (field.Kind == StorageKind.Json) + if (field.Kind == FieldStorageKind.Json) { conventions.ConfigureJsonColumn(entity, property); } @@ -145,12 +95,6 @@ private void MapIndexes(EntityTypeBuilder entity, SchemaType type, string table, if (field.Field.Unique) { - if (field.Kind == StorageKind.Json) - { - Report(SchemaErrorCodes.InvalidField, $"field {field.Location} is unique, but a {field.Field.FieldType} field is stored as JSON"); - return; - } - entity.HasIndex([.. scope, field.Column]) .IsUnique() .HasFilter(StorageNames.PublishedOnly) @@ -159,7 +103,7 @@ private void MapIndexes(EntityTypeBuilder entity, SchemaType type, string table, if (field.Field.Indexed) { - if (field.Kind == StorageKind.Json) + if (field.Kind == FieldStorageKind.Json) { conventions.ConfigureJsonIndex(entity, field.Column); } @@ -169,6 +113,4 @@ private void MapIndexes(EntityTypeBuilder entity, SchemaType type, string table, } } } - - private void Report(string code, string message) => _errors.Add(new SchemaError(code, message)); } diff --git a/src/FluentCMS.Core/Storage/FieldStorage.cs b/src/FluentCMS.Core/Storage/FieldStorage.cs index d36b85148..3f100aabf 100644 --- a/src/FluentCMS.Core/Storage/FieldStorage.cs +++ b/src/FluentCMS.Core/Storage/FieldStorage.cs @@ -1,21 +1,15 @@ using System.Globalization; +using FluentCMS.Core.Schema.FieldTypes; using FluentCMS.Schema; namespace FluentCMS.Core.Storage; -internal enum StorageKind -{ - Column, - Json, - Reference, -} - // One leaf of a content type after single components are flattened. A Reference has no column, so no ClrType. internal sealed record StoredField( SchemaField Field, string Location, string Column, - StorageKind Kind, + FieldStorageKind Kind, Type? ClrType, bool Localized, bool Required = false, @@ -24,8 +18,9 @@ internal sealed record StoredField( int? Scale = null, string? DefaultValue = null); -// The one place that knows how a field type is stored. The field type registry (#2684) replaces the -// switch on names with IFieldType.StorageKind(field). +// Where a field lives comes from IFieldType.StorageKind of the registry the schema was built with. This +// class adds what the registry does not say: the CLR type and length defaults of the core types. A plugin +// type that stores a column gets a nullable string, one that stores JSON a nullable JSON string. internal static class FieldStorage { // Appendix A sizes these columns although the sample fields declare no length. @@ -44,54 +39,58 @@ private static IEnumerable Flatten(SchemaModel schema, SchemaField var location = $"{owner}.{field.Name}"; var column = prefix + StorageNames.Column(field.Name); - if (field.FieldType == "component" && !field.Multiple) + var fieldType = schema.FieldTypes.Find(field.FieldType) + ?? throw new InvalidOperationException($"Field {location} has type '{field.FieldType}', which is not registered."); + var kind = fieldType.StorageKind(field); + + if (field.FieldType == FieldTypeNames.Component && kind == FieldStorageKind.Column) { var component = schema.FindComponent((string)field.Settings[FieldSettings.Component])!; return component.Fields.SelectMany(inner => Flatten(schema, inner, location, column + "_", flattened: true, localized)); } - return [Map(field, location, column, flattened, localized)]; + return [Map(field, kind, location, column, flattened, localized)]; } // Nothing is NOT NULL unless Appendix A shows it, because a draft may be incomplete and a missing // number or date has no honest placeholder. Text can hold '', a slug is derived, and rich text has an // empty document. A flattened column stays nullable because the whole component may be absent. - private static StoredField Map(SchemaField field, string location, string column, bool flattened, bool localized) + private static StoredField Map(SchemaField field, FieldStorageKind kind, string location, string column, bool flattened, bool localized) { + if (kind == FieldStorageKind.Reference) + { + return new(field, location, column, kind, null, localized); + } + + if (kind == FieldStorageKind.Json) + { + // A list is never null; a rich text field has an empty document, except in a component that may be absent. + var list = field.Multiple || field.FieldType == FieldTypeNames.Blocks; + var required = list || (field.FieldType == FieldTypeNames.RichText && !flattened); + return new(field, location, column, kind, typeof(string), localized, required, DefaultValue: list ? EmptyList : null); + } + var (precision, scale) = DecimalPrecision(field); StoredField Text(bool required = false, int? maxLength = null) => - new(field, location, column, StorageKind.Column, typeof(string), localized, required && !flattened, field.MaxLength ?? maxLength); + new(field, location, column, kind, typeof(string), localized, required && !flattened, field.MaxLength ?? maxLength); StoredField Value(Type clrType) => - new(field, location, column, StorageKind.Column, clrType, localized); - - StoredField Json(bool required = false, string? defaultValue = null) => - new(field, location, column, StorageKind.Json, typeof(string), localized, required, DefaultValue: defaultValue); - - var isReference = field.FieldType is "relation" or "media"; - if (field.Multiple && !isReference) - { - return Json(required: true, EmptyList); - } + new(field, location, column, kind, clrType, localized); return field.FieldType switch { - "relation" or "media" => new(field, location, column, StorageKind.Reference, null, localized), - "text" or "longText" => Text(field.Required), - "slug" => Text(required: true, SlugLength), - "email" => Text(maxLength: EmailLength), - "url" => Text(maxLength: UrlLength), - "select" => Text(maxLength: SelectColumnLength(field)), - "integer" => Value(typeof(long?)), - "decimal" => Value(typeof(decimal?)) with { Precision = precision, Scale = scale }, - "boolean" => Value(typeof(bool?)), - "date" => Value(typeof(DateOnly?)), - "dateTime" => Value(typeof(DateTimeOffset?)), - "richText" => Json(required: !flattened), - "json" => Json(), - "blocks" => Json(required: true, EmptyList), - _ => throw new InvalidOperationException($"Field {location} has type '{field.FieldType}', which has no storage mapping."), + FieldTypeNames.Text or FieldTypeNames.LongText => Text(field.Required), + FieldTypeNames.Slug => Text(required: true, SlugLength), + FieldTypeNames.Email => Text(maxLength: EmailLength), + FieldTypeNames.Url => Text(maxLength: UrlLength), + FieldTypeNames.Select => Text(maxLength: SelectColumnLength(field)), + FieldTypeNames.Integer => Value(typeof(long?)), + FieldTypeNames.Decimal => Value(typeof(decimal?)) with { Precision = precision, Scale = scale }, + FieldTypeNames.Boolean => Value(typeof(bool?)), + FieldTypeNames.Date => Value(typeof(DateOnly?)), + FieldTypeNames.DateTime => Value(typeof(DateTimeOffset?)), + _ => Text(), }; } diff --git a/src/FluentCMS.Core/Storage/StorageNames.cs b/src/FluentCMS.Core/Storage/StorageNames.cs index 250c84cc3..9bece92d8 100644 --- a/src/FluentCMS.Core/Storage/StorageNames.cs +++ b/src/FluentCMS.Core/Storage/StorageNames.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Text.Json; using FluentCMS.Schema; @@ -18,11 +19,39 @@ internal static class StorageNames public const int LocaleLength = 16; + // Columns every content table has, and the one a locale table adds. + public static readonly FrozenSet SystemColumns = new[] { Id, DocumentId, SiteId, State }.ToFrozenSet(StringComparer.Ordinal); + + public static readonly FrozenSet LocaleTableColumns = SystemColumns.Append(Locale).ToFrozenSet(StringComparer.Ordinal); + + // Every table of spec Appendix C, the first-party plugins' included, so a content type cannot take a name + // a later release maps. Add a table here when the spec does. + public static readonly FrozenSet SystemTables = new[] + { + "site", "site_locale", "schedule", "schema_history", + "user", "user_role", "user_claim", "user_login", "user_token", "invitation", "role", "role_permission", + "oidc_application", "oidc_authorization", "oidc_scope", "oidc_token", "signing_key", "data_protection_key", + "api_key", + "member", "member_login", "member_token", "member_group", "member_group_member", + "activity", + "job", "job_attempt", "recurring_job", "lock", "outbox", + "setting", "plugin_site", + "media_asset", "media_folder", "media_variant", "media_preset", + "webhook", "webhook_delivery", + "mcp_instruction", "mcp_prompt", + "ai_usage", + "site_host", + }.Select(name => Prefix + name).Concat([Document, Reference, Version]).ToFrozenSet(StringComparer.Ordinal); + public const string PublishedOnly = "state = 1"; - public static string Table(SchemaType type) => Prefix + type.Name.Replace('-', '_'); + public static string Table(SchemaType type) => Table(type.Name); + + public static string Table(string typeName) => Prefix + typeName.Replace('-', '_'); + + public static string LocaleTable(SchemaType type) => LocaleTable(type.Name); - public static string LocaleTable(SchemaType type) => Table(type) + "_i18n"; + public static string LocaleTable(string typeName) => Table(typeName) + "_i18n"; public static string Column(string name) => JsonNamingPolicy.SnakeCaseLower.ConvertName(name); } diff --git a/src/FluentCMS.Sdk/Schema/SchemaModel.cs b/src/FluentCMS.Sdk/Schema/SchemaModel.cs index fd3dba478..5e125f1c8 100644 --- a/src/FluentCMS.Sdk/Schema/SchemaModel.cs +++ b/src/FluentCMS.Sdk/Schema/SchemaModel.cs @@ -12,8 +12,9 @@ public sealed class SchemaModel private readonly Dictionary _components; private readonly Dictionary _blocks; - internal SchemaModel(IEnumerable types) + internal SchemaModel(IEnumerable types, IFieldTypeRegistry fieldTypes) { + FieldTypes = fieldTypes; var ordered = types.OrderBy(type => type.Name, StringComparer.Ordinal).ToList(); ContentTypes = Select(ordered, SchemaTypeKind.Collection, SchemaTypeKind.Singleton); @@ -25,6 +26,9 @@ internal SchemaModel(IEnumerable types) _blocks = Blocks.ToDictionary(type => type.Name, StringComparer.Ordinal); } + // The registry the schema was validated with, for the engine; it is not part of the schema itself. + internal IFieldTypeRegistry FieldTypes { get; } + /// The collections and singletons, ordered by name. public IReadOnlyList ContentTypes { get; } diff --git a/tests/FluentCMS.Core.Tests/Schema/StorageSchemaValidationTests.cs b/tests/FluentCMS.Core.Tests/Schema/StorageSchemaValidationTests.cs new file mode 100644 index 000000000..36c323132 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/StorageSchemaValidationTests.cs @@ -0,0 +1,164 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema; + +// The checks the storage mapping depends on, reported at Build() with the other FCMS12xx errors. +public class StorageSchemaValidationTests +{ + private static SchemaValidationException Fails(SchemaBuilder builder) => + Should.Throw(() => builder.Build()); + + [Fact] + public void FieldThatTakesASystemColumnIsReported() + { + var builder = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("documentId", "text").Field("siteId", "text").Field("state", "integer")); + + var exception = Fails(builder); + + exception.Errors.Select(error => error.Code).ShouldBe([SchemaErrorCodes.ReservedName, SchemaErrorCodes.ReservedName, SchemaErrorCodes.ReservedName]); + exception.Message.ShouldContain("page.documentId"); + exception.Message.ShouldContain("page.siteId"); + exception.Message.ShouldContain("page.state"); + } + + [Fact] + public void ReservedFieldNameIsNotReportedTwice() + { + var builder = SampleSchema.NewBuilder().AddCollection("page", type => type.Localized().Field("locale", "text").Field("id", "text")); + + Fails(builder).Errors.Select(error => error.Code).ShouldBe([SchemaErrorCodes.ReservedName, SchemaErrorCodes.ReservedName]); + } + + [Fact] + public void ComponentFieldIsPrefixedSoItCannotTakeASystemColumn() + { + var builder = SampleSchema.NewBuilder() + .AddComponent("flags", type => type.Field("state", "text")) + .AddCollection("page", type => type.Field("flags", "component", field => field.Setting(FieldSettings.Component, "flags"))); + + builder.Build(); + } + + [Fact] + public void FieldsThatShareAColumnAreReportedByName() + { + var builder = SampleSchema.NewBuilder() + .AddComponent("seo", type => type.Field("metaTitle", "text")) + .AddCollection("page", type => type + .Field("seo", "component", field => field.Setting(FieldSettings.Component, "seo")) + .Field("seoMetaTitle", "text")); + + var error = Fails(builder).Errors.ShouldHaveSingleItem(); + + error.Code.ShouldBe(SchemaErrorCodes.DuplicateField); + error.Message.ShouldContain("page.seo.metaTitle"); + error.Message.ShouldContain("page.seoMetaTitle"); + error.Message.ShouldContain("seo_meta_title"); + } + + [Fact] + public void FieldsInDifferentTablesMayShareAColumn() + { + var builder = SampleSchema.NewBuilder() + .AddComponent("seo", type => type.Field("metaTitle", "text")) + .AddCollection("page", type => type + .Localized() + .Field("seo", "component", field => field.Localizable().Setting(FieldSettings.Component, "seo")) + .Field("seoMetaTitle", "text")); + + builder.Build(); + } + + [Fact] + public void RepeatedFieldNameIsReportedOnce() + { + var builder = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("title", "text").Field("title", "text")); + + Fails(builder).Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.DuplicateField); + } + + [Theory] + [InlineData("document", "cms_document")] + [InlineData("reference", "cms_reference")] + [InlineData("version", "cms_version")] + [InlineData("job", "cms_job")] + [InlineData("job-attempt", "cms_job_attempt")] + [InlineData("user", "cms_user")] + [InlineData("media-asset", "cms_media_asset")] + [InlineData("site-host", "cms_site_host")] + [InlineData("schema-history", "cms_schema_history")] + public void TypeThatTakesASystemTableIsReported(string type, string table) + { + var exception = Fails(SampleSchema.NewBuilder().AddCollection(type, _ => { })); + + exception.Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.ReservedName); + exception.Message.ShouldContain(table); + } + + [Fact] + public void SingletonThatTakesASystemTableIsReported() => + Fails(SampleSchema.NewBuilder().AddSingleton("setting", _ => { })).Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.ReservedName); + + [Fact] + public void ComponentMayBeNamedLikeASystemTable() => + SampleSchema.NewBuilder().AddComponent("job", _ => { }).Build(); + + [Fact] + public void TypeThatTakesALocaleTableIsReported() + { + var builder = SampleSchema.NewBuilder() + .AddCollection("page", type => type.Localized()) + .AddCollection("page-i18n", _ => { }); + + var error = Fails(builder).Errors.ShouldHaveSingleItem(); + + error.Code.ShouldBe(SchemaErrorCodes.ReservedName); + error.Message.ShouldContain("cms_page_i18n"); + } + + [Fact] + public void TypeBesideAnUnlocalizedTypeMayEndInI18n() => + SampleSchema.NewBuilder().AddCollection("page", _ => { }).AddCollection("page-i18n", _ => { }).Build(); + + [Theory] + [InlineData("""{ "name": "body", "type": "richText", "unique": true }""")] + [InlineData("""{ "name": "data", "type": "json", "unique": true }""")] + [InlineData("""{ "name": "owner", "type": "relation", "target": "page", "unique": true }""")] + [InlineData("""{ "name": "tags", "type": "select", "options": ["a", "b"], "multiple": true, "unique": true }""")] + public void UniqueFieldWithoutAColumnIsReported(string field) + { + var builder = SampleSchema.NewBuilder().AddSchemaJson($$"""{ "collections": [ { "name": "page", "fields": [ {{field}} ] } ] }"""); + + Fails(builder).Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.InvalidField); + } + + [Fact] + public void UniqueMediaFieldIsReported() + { + var builder = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("hero", "media", field => field.Unique())); + + Fails(builder).Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.InvalidField); + } + + [Fact] + public void UniqueFieldOfAComponentIsCheckedToo() + { + var builder = SampleSchema.NewBuilder() + .AddComponent("seo", type => type.Field("notes", "json", field => field.Unique())) + .AddCollection("page", type => type.Field("seo", "component", field => field.Setting(FieldSettings.Component, "seo"))); + + Fails(builder).Errors.ShouldHaveSingleItem().Message.ShouldContain("page.seo.notes"); + } + + [Fact] + public void ErrorsOfEveryKindAreReportedTogether() + { + var builder = SampleSchema.NewBuilder() + .AddCollection("job", type => type.Field("state", "text").Field("data", "json", field => field.Unique())); + + Fails(builder).Errors.Select(error => error.Code).Order().ShouldBe([SchemaErrorCodes.ReservedName, SchemaErrorCodes.ReservedName, SchemaErrorCodes.InvalidField]); + } +} diff --git a/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs b/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs index 2662a7f6d..f071122f0 100644 --- a/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs +++ b/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs @@ -43,7 +43,7 @@ public void ArticleFieldsSplitByLocalizable() var fields = ArticleFields(); fields.Where(field => field.Localized).Select(field => field.Column).ShouldBe(["title", "slug", "body"]); - fields.Where(field => !field.Localized && field.Kind != StorageKind.Reference).Select(field => field.Column) + fields.Where(field => !field.Localized && field.Kind != FieldStorageKind.Reference).Select(field => field.Column) .ShouldBe(["sections", "seo_meta_title", "seo_meta_description", "category", "published_on", "video_url"]); } @@ -52,12 +52,12 @@ public void ArticleFieldsHaveTheKindsOfTheColumnMapping() { var kinds = ArticleFields().ToDictionary(field => field.Column, field => field.Kind); - kinds["title"].ShouldBe(StorageKind.Column); - kinds["body"].ShouldBe(StorageKind.Json); - kinds["sections"].ShouldBe(StorageKind.Json); - kinds["author"].ShouldBe(StorageKind.Reference); - kinds["tags"].ShouldBe(StorageKind.Reference); - kinds["hero"].ShouldBe(StorageKind.Reference); + kinds["title"].ShouldBe(FieldStorageKind.Column); + kinds["body"].ShouldBe(FieldStorageKind.Json); + kinds["sections"].ShouldBe(FieldStorageKind.Json); + kinds["author"].ShouldBe(FieldStorageKind.Reference); + kinds["tags"].ShouldBe(FieldStorageKind.Reference); + kinds["hero"].ShouldBe(FieldStorageKind.Reference); } [Fact] @@ -76,12 +76,21 @@ public void LocalizableComponentFlattensIntoTheLocaleTable() } [Fact] - public void FieldTypeWithoutAMappingIsAnError() + public void MediaFieldIsStoredAsReferences() { var schema = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("hero", "media", field => field.Multiple())).Build(); - FieldStorage.Of(schema, schema.FindContentType("page")!).ShouldHaveSingleItem().Kind.ShouldBe(StorageKind.Reference); + FieldStorage.Of(schema, schema.FindContentType("page")!).ShouldHaveSingleItem().Kind.ShouldBe(FieldStorageKind.Reference); + } + + [Fact] + public void PluginFieldTypeIsStoredWhereItsStorageKindSays() + { var custom = new SchemaBuilder(new FieldTypeRegistry([new FreeFormFieldType()])).AddCollection("page", type => type.Field("tint", "freeform")).Build(); - Should.Throw(() => FieldStorage.Of(custom, custom.FindContentType("page")!).ToList()).Message.ShouldContain("page.tint"); + + var field = FieldStorage.Of(custom, custom.FindContentType("page")!).ShouldHaveSingleItem(); + + field.Kind.ShouldBe(FieldStorageKind.Column); + field.ClrType.ShouldBe(typeof(string)); } } diff --git a/tests/FluentCMS.Data.Tests/Storage/ContentModelTests.cs b/tests/FluentCMS.Data.Tests/Storage/ContentModelTests.cs index 008c9c0c7..7bbec8504 100644 --- a/tests/FluentCMS.Data.Tests/Storage/ContentModelTests.cs +++ b/tests/FluentCMS.Data.Tests/Storage/ContentModelTests.cs @@ -283,72 +283,6 @@ public void IndexesOnAComponentFieldUseItsFlattenedColumn() IndexColumns(unique).ShouldBe(["site_id", "seo_meta_title"]); } - [Fact] - public void FieldThatTakesASystemColumnIsReported() - { - var schema = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("documentId", "text").Field("state", "integer")).Build(); - - var exception = Should.Throw(() => ModelOf(schema)); - - exception.Errors.Select(error => error.Code).ShouldBe([SchemaErrorCodes.ReservedName, SchemaErrorCodes.ReservedName]); - exception.Message.ShouldContain("page.documentId"); - exception.Message.ShouldContain("page.state"); - } - - [Fact] - public void FieldsThatShareAColumnAreReportedByName() - { - var schema = SampleSchema.NewBuilder() - .AddComponent("seo", type => type.Field("metaTitle", "text")) - .AddCollection("page", type => type - .Field("seo", "component", field => field.Setting(FieldSettings.Component, "seo")) - .Field("seoMetaTitle", "text")) - .Build(); - - var error = Should.Throw(() => ModelOf(schema)).Errors.ShouldHaveSingleItem(); - - error.Code.ShouldBe(SchemaErrorCodes.DuplicateField); - error.Message.ShouldContain("page.seo.metaTitle"); - error.Message.ShouldContain("page.seoMetaTitle"); - error.Message.ShouldContain("seo_meta_title"); - } - - [Theory] - [InlineData("document", "cms_document")] - [InlineData("reference", "cms_reference")] - [InlineData("version", "cms_version")] - public void TypeThatTakesASystemTableIsReported(string type, string table) - { - var schema = SampleSchema.NewBuilder().AddCollection(type, _ => { }).Build(); - - var exception = Should.Throw(() => ModelOf(schema)); - - exception.Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.ReservedName); - exception.Message.ShouldContain(table); - } - - [Fact] - public void TypeThatTakesALocaleTableIsReported() - { - var schema = SampleSchema.NewBuilder() - .AddCollection("page", type => type.Localized()) - .AddCollection("page-i18n", _ => { }) - .Build(); - - Should.Throw(() => ModelOf(schema)).Message.ShouldContain("cms_page_i18n"); - } - - [Theory] - [InlineData("""{ "name": "body", "type": "richText", "unique": true }""")] - [InlineData("""{ "name": "owner", "type": "relation", "target": "page", "unique": true }""")] - public void UniqueFieldWithoutAColumnIsReported(string field) - { - var schema = SampleSchema.NewBuilder().AddSchemaJson($$"""{ "collections": [ { "name": "page", "fields": [ {{field}} ] } ] }""").Build(); - - Should.Throw(() => ModelOf(schema)) - .Errors.ShouldHaveSingleItem().Code.ShouldBe(SchemaErrorCodes.InvalidField); - } - [Fact] public void IndexedRelationAndJsonFieldsGetNoNeutralIndex() { diff --git a/tests/FluentCMS.Data.Tests/Storage/PluginFieldStorageTests.cs b/tests/FluentCMS.Data.Tests/Storage/PluginFieldStorageTests.cs new file mode 100644 index 000000000..31acbd3c4 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Storage/PluginFieldStorageTests.cs @@ -0,0 +1,128 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Storage; +using FluentCMS.Schema; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; + +namespace FluentCMS.Data.Tests.Storage; + +public class PluginFieldStorageTests +{ + private static readonly Guid Site = Guid.CreateVersion7(); + + private static SchemaModel Schema(Action color, Action palette) => + new SchemaBuilder(new FieldTypeRegistry([new ColorFieldType(), new PaletteFieldType()])) + .AddCollection("theme", type => type.Field("name", "text").Field("accent", "color", color).Field("swatches", "palette", palette)) + .Build(); + + [Fact] + public void ColumnTypeRoundTripsARow() + { + using var database = new StorageDatabase(Schema(_ => { }, _ => { })); + var documentId = Guid.CreateVersion7(); + + using (var context = database.CreateCreatedContext()) + { + context.Rows("cms_theme").Add(new Dictionary + { + ["document_id"] = documentId, + ["site_id"] = Site, + ["state"] = ContentState.Draft, + ["name"] = "Dark", + ["accent"] = "#ff8800", + }); + context.SaveChanges(); + } + + using (var context = database.CreateContext()) + { + var row = context.Rows("cms_theme").Single(row => EF.Property(row, "document_id") == documentId); + row["accent"].ShouldBe("#ff8800"); + row["swatches"].ShouldBe(null!); + } + } + + [Fact] + public void JsonTypeRoundTripsARow() + { + using var database = new StorageDatabase(Schema(_ => { }, _ => { })); + var documentId = Guid.CreateVersion7(); + const string swatches = """["#000","#fff"]"""; + + using (var context = database.CreateCreatedContext()) + { + context.Rows("cms_theme").Add(new Dictionary + { + ["document_id"] = documentId, + ["site_id"] = Site, + ["state"] = ContentState.Draft, + ["swatches"] = swatches, + }); + context.SaveChanges(); + } + + using (var context = database.CreateContext()) + { + context.Rows("cms_theme").Single(row => EF.Property(row, "document_id") == documentId)["swatches"].ShouldBe(swatches); + } + } + + [Fact] + public void PluginColumnIsANullableStringAndHonoursMaxLength() + { + var schema = Schema(field => field.Length(null, 7), _ => { }); + + var accent = FieldStorage.Of(schema, schema.FindContentType("theme")!).Single(field => field.Column == "accent"); + + accent.Kind.ShouldBe(FieldStorageKind.Column); + accent.ClrType.ShouldBe(typeof(string)); + accent.Required.ShouldBeFalse(); + accent.MaxLength.ShouldBe(7); + } + + [Fact] + public void PluginJsonIsANullableJsonString() + { + var schema = Schema(_ => { }, _ => { }); + + var swatches = FieldStorage.Of(schema, schema.FindContentType("theme")!).Single(field => field.Column == "swatches"); + + swatches.Kind.ShouldBe(FieldStorageKind.Json); + swatches.ClrType.ShouldBe(typeof(string)); + swatches.Required.ShouldBeFalse(); + swatches.DefaultValue.ShouldBeNull(); + } + + [Fact] + public void PluginJsonListIsRequiredWithAnEmptyDefault() + { + var schema = Schema(_ => { }, field => field.Multiple()); + + var swatches = FieldStorage.Of(schema, schema.FindContentType("theme")!).Single(field => field.Column == "swatches"); + + swatches.Required.ShouldBeTrue(); + swatches.DefaultValue.ShouldBe("[]"); + } + + [Fact] + public void ContentModelBuilderRaisesNoSchemaErrorsOfItsOwn() + { + var source = File.ReadAllText(Path.Combine(FindRepositoryRoot(), "src", "FluentCMS.Core", "Storage", "ContentModelBuilder.cs")); + + source.ShouldNotContain("SchemaValidationException"); + source.ShouldNotContain("SchemaError"); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Directory.Packages.props"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new InvalidOperationException("The repository root was not found."); + } +} diff --git a/tests/FluentCMS.Data.Tests/Storage/PluginFieldTypes.cs b/tests/FluentCMS.Data.Tests/Storage/PluginFieldTypes.cs new file mode 100644 index 000000000..f746def85 --- /dev/null +++ b/tests/FluentCMS.Data.Tests/Storage/PluginFieldTypes.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Data.Tests.Storage; + +// Plugin field types that the storage mapping knows nothing about; only the storage kind tells it where they live. +internal abstract class PluginFieldType(string name, FieldStorageKind kind) : IFieldType +{ + public string Name => name; + + public IReadOnlyList ClrTypes { get; } = [typeof(string)]; + + public IReadOnlySet FilterOperators { get; } = new HashSet(); + + public IReadOnlyList Settings { get; } = []; + + public bool Sortable(SchemaField field) => false; + + public bool Indexable(SchemaField field) => true; + + public FieldStorageKind StorageKind(SchemaField field) => kind; + + public bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + result = value; + message = null; + return true; + } + + public JsonObject OpenApiSchema(SchemaField field) => []; +} + +internal sealed class ColorFieldType() : PluginFieldType("color", FieldStorageKind.Column); + +internal sealed class PaletteFieldType() : PluginFieldType("palette", FieldStorageKind.Json);