From 3a5301beeddc456601d054ecf5b1345f80d2ca02 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 13:07:42 -0400 Subject: [PATCH] Add entity types and references to entity fields --- CONTRIBUTING.md | 2 +- .../Builtin/FieldTypes/ListFieldType.cs | 4 +- .../Builtin/FieldTypes/ReferenceFieldType.cs | 15 +- src/FluentCMS.Core/Schema/ContentType.cs | 16 +- .../Schema/ContentTypeDefinition.cs | 48 +++-- src/FluentCMS.Core/Schema/FactMetadata.cs | 17 ++ src/FluentCMS.Core/Schema/FieldReference.cs | 4 +- .../Schema/FieldSettingsReader.cs | 16 ++ .../Schema/FieldTypeRegistry.cs | 4 +- src/FluentCMS.Core/Schema/IFieldType.cs | 4 +- src/FluentCMS.Core/Schema/ReferenceTarget.cs | 4 + src/FluentCMS.Core/Schema/SchemaErrorCodes.cs | 4 + .../Schema/ContentTypeDirectory.cs | 122 ++++++++++--- .../Schema/ContentTypeDirectoryTests.cs | 34 ++-- .../Schema/EntityTypeTests.cs | 165 ++++++++++++++++++ .../Schema/SchemaScenario.cs | 23 ++- .../Schema/BuiltinFieldTypeTests.cs | 28 ++- .../Schema/ContentTypeDefinitionTests.cs | 69 +++++++- .../Schema/ContentTypeTests.cs | 25 +++ .../Schema/FactMetadataTests.cs | 20 +++ .../Schema/FieldTypeConformanceTests.cs | 12 +- .../Schema/PluginFieldTypeTests.cs | 6 +- 22 files changed, 556 insertions(+), 86 deletions(-) create mode 100644 src/FluentCMS.Core/Schema/FactMetadata.cs create mode 100644 src/FluentCMS.Core/Schema/ReferenceTarget.cs create mode 100644 tests/FluentCMS.IntegrationTests/Schema/EntityTypeTests.cs create mode 100644 tests/FluentCMS.Tests/Schema/FactMetadataTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d7061304..6f078efff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,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. 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. +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, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks 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 2e9351955..602f010fc 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs @@ -30,12 +30,12 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } - public IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + public IReadOnlyList ReferenceTargets(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); + return fieldTypes.ReferenceTargets(settings.GetProperty("itemType").GetString()!, itemSettings); } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs index 313962603..eea959cb6 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs @@ -4,7 +4,8 @@ namespace FluentCMS.Core.Builtin.FieldTypes; -// Points at an entry of the content type named by 'target'. Whether that type exists is checked where types are stored. +// Points at an entry of the content type named by 'target', or with 'field' at one field of it, such as product.price (SCH-04). +// Whether the type and field exist, and that a field target is an entity type, is checked where types are stored. internal sealed class ReferenceFieldType : IFieldType { public string Name => "reference"; @@ -13,17 +14,25 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { var reader = new FieldSettingsReader(Name, settings); var target = reader.RequiredString("target"); + var field = reader.OptionalString("field"); if (target is not null && !SchemaNames.IsSlug(target)) { reader.Invalid("target", "must be the slug of a content type, such as 'product'"); } + if (field is not null && !SchemaNames.IsFieldName(field)) + { + reader.Invalid("field", "must be the name of a field of the target, such as 'price'"); + } + return reader.Errors(); } - public IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + public IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { - return [settings.GetProperty("target").GetString()!]; + var field = settings.TryGetProperty("field", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + return [new ReferenceTarget(settings.GetProperty("target").GetString()!, field)]; } } diff --git a/src/FluentCMS.Core/Schema/ContentType.cs b/src/FluentCMS.Core/Schema/ContentType.cs index 1b299bf39..30f3a76c5 100644 --- a/src/FluentCMS.Core/Schema/ContentType.cs +++ b/src/FluentCMS.Core/Schema/ContentType.cs @@ -85,10 +85,20 @@ public Result Revise(ContentTypeDefinition definition, FieldTypeReg ArgumentNullException.ThrowIfNull(definition); ArgumentNullException.ThrowIfNull(fieldTypes); - var validation = definition.Validate(fieldTypes); + var errors = new List(); + + // Dropping the flag would strip the source, owner and validity from every fact stored under this type (SCH-03). + if (Definition.IsEntity && !definition.IsEntity) + { + errors.Add(new Error( + SchemaErrorCodes.EntityFlagRemoved, + $"The content type '{Slug}' is an entity type whose fields carry source, owner and validity, so a revision must keep \"entity\": true.")); + } - return validation.IsSuccess + errors.AddRange(definition.Validate(fieldTypes).Errors); + + return errors.Count == 0 ? Result.Success(new ContentType(Id, SiteId, Name, Slug, Version + 1, definition)) - : Result.Failure(validation.Errors); + : Result.Failure(errors); } } diff --git a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs index c6025126a..ce9815d68 100644 --- a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs +++ b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs @@ -6,16 +6,17 @@ namespace FluentCMS.Core.Schema; // The fields of one content type version, stored as one JSON document (SCH-01, ADR-0005): -// {"fields":[{"name":"price","type":"number","required":true,"settings":{"min":0}}]} +// {"entity":true,"fields":[{"name":"price","type":"number","required":true,"settings":{"min":0}}]} public sealed class ContentTypeDefinition { private static readonly JsonDocumentOptions ParseOptions = new() { AllowDuplicateProperties = false }; - public ContentTypeDefinition(IEnumerable fields) + public ContentTypeDefinition(IEnumerable fields, bool isEntity = false) { ArgumentNullException.ThrowIfNull(fields); Fields = fields.ToList(); + IsEntity = isEntity; if (Fields.Any(field => field is null)) { throw new ArgumentException("A definition cannot contain a null field.", nameof(fields)); @@ -24,6 +25,9 @@ public ContentTypeDefinition(IEnumerable fields) public IReadOnlyList Fields { get; } + // An entity type is a fact source: every one of its fields carries FactMetadata next to its value (SCH-03). + public bool IsEntity { get; } + // Checks the shape of the document only; what the names, types and settings mean is checked by Validate. public static Result Parse(string json) { @@ -42,10 +46,10 @@ public static Result Parse(string json) using (document) { var errors = new List(); - var fields = ReadFields(document.RootElement, errors); + var (fields, isEntity) = ReadDocument(document.RootElement, errors); return errors.Count == 0 - ? Result.Success(new ContentTypeDefinition(fields)) + ? Result.Success(new ContentTypeDefinition(fields, isEntity)) : Result.Failure(errors); } } @@ -56,6 +60,7 @@ public string ToJson() using (var writer = new Utf8JsonWriter(buffer)) { writer.WriteStartObject(); + writer.WriteBoolean("entity", IsEntity); writer.WriteStartArray("fields"); foreach (var field in Fields) @@ -111,35 +116,56 @@ public Result Validate(FieldTypeRegistry fieldTypes) return errors.Count == 0 ? Result.Success() : Result.Failure(errors); } + public bool HasField(string name) + { + ArgumentNullException.ThrowIfNull(name); + return Fields.Any(field => field.Name == name); + } + // 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))) + .SelectMany(field => fieldTypes.ReferenceTargets(field.Type, field.Settings).Select(target => new FieldReference(field.Name, target))) .ToList(); } - private static List ReadFields(JsonElement root, List errors) + // A document without 'entity' is a plain content type, as every definition stored before the flag existed. + private static (List Fields, bool IsEntity) ReadDocument(JsonElement root, List errors) { var fields = new List(); + var isEntity = false; if (root.ValueKind != JsonValueKind.Object) { errors.Add(Malformed("$ must be an object with a 'fields' array.")); - return fields; + return (fields, isEntity); } - foreach (var property in root.EnumerateObject().Where(property => property.Name != "fields")) + foreach (var property in root.EnumerateObject()) { - errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has only 'fields'.")); + switch (property.Name) + { + case "fields": + break; + case "entity" when property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False: + isEntity = property.Value.GetBoolean(); + break; + case "entity": + errors.Add(Malformed("$.entity must be true or false.")); + break; + default: + errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has 'entity' and 'fields'.")); + break; + } } if (!root.TryGetProperty("fields", out var array) || array.ValueKind != JsonValueKind.Array) { errors.Add(Malformed("$.fields must be an array.")); - return fields; + return (fields, isEntity); } var index = 0; @@ -151,7 +177,7 @@ private static List ReadFields(JsonElement root, List er } } - return fields; + return (fields, isEntity); } private static FieldDefinition? ReadField(JsonElement element, string path, List errors) diff --git a/src/FluentCMS.Core/Schema/FactMetadata.cs b/src/FluentCMS.Core/Schema/FactMetadata.cs new file mode 100644 index 000000000..756c7659e --- /dev/null +++ b/src/FluentCMS.Core/Schema/FactMetadata.cs @@ -0,0 +1,17 @@ +namespace FluentCMS.Core.Schema; + +// What every field of an entity type carries next to its value (SCH-03). The schema document (SCH-06) describes these names and +// entity values are checked against them (F-ENT); a fact without a source is kept and flagged unsourced (ENT-02). +public static class FactMetadata +{ + public const string Value = "value"; + public const string Source = "source"; + public const string Owner = "owner"; + public const string ValidFrom = "validFrom"; + public const string ValidUntil = "validUntil"; + public const string LastVerified = "lastVerified"; + + public static IReadOnlyList Properties { get; } = [Source, Owner, ValidFrom, ValidUntil, LastVerified]; + + public static IReadOnlyList SourceKinds { get; } = ["url", "file", "person", "system"]; +} diff --git a/src/FluentCMS.Core/Schema/FieldReference.cs b/src/FluentCMS.Core/Schema/FieldReference.cs index 0823a5d11..db8b21d70 100644 --- a/src/FluentCMS.Core/Schema/FieldReference.cs +++ b/src/FluentCMS.Core/Schema/FieldReference.cs @@ -1,4 +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); +// A field of a definition whose values point at the target. +public sealed record FieldReference(string Field, ReferenceTarget Target); diff --git a/src/FluentCMS.Core/Schema/FieldSettingsReader.cs b/src/FluentCMS.Core/Schema/FieldSettingsReader.cs index d5964cd33..33e636e89 100644 --- a/src/FluentCMS.Core/Schema/FieldSettingsReader.cs +++ b/src/FluentCMS.Core/Schema/FieldSettingsReader.cs @@ -86,6 +86,22 @@ public bool Flag(string name) return null; } + public string? OptionalString(string name) + { + if (Read(name) is not { } value) + { + return null; + } + + if (value.ValueKind == JsonValueKind.String && value.GetString() is { } text && !string.IsNullOrWhiteSpace(text)) + { + return text; + } + + Invalid(name, "must be a non-empty string"); + return null; + } + public IReadOnlyList? RequiredStringList(string name) { if (Read(name) is not { } value) diff --git a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs index 120142046..4c692fb99 100644 --- a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs +++ b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs @@ -56,10 +56,10 @@ public IReadOnlyList ValidateSettings(string type, JsonElement settings) return fieldType.ValidateSettings(settings, this); } - public IReadOnlyList ReferencedTypes(string type, JsonElement settings) + public IReadOnlyList ReferenceTargets(string type, JsonElement settings) { ArgumentNullException.ThrowIfNull(type); - return Find(type)?.ReferencedTypes(settings, this) ?? []; + return Find(type)?.ReferenceTargets(settings, this) ?? []; } } diff --git a/src/FluentCMS.Core/Schema/IFieldType.cs b/src/FluentCMS.Core/Schema/IFieldType.cs index 7a0c6e58f..82f71a5b0 100644 --- a/src/FluentCMS.Core/Schema/IFieldType.cs +++ b/src/FluentCMS.Core/Schema/IFieldType.cs @@ -17,9 +17,9 @@ public interface IFieldType // 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. + // What a field of this type points at, so the schema can check that the types and fields exist (SCH-04). // Only called with settings that passed ValidateSettings. - IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { return []; } diff --git a/src/FluentCMS.Core/Schema/ReferenceTarget.cs b/src/FluentCMS.Core/Schema/ReferenceTarget.cs new file mode 100644 index 000000000..ba476da6b --- /dev/null +++ b/src/FluentCMS.Core/Schema/ReferenceTarget.cs @@ -0,0 +1,4 @@ +namespace FluentCMS.Core.Schema; + +// Entries of the content type with this slug, or one field of them when Field is set, such as product.price (SCH-04). +public sealed record ReferenceTarget(string Type, string? Field = null); diff --git a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs index eba50402c..0eec13523 100644 --- a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs +++ b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs @@ -17,4 +17,8 @@ public static class SchemaErrorCodes public const string ContentTypeNotFound = "content_type_not_found"; public const string VersionNotFound = "version_not_found"; public const string VersionConflict = "version_conflict"; + public const string EntityFlagRemoved = "entity_flag_removed"; + public const string ReferenceTargetNotEntity = "reference_target_not_entity"; + public const string ReferenceFieldUnknown = "reference_field_unknown"; + public const string FieldReferenced = "field_referenced"; } diff --git a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs index 861e9d67a..60f1f399f 100644 --- a/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs +++ b/src/FluentCMS.Infrastructure/Schema/ContentTypeDirectory.cs @@ -59,7 +59,7 @@ public async Task> CreateAsync(NewContentType contentType, C errors.Add(SlugTaken(type.Slug)); } - errors.AddRange(await UnknownTargetsAsync(type, cancellationToken)); + errors.AddRange(await ReferenceErrorsAsync(type, cancellationToken)); if (errors.Count > 0) { return Result.Failure(errors); @@ -105,17 +105,19 @@ public async Task> ReviseAsync( .AsNoTracking() .SingleAsync(version => version.ContentTypeId == row.Id && version.Version == row.Version, cancellationToken); - var revised = ToContentType(row, current).Revise(definition, fieldTypes); + var previous = ToContentType(row, current); + var revised = previous.Revise(definition, fieldTypes); if (!revised.IsSuccess) { return revised; } var type = revised.Value; - var unknownTargets = await UnknownTargetsAsync(type, cancellationToken); - if (unknownTargets.Count > 0) + var errors = await ReferenceErrorsAsync(type, cancellationToken); + errors.AddRange(await RemovedFieldsInUseAsync(type, previous.Definition, cancellationToken)); + if (errors.Count > 0) { - return Result.Failure(unknownTargets); + return Result.Failure(errors); } await using var scope = await unitOfWork.BeginAsync(cancellationToken); @@ -166,30 +168,93 @@ private async Task> ReadAsync(string slug, int? version, Can .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) + // A reference to the type being written is checked against the definition being written, so a category can have a parent category. + private async Task> ReferenceErrorsAsync(ContentType type, CancellationToken cancellationToken) { - var references = type.Definition.References(fieldTypes).Where(reference => reference.Target != type.Slug).ToList(); + var references = type.Definition.References(fieldTypes); 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); + var others = references.Select(reference => reference.Target.Type).Where(slug => slug != type.Slug).Distinct().ToList(); + var targets = others.Count == 0 ? [] : await CurrentDefinitionsAsync(others, cancellationToken); + targets[type.Slug] = type.Definition; + + var errors = new List(); + foreach (var (field, target) in references) + { + if (!targets.TryGetValue(target.Type, out var definition)) + { + errors.Add(new Error( + SchemaErrorCodes.ReferenceTargetUnknown, + $"Field '{field}': there is no content type '{target.Type}' in this site to reference.")); + } + else if (target.Field is null) + { + continue; + } + else if (!definition.IsEntity) + { + // Only facts are referenced field by field (SCH-04); anything else is referenced as a whole entry. + errors.Add(new Error( + SchemaErrorCodes.ReferenceTargetNotEntity, + $"Field '{field}': '{target.Type}' is not an entity type, so its fields cannot be referenced; reference its entries instead.")); + } + else if (!definition.HasField(target.Field)) + { + errors.Add(new Error( + SchemaErrorCodes.ReferenceFieldUnknown, + $"Field '{field}': the entity type '{target.Type}' has no field '{target.Field}'.")); + } + } + + return errors; + } + + // A field another type points at cannot go, or that reference would dangle (SCH-04). + private async Task> RemovedFieldsInUseAsync(ContentType type, ContentTypeDefinition previous, CancellationToken cancellationToken) + { + var removed = previous.Fields.Select(field => field.Name).Where(name => !type.Definition.HasField(name)).ToHashSet(); + if (removed.Count == 0) + { + return []; + } - 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.")) + var definitions = await CurrentDefinitionsAsync(slugs: null, cancellationToken); + + return definitions + .Where(other => other.Key != type.Slug) + .OrderBy(other => other.Key, StringComparer.Ordinal) + .SelectMany(other => other.Value.References(fieldTypes).Select(reference => (Type: other.Key, Reference: reference))) + .Where(use => use.Reference.Target.Type == type.Slug && use.Reference.Target.Field is { } field && removed.Contains(field)) + .Select(use => new Error( + SchemaErrorCodes.FieldReferenced, + $"Field '{use.Reference.Target.Field}' is referenced by the field '{use.Reference.Field}' of '{use.Type}'; change that type first.")) .ToList(); } + // The current definition of every type in the site, or of the given slugs only. + private async Task> CurrentDefinitionsAsync( + IReadOnlyCollection? slugs, + CancellationToken cancellationToken) + { + var query = + from type in context.ContentTypes.AsNoTracking() + join version in context.ContentTypeVersions.AsNoTracking() + on new { ContentTypeId = type.Id, type.Version } equals new { version.ContentTypeId, version.Version } + where type.SiteId == SiteId + select new { type.Slug, version.Version, version.Definition }; + + if (slugs is not null) + { + query = query.Where(row => slugs.Contains(row.Slug)); + } + + var rows = await query.ToListAsync(cancellationToken); + return rows.ToDictionary(row => row.Slug, row => ReadDefinition(row.Slug, row.Version, row.Definition)); + } + private static async Task> CommitAsync( IUnitOfWorkScope scope, ContentType type, @@ -222,15 +287,18 @@ private static ContentTypeVersionRow VersionRow(ContentType type) 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, ReadDefinition(row.Slug, version.Version, version.Definition)); + } - return ContentType.Restore(row.Id, row.SiteId, row.Name, row.Slug, version.Version, definition.Value); + private static ContentTypeDefinition ReadDefinition(string slug, int version, string json) + { + var definition = ContentTypeDefinition.Parse(json); + + return definition.IsSuccess + ? definition.Value + : throw new InvalidOperationException( + $"Version {version} of content type '{slug}' is stored in a form that cannot be read: " + + string.Join("; ", definition.Errors.Select(error => error.Message))); } private static Result NotFound(string slug) diff --git a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs index 776c6909b..0dc8bc368 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs @@ -250,34 +250,44 @@ public async Task Of_two_concurrent_revisions_of_one_version_one_wins_and_the_ot 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). + // The model behind the reference scenario (02-personas-and-scenarios), the setup for S1 and S2 in the F-SCH acceptance. [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 = + (string Slug, bool IsEntity, 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")]), + ("category", true, [Field("name", "text", required: true), Reference("parent", "category")]), + ("region", true, [Field("name", "text", required: true), Field("code", "text", """{"maxLength": 8}""")]), + ("spec", true, [Field("name", "text", required: true), Field("value", "text"), Field("unit", "enum", """{"values": ["mm", "kg", "W"]}""")]), + ("product", true, [.. ProductFields, Reference("category", "category"), References("specs", "spec"), References("regions", "region"), Field("hero", "media")]), + ("product-page", false, [Reference("product", "product"), Reference("price", "product", "price"), Field("headline", "text"), Field("body", "richText")]), + ("category-page", false, [Reference("category", "category"), Field("intro", "richText")]), + ("comparison-page", false, [References("products", "product"), References("prices", "product", "price"), Field("verdict", "richText"), Field("extra", "json")]), + ("blog-post", false, [Field("title", "text", required: true), Field("body", "richText"), Field("publishedOn", "dateTime"), Field("featured", "boolean"), References("related", "product")]), ]; - foreach (var (slug, fields) in model) + foreach (var (slug, isEntity, fields) in model) { - var created = await scenario.CreateAsync(slug, fields); + var created = isEntity ? await scenario.CreateEntityAsync(slug, fields) : 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)); + + foreach (var (slug, isEntity, _) in model) + { + Assert.Equal(isEntity, (await scenario.RunAsync(directory => directory.GetAsync(slug))).Value.Definition.IsEntity); + } + + var productPage = (await scenario.RunAsync(directory => directory.GetAsync("product-page"))).Value; + Assert.Contains( + new FieldReference("price", new ReferenceTarget("product", "price")), + productPage.Definition.References(scenario.Services.GetRequiredService())); } [PostgresFact] diff --git a/tests/FluentCMS.IntegrationTests/Schema/EntityTypeTests.cs b/tests/FluentCMS.IntegrationTests/Schema/EntityTypeTests.cs new file mode 100644 index 000000000..41fd72b84 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Schema/EntityTypeTests.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.Infrastructure.Schema; +using static FluentCMS.IntegrationTests.Schema.SchemaScenario; + +namespace FluentCMS.IntegrationTests.Schema; + +// Entity types (SCH-03) and references to an entity or one of its fields (SCH-04) through the content type directory. +public sealed class EntityTypeTests +{ + private static readonly FieldDefinition[] ProductFields = + [ + Field("name", "text", required: true), + Field("price", "number", """{"min": 0}""", required: true), + Field("currency", "enum", """{"values": ["EUR", "USD"]}"""), + ]; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_entity_type_is_stored_with_its_flag_and_reads_back_as_one(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + await scenario.CreateEntityAsync("product", ProductFields); + var product = (await scenario.RunAsync(directory => directory.GetAsync("product"))).Value; + var stored = JsonSerializer.Deserialize(Assert.Single(await scenario.VersionRowsAsync()).Definition); + + Assert.True(product.Definition.IsEntity); + Assert.True(stored.GetProperty("entity").GetBoolean()); + } + + // Definitions stored before the flag existed have no 'entity' property. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_stored_definition_without_the_flag_reads_as_a_content_type(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + var id = Guid.NewGuid(); + + await scenario.QueryAsync(async context => + { + context.ContentTypes.Add(new ContentTypeRow { Id = id, SiteId = Site.Default.Id, Name = "Page", Slug = "page", Version = 1 }); + context.ContentTypeVersions.Add(new ContentTypeVersionRow + { + Id = Guid.NewGuid(), + ContentTypeId = id, + Version = 1, + Definition = """{"fields":[{"name":"title","type":"text","required":true,"settings":{}}]}""", + }); + return await context.SaveChangesAsync(); + }); + + var page = (await scenario.RunAsync(directory => directory.GetAsync("page"))).Value; + + Assert.False(page.Definition.IsEntity); + Assert.Equal("title", Assert.Single(page.Definition.Fields).Name); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_entity_type_keeps_its_flag_and_a_content_type_can_take_it(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateEntityAsync("product", ProductFields); + await scenario.CreateAsync("region", Field("name", "text")); + + var dropped = await scenario.ReviseAsync("product", 1, new ContentTypeDefinition(ProductFields)); + var flagged = await scenario.ReviseAsync("region", 1, new ContentTypeDefinition([Field("name", "text")], isEntity: true)); + + Assert.Equal(SchemaErrorCodes.EntityFlagRemoved, Assert.Single(dropped.Errors).Code); + Assert.True((await scenario.RunAsync(directory => directory.GetAsync("product"))).Value.Definition.IsEntity); + Assert.True(flagged.Value.Definition.IsEntity); + Assert.Equal(3, (await scenario.VersionRowsAsync()).Count); + Assert.Equal(3, (await scenario.AuditRowsAsync()).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_reference_can_target_one_field_of_an_entity(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateEntityAsync("product", ProductFields); + + var created = await scenario.CreateAsync( + "product-page", + Reference("product", "product"), + Reference("price", "product", "price"), + References("prices", "product", "price")); + + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + var read = (await scenario.RunAsync(directory => directory.GetAsync("product-page"))).Value; + Assert.Equal("price", read.Definition.Fields[1].Settings.GetProperty("field").GetString()); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_field_reference_needs_an_entity_type_that_has_the_field(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateEntityAsync("product", ProductFields); + await scenario.CreateAsync("blog-post", Field("title", "text")); + + var result = await scenario.CreateAsync( + "product-page", + Reference("cost", "product", "cost"), + Reference("headline", "blog-post", "title"), + References("costs", "product", "cost")); + + Assert.Equal( + [ + "Field 'cost': the entity type 'product' has no field 'cost'.", + "Field 'headline': 'blog-post' is not an entity type, so its fields cannot be referenced; reference its entries instead.", + "Field 'costs': the entity type 'product' has no field 'cost'.", + ], + result.Errors.Select(error => error.Message)); + Assert.Equal( + [SchemaErrorCodes.ReferenceFieldUnknown, SchemaErrorCodes.ReferenceTargetNotEntity, SchemaErrorCodes.ReferenceFieldUnknown], + result.Errors.Select(error => error.Code)); + Assert.Equal(2, (await scenario.ContentTypeRowsAsync()).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_field_that_another_type_references_cannot_be_removed(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateEntityAsync("product", ProductFields); + await scenario.CreateAsync("product-page", Reference("price", "product", "price")); + await scenario.CreateAsync("comparison-page", References("prices", "product", "price")); + + var withoutPrice = await scenario.ReviseAsync("product", 1, new ContentTypeDefinition([ProductFields[0], ProductFields[2]], isEntity: true)); + var withoutCurrency = await scenario.ReviseAsync("product", 1, new ContentTypeDefinition([ProductFields[0], ProductFields[1]], isEntity: true)); + + Assert.Equal( + [ + "Field 'price' is referenced by the field 'prices' of 'comparison-page'; change that type first.", + "Field 'price' is referenced by the field 'price' of 'product-page'; change that type first.", + ], + withoutPrice.Errors.Select(error => error.Message)); + Assert.All(withoutPrice.Errors, error => Assert.Equal(SchemaErrorCodes.FieldReferenced, error.Code)); + Assert.Equal(2, withoutCurrency.Value.Version); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_type_references_its_own_fields_only_as_an_entity_type(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + + var entity = await scenario.CreateEntityAsync("region", Field("name", "text"), Reference("alias", "region", "name")); + var plain = await scenario.CreateAsync("page", Field("title", "text"), Reference("heading", "page", "title")); + + Assert.True(entity.IsSuccess, string.Join("; ", entity.Errors.Select(error => error.Message))); + Assert.Equal(SchemaErrorCodes.ReferenceTargetNotEntity, Assert.Single(plain.Errors).Code); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs index 0c952e0e9..56ad4ce54 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs @@ -48,6 +48,16 @@ public Task> CreateAsync(string slug, params FieldDefinition return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields)))); } + public Task> CreateEntityAsync(string slug, params FieldDefinition[] fields) + { + return RunAsync(directory => directory.CreateAsync(new NewContentType(Title(slug), slug, new ContentTypeDefinition(fields, isEntity: true)))); + } + + public Task> ReviseAsync(string slug, int baseVersion, ContentTypeDefinition definition) + { + return RunAsync(directory => directory.ReviseAsync(slug, baseVersion, definition)); + } + public async Task> ContentTypeRowsAsync() { return await QueryAsync(context => context.ContentTypes.AsNoTracking().ToListAsync()); @@ -80,14 +90,19 @@ public static FieldDefinition Field(string name, string type, string settings = return new FieldDefinition(name, type, required, JsonSerializer.Deserialize(settings)); } - public static FieldDefinition Reference(string name, string target) + public static FieldDefinition Reference(string name, string target, string? field = null) + { + return Field(name, "reference", ReferenceSettings(target, field)); + } + + public static FieldDefinition References(string name, string target, string? field = null) { - return Field(name, "reference", $$"""{"target": "{{target}}"}"""); + return Field(name, "list", $$"""{"itemType": "reference", "itemSettings": {{ReferenceSettings(target, field)}}}"""); } - public static FieldDefinition References(string name, string target) + private static string ReferenceSettings(string target, string? field) { - return Field(name, "list", $$$"""{"itemType": "reference", "itemSettings": {"target": "{{{target}}}"}}"""); + return field is null ? $$"""{"target": "{{target}}"}""" : $$"""{"target": "{{target}}", "field": "{{field}}"}"""; } private static string Title(string slug) diff --git a/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs b/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs index 89ffc2128..22d69791a 100644 --- a/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/BuiltinFieldTypeTests.cs @@ -109,15 +109,29 @@ public sealed class ReferenceFieldTypeTests : FieldTypeConformanceTests { protected override string FieldTypeName => "reference"; - protected override IEnumerable AcceptedSettings => ["""{"target": "product"}""", """{"target": "product-page"}"""]; + protected override IEnumerable AcceptedSettings => + ["""{"target": "product"}""", """{"target": "product-page"}""", """{"target": "product", "field": "price"}"""]; protected override IEnumerable RejectedSettings => - ["{}", """{"target": "Product"}""", """{"target": "product page"}""", """{"target": ""}""", """{"target": 5}"""]; + [ + "{}", + """{"target": "Product"}""", + """{"target": "product page"}""", + """{"target": ""}""", + """{"target": 5}""", + """{"field": "price"}""", + """{"target": "product", "field": "Price"}""", + """{"target": "product", "field": ""}""", + """{"target": "product", "field": ["price"]}""", + ]; [Fact] - public void Declares_its_target_as_referenced() + public void Declares_its_target_type_and_field() { - Assert.Equal(["product"], Registry.ReferencedTypes(FieldTypeName, Parse("""{"target": "product"}"""))); + Assert.Equal([new ReferenceTarget("product")], Registry.ReferenceTargets(FieldTypeName, Parse("""{"target": "product"}"""))); + Assert.Equal( + [new ReferenceTarget("product", "price")], + Registry.ReferenceTargets(FieldTypeName, Parse("""{"target": "product", "field": "price"}"""))); } } @@ -155,8 +169,10 @@ public void A_list_of_lists_is_rejected() [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"}"""))); + Assert.Equal( + [new ReferenceTarget("product", "price")], + Registry.ReferenceTargets(FieldTypeName, Parse("""{"itemType": "reference", "itemSettings": {"target": "product", "field": "price"}}"""))); + Assert.Empty(Registry.ReferenceTargets(FieldTypeName, Parse("""{"itemType": "text"}"""))); } [Fact] diff --git a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs index 6b5946741..01bd610e0 100644 --- a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs +++ b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs @@ -10,7 +10,7 @@ public sealed class ContentTypeDefinitionTests : IDisposable // One field of every SCH-02 type, in the form the definition is stored in. private const string EveryFieldType = """ - {"fields":[ + {"entity":false,"fields":[ {"name":"title","type":"text","required":true,"settings":{"maxLength":200}}, {"name":"body","type":"richText","required":false,"settings":{}}, {"name":"price","type":"number","required":true,"settings":{"min":0,"max":99999.99}}, @@ -56,7 +56,7 @@ public void ToJson_writes_every_property_of_every_field() ]); Assert.Equal( - """{"fields":[{"name":"title","type":"text","required":true,"settings":{"maxLength":200}},{"name":"body","type":"richText","required":false,"settings":{}}]}""", + """{"entity":false,"fields":[{"name":"title","type":"text","required":true,"settings":{"maxLength":200}},{"name":"body","type":"richText","required":false,"settings":{}}]}""", definition.ToJson()); } @@ -124,7 +124,7 @@ public void Every_structural_problem_is_reported_with_its_path() Assert.All(result.Errors, error => Assert.Equal(SchemaErrorCodes.DefinitionMalformed, error.Code)); Assert.Equal( [ - "$.version is not part of a definition; it has only 'fields'.", + "$.version is not part of a definition; it has 'entity' and 'fields'.", "$.fields[0].type must be a string.", "$.fields[1].name is missing.", "$.fields[2] must be an object.", @@ -180,7 +180,68 @@ 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)); + Assert.Equal([new FieldReference("category", new ReferenceTarget("category"))], definition.References(FieldTypes)); + } + + [Fact] + public void The_entity_flag_is_written_and_read_back() + { + var entity = new ContentTypeDefinition([new FieldDefinition("price", "number")], isEntity: true); + + var json = entity.ToJson(); + var readBack = ContentTypeDefinition.Parse(json).Value; + + Assert.StartsWith("""{"entity":true,""", json); + Assert.True(readBack.IsEntity); + Assert.Equal(json, readBack.ToJson()); + } + + // Definitions stored before the flag existed have no 'entity' property, and they stay plain content types. + [Fact] + public void A_document_without_the_flag_is_a_plain_content_type() + { + Assert.False(ContentTypeDefinition.Parse("""{"fields":[]}""").Value.IsEntity); + } + + [Theory] + [InlineData("""{"entity": "yes", "fields": []}""")] + [InlineData("""{"entity": null, "fields": []}""")] + [InlineData("""{"entity": 1, "fields": []}""")] + public void The_entity_flag_must_be_true_or_false(string json) + { + var error = Assert.Single(ContentTypeDefinition.Parse(json).Errors); + + Assert.Equal("$.entity must be true or false.", error.Message); + } + + // There is no way to opt a field of an entity type out of its fact metadata (SCH-03). + [Fact] + public void A_field_cannot_opt_out_of_fact_metadata() + { + var result = ContentTypeDefinition.Parse("""{"entity": true, "fields": [{"name": "price", "type": "number", "fact": false}]}"""); + + Assert.Equal( + "$.fields[0].fact is not part of a field; a field has name, type, required and settings.", + Assert.Single(result.Errors).Message); + } + + [Fact] + public void References_name_the_field_a_reference_targets() + { + var definition = new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", settings: Element("""{"target": "product"}""")), + new FieldDefinition("price", "reference", settings: Element("""{"target": "product", "field": "price"}""")), + new FieldDefinition("prices", "list", settings: Element("""{"itemType": "reference", "itemSettings": {"target": "product", "field": "price"}}""")), + ]); + + Assert.Equal( + [ + new FieldReference("product", new ReferenceTarget("product")), + new FieldReference("price", new ReferenceTarget("product", "price")), + new FieldReference("prices", new ReferenceTarget("product", "price")), + ], + definition.References(FieldTypes)); } [Fact] diff --git a/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs b/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs index 40680067d..8ec42ba09 100644 --- a/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/ContentTypeTests.cs @@ -130,6 +130,31 @@ public void A_restored_type_keeps_what_was_stored_without_validating_it_again() Assert.False(restored.Revise(stored, FieldTypes).IsSuccess); } + [Fact] + public void An_entity_type_stays_an_entity_type() + { + var fields = new[] { new FieldDefinition("price", "number") }; + var product = ContentType.Create(Site, "Product", "product", new ContentTypeDefinition(fields, isEntity: true), FieldTypes).Value; + + var dropped = product.Revise(new ContentTypeDefinition(fields), FieldTypes); + var kept = product.Revise(new ContentTypeDefinition([.. fields, new FieldDefinition("currency", "text")], isEntity: true), FieldTypes); + + Assert.True(product.Definition.IsEntity); + Assert.Equal(SchemaErrorCodes.EntityFlagRemoved, Assert.Single(dropped.Errors).Code); + Assert.True(kept.Value.Definition.IsEntity); + } + + [Fact] + public void A_content_type_can_become_an_entity_type() + { + var region = ContentType.Create(Site, "Region", "region", ProductPage(), FieldTypes).Value; + + var flagged = region.Revise(new ContentTypeDefinition(region.Definition.Fields, isEntity: true), FieldTypes); + + Assert.True(flagged.Value.Definition.IsEntity); + Assert.Equal(2, flagged.Value.Version); + } + public void Dispose() { _provider.Dispose(); diff --git a/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs b/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs new file mode 100644 index 000000000..9abeb6e94 --- /dev/null +++ b/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs @@ -0,0 +1,20 @@ +using FluentCMS.Core.Schema; + +namespace FluentCMS.Tests.Schema; + +// The names are a contract with the schema document (SCH-06) and entity values (F-ENT), so a rename has to be deliberate. +public sealed class FactMetadataTests +{ + [Fact] + public void Every_fact_carries_a_source_an_owner_and_its_validity() + { + Assert.Equal(["source", "owner", "validFrom", "validUntil", "lastVerified"], FactMetadata.Properties); + Assert.Equal("value", FactMetadata.Value); + } + + [Fact] + public void A_source_is_a_url_a_file_a_person_or_a_system() + { + Assert.Equal(["url", "file", "person", "system"], FactMetadata.SourceKinds); + } +} diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs index ded686115..c681a1521 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs @@ -87,13 +87,17 @@ public void Rejects_a_setting_it_does_not_know() } [Fact] - public void Declares_only_slugs_as_referenced_types() + public void Declares_reference_targets_as_a_slug_and_an_optional_field_name() { 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.")); + foreach (var target in Registry.ReferenceTargets(FieldTypeName, Parse(json))) + { + Assert.True(SchemaNames.IsSlug(target.Type), $"{json} declared the type '{target.Type}', which is not a slug."); + Assert.True( + target.Field is null || SchemaNames.IsFieldName(target.Field), + $"{json} declared the field '{target.Field}', which is not a field name."); + } } } diff --git a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs index 67b140a62..5a7182aab 100644 --- a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs @@ -118,7 +118,7 @@ public void Its_targets_count_as_references_of_a_definition() ]); Assert.Equal( - [new FieldReference("seeAlso", "product"), new FieldReference("category", "category")], + [new FieldReference("seeAlso", new ReferenceTarget("product")), new FieldReference("category", new ReferenceTarget("category"))], definition.References(Registry)); } } @@ -139,8 +139,8 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } - public IReadOnlyList ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes) + public IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { - return [settings.GetProperty("to").GetString()!]; + return [new ReferenceTarget(settings.GetProperty("to").GetString()!)]; } }