From 3a5301beeddc456601d054ecf5b1345f80d2ca02 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 13:07:42 -0400 Subject: [PATCH 1/2] 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()!)]; } } From 7cd252dda189cbef65c959edb542cb57a45defd8 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 14:06:08 -0400 Subject: [PATCH 2/2] Add the schema document with field descriptions and examples --- CONTRIBUTING.md | 2 +- Directory.Packages.props | 1 + .../Builtin/FieldTypes/BooleanFieldType.cs | 6 + .../Builtin/FieldTypes/DateTimeFieldType.cs | 14 ++ .../Builtin/FieldTypes/EnumFieldType.cs | 11 + .../Builtin/FieldTypes/Examples.cs | 14 ++ .../Builtin/FieldTypes/JsonFieldType.cs | 6 + .../Builtin/FieldTypes/ListFieldType.cs | 14 ++ .../Builtin/FieldTypes/MediaFieldType.cs | 9 + .../Builtin/FieldTypes/NumberFieldType.cs | 43 ++++ .../Builtin/FieldTypes/ReferenceFieldType.cs | 14 ++ .../Builtin/FieldTypes/RichTextFieldType.cs | 16 ++ .../Builtin/FieldTypes/TextFieldType.cs | 13 ++ .../CoreServiceCollectionExtensions.cs | 3 + .../Schema/ContentTypeDefinition.cs | 83 ++++++- src/FluentCMS.Core/Schema/FactMetadata.cs | 4 + src/FluentCMS.Core/Schema/FieldDefinition.cs | 6 +- .../Schema/FieldTypeRegistry.cs | 10 + .../Schema/FieldValueDescription.cs | 11 + src/FluentCMS.Core/Schema/IFieldType.cs | 4 + src/FluentCMS.Core/Schema/ISchemaDocuments.cs | 14 ++ src/FluentCMS.Core/Schema/SchemaDocument.cs | 155 +++++++++++++ src/FluentCMS.Core/Schema/SchemaDocuments.cs | 29 +++ src/FluentCMS.Core/Schema/SchemaErrorCodes.cs | 1 + .../FluentCMS.IntegrationTests.csproj | 1 + .../Schema/ContentTypeDirectoryTests.cs | 23 +- .../Schema/SchemaDocumentsTests.cs | 78 +++++++ .../Schema/SchemaScenario.cs | 37 +++- tests/FluentCMS.Tests/FluentCMS.Tests.csproj | 1 + .../Schema/ContentTypeDefinitionTests.cs | 50 ++++- .../Schema/FactMetadataTests.cs | 1 + .../Schema/FieldTypeConformanceTests.cs | 16 ++ .../Schema/FieldTypeRegistryTests.cs | 6 + .../Schema/JsonSchemaValidation.cs | 14 ++ .../Schema/PluginFieldTypeTests.cs | 19 ++ .../Schema/SchemaDocumentTests.cs | 203 ++++++++++++++++++ 36 files changed, 894 insertions(+), 38 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs create mode 100644 src/FluentCMS.Core/Schema/FieldValueDescription.cs create mode 100644 src/FluentCMS.Core/Schema/ISchemaDocuments.cs create mode 100644 src/FluentCMS.Core/Schema/SchemaDocument.cs create mode 100644 src/FluentCMS.Core/Schema/SchemaDocuments.cs create mode 100644 tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs create mode 100644 tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs create mode 100644 tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f078efff..d1811d52f 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, 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. +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`. Every type also describes its values for the schema document agents read (`DescribeValue`, SCH-06): a JSON Schema for one value, an example that satisfies it and one sentence about it. 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/Directory.Packages.props b/Directory.Packages.props index 0522f2e33..dddc49271 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs index 3b86543e8..cbf6a5c22 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -12,4 +13,9 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return new FieldSettingsReader(Name, settings).Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription(new JsonObject { ["type"] = "boolean" }, true, "true or false."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs index 1451e142c..0f6ff9f37 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,17 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldSettingsReader(Name, settings).Flag("dateOnly") + ? new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "date" }, + "2026-01-31", + "A calendar date as YYYY-MM-DD.") + : new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "date-time" }, + "2026-01-31T09:30:00Z", + "A point in time in ISO 8601 with an offset, such as 2026-01-31T09:30:00Z."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs index 9c20afd85..43aa75009 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,14 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var values = new FieldSettingsReader(Name, settings).RequiredStringList("values") ?? []; + + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["enum"] = new JsonArray([.. values.Select(value => (JsonNode?)value)]) }, + values.FirstOrDefault(), + $"One of: {string.Join(", ", values)}."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs b/src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs new file mode 100644 index 000000000..d338fd394 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Core.Builtin.FieldTypes; + +internal static class Examples +{ + public static string Fit(string text, int? maxLength) + { + return maxLength is { } limit && text.Length > limit ? text[..limit] : text; + } + + public static string AtMost(int? maxLength) + { + return maxLength is { } limit ? $" of at most {limit} characters" : string.Empty; + } +} diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs index fc9418aa7..0a7a3b997 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -13,4 +14,9 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return new FieldSettingsReader(Name, settings).Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription(new JsonObject(), new JsonObject { ["key"] = "value" }, "Any JSON value."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs index 602f010fc..8e93d375f 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -30,6 +31,19 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object + ? value + : FieldDefinition.EmptySettings; + var item = fieldTypes.DescribeValue(settings.GetProperty("itemType").GetString()!, itemSettings); + + return new FieldValueDescription( + new JsonObject { ["type"] = "array", ["items"] = item.Schema }, + new JsonArray(item.Example), + $"A list of values. Each one: {item.Description}"); + } + public IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs index 3eb866f70..2d89c3e69 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -13,4 +14,12 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return new FieldSettingsReader(Name, settings).Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + FieldValueDescription.ExampleId, + "The id of an asset in the media library."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs index b4325d441..cdddac70e 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -32,4 +34,45 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var reader = new FieldSettingsReader(Name, settings); + var integer = reader.Flag("integer"); + var min = reader.Number("min"); + var max = reader.Number("max"); + + var schema = new JsonObject { ["type"] = integer ? "integer" : "number" }; + if (min is { } low) + { + schema["minimum"] = low; + } + + if (max is { } high) + { + schema["maximum"] = high; + } + + var range = (min, max) switch + { + (not null, not null) => string.Create(CultureInfo.InvariantCulture, $" from {min} to {max}"), + (not null, null) => string.Create(CultureInfo.InvariantCulture, $" of at least {min}"), + (null, not null) => string.Create(CultureInfo.InvariantCulture, $" of at most {max}"), + _ => string.Empty, + }; + + return new FieldValueDescription(schema, Example(integer, min ?? max ?? 0m), $"{(integer ? "A whole number" : "A number")}{range}."); + } + + // A whole number is written without the ".0" a setting may carry, so the example reads as an integer. + private static JsonNode Example(bool integer, decimal value) + { + if (!integer) + { + return value; + } + + var whole = decimal.Truncate(value); + return whole is >= long.MinValue and <= long.MaxValue ? decimal.ToInt64(whole) : whole; + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs index eea959cb6..64d79e0dd 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -29,6 +30,19 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var target = ReferenceTargets(settings, fieldTypes)[0]; + + // A field reference stores the entity, not the value, so the page always shows the entity's current fact (SCH-04, CNT-04). + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + FieldValueDescription.ExampleId, + target.Field is null + ? $"The id of an entry of the content type '{target.Type}'." + : $"The id of a '{target.Type}' entity; the field shows that entity's '{target.Field}' instead of a copy of it."); + } + public IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { var field = settings.TryGetProperty("field", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs index 6805105ea..fed9c5769 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,19 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var maxLength = new FieldSettingsReader(Name, settings).PositiveInteger("maxLength"); + var schema = new JsonObject { ["type"] = "string", ["contentMediaType"] = "text/markdown" }; + if (maxLength is { } limit) + { + schema["maxLength"] = limit; + } + + return new FieldValueDescription( + schema, + Examples.Fit("Some **Markdown** text.", maxLength), + $"Formatted text written as Markdown{Examples.AtMost(maxLength)}."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs index f52604951..ff3637bcc 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,16 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var maxLength = new FieldSettingsReader(Name, settings).PositiveInteger("maxLength"); + var schema = new JsonObject { ["type"] = "string" }; + if (maxLength is { } limit) + { + schema["maxLength"] = limit; + } + + return new FieldValueDescription(schema, Examples.Fit("Example text", maxLength), $"Plain text{Examples.AtMost(maxLength)}."); + } } diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 69dc3a629..5adc43b93 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -19,6 +19,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Reads through IAuditReader, which Infrastructure provides. services.AddScoped(); + // Reads through IContentTypeDirectory, which Infrastructure provides. + services.AddScoped(); + return services; } } diff --git a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs index ce9815d68..ff39c160a 100644 --- a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs +++ b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs @@ -6,17 +6,20 @@ namespace FluentCMS.Core.Schema; // The fields of one content type version, stored as one JSON document (SCH-01, ADR-0005): -// {"entity":true,"fields":[{"name":"price","type":"number","required":true,"settings":{"min":0}}]} +// {"entity":true,"description":"...","fields":[{"name":"price","type":"number","required":true,"description":"...","settings":{"min":0}}]} public sealed class ContentTypeDefinition { + public const int MaxDescriptionLength = 1000; + private static readonly JsonDocumentOptions ParseOptions = new() { AllowDuplicateProperties = false }; - public ContentTypeDefinition(IEnumerable fields, bool isEntity = false) + public ContentTypeDefinition(IEnumerable fields, bool isEntity = false, string? description = null) { ArgumentNullException.ThrowIfNull(fields); Fields = fields.ToList(); IsEntity = isEntity; + Description = description; if (Fields.Any(field => field is null)) { throw new ArgumentException("A definition cannot contain a null field.", nameof(fields)); @@ -28,6 +31,9 @@ public ContentTypeDefinition(IEnumerable fields, bool isEntity // An entity type is a fact source: every one of its fields carries FactMetadata next to its value (SCH-03). public bool IsEntity { get; } + // What the type is for, in the author's words; the schema document shows it to agents (SCH-06). + public string? Description { 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) { @@ -46,10 +52,10 @@ public static Result Parse(string json) using (document) { var errors = new List(); - var (fields, isEntity) = ReadDocument(document.RootElement, errors); + var (fields, isEntity, description) = ReadDocument(document.RootElement, errors); return errors.Count == 0 - ? Result.Success(new ContentTypeDefinition(fields, isEntity)) + ? Result.Success(new ContentTypeDefinition(fields, isEntity, description)) : Result.Failure(errors); } } @@ -61,6 +67,7 @@ public string ToJson() { writer.WriteStartObject(); writer.WriteBoolean("entity", IsEntity); + WriteDescription(writer, Description); writer.WriteStartArray("fields"); foreach (var field in Fields) @@ -69,6 +76,7 @@ public string ToJson() writer.WriteString("name", field.Name); writer.WriteString("type", field.Type); writer.WriteBoolean("required", field.Required); + WriteDescription(writer, field.Description); writer.WritePropertyName("settings"); field.Settings.WriteTo(writer); writer.WriteEndObject(); @@ -89,6 +97,11 @@ public Result Validate(FieldTypeRegistry fieldTypes) var errors = new List(); var names = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!IsValidDescription(Description)) + { + errors.Add(DescriptionInvalid("The type's description")); + } + for (var index = 0; index < Fields.Count; index++) { var field = Fields[index]; @@ -109,6 +122,11 @@ public Result Validate(FieldTypeRegistry fieldTypes) $"{label}: another field already has this name; field names are compared ignoring case.")); } + if (!IsValidDescription(field.Description)) + { + errors.Add(DescriptionInvalid($"{label}: the description")); + } + errors.AddRange(fieldTypes.ValidateSettings(field.Type, field.Settings) .Select(error => error with { Message = $"{label}: {error.Message}" })); } @@ -133,15 +151,16 @@ public IReadOnlyList References(FieldTypeRegistry fieldTypes) } // 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) + private static (List Fields, bool IsEntity, string? Description) ReadDocument(JsonElement root, List errors) { var fields = new List(); var isEntity = false; + string? description = null; if (root.ValueKind != JsonValueKind.Object) { errors.Add(Malformed("$ must be an object with a 'fields' array.")); - return (fields, isEntity); + return (fields, isEntity, description); } foreach (var property in root.EnumerateObject()) @@ -156,8 +175,11 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl case "entity": errors.Add(Malformed("$.entity must be true or false.")); break; + case "description": + description = ReadDescription(property.Value, "$.description", errors); + break; default: - errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has 'entity' and 'fields'.")); + errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has 'entity', 'description' and 'fields'.")); break; } } @@ -165,7 +187,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl if (!root.TryGetProperty("fields", out var array) || array.ValueKind != JsonValueKind.Array) { errors.Add(Malformed("$.fields must be an array.")); - return (fields, isEntity); + return (fields, isEntity, description); } var index = 0; @@ -177,7 +199,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl } } - return (fields, isEntity); + return (fields, isEntity, description); } private static FieldDefinition? ReadField(JsonElement element, string path, List errors) @@ -192,6 +214,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl string? name = null; string? type = null; var required = false; + string? description = null; JsonElement? settings = null; foreach (var property in element.EnumerateObject()) @@ -211,6 +234,9 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl case "settings" when value.ValueKind == JsonValueKind.Object: settings = value; break; + case "description": + description = ReadDescription(value, $"{path}.description", errors); + break; case "name" or "type": errors.Add(Malformed($"{path}.{property.Name} must be a string.")); break; @@ -221,7 +247,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl errors.Add(Malformed($"{path}.settings must be an object.")); break; default: - errors.Add(Malformed($"{path}.{property.Name} is not part of a field; a field has name, type, required and settings.")); + errors.Add(Malformed($"{path}.{property.Name} is not part of a field; a field has name, type, required, description and settings.")); break; } } @@ -236,7 +262,42 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl errors.Add(Malformed($"{path}.type is missing.")); } - return errors.Count == count ? new FieldDefinition(name!, type!, required, settings) : null; + return errors.Count == count ? new FieldDefinition(name!, type!, required, settings, description) : null; + } + + // An explicit null reads as no description. + private static string? ReadDescription(JsonElement value, string path, List errors) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + return value.GetString(); + case JsonValueKind.Null: + return null; + default: + errors.Add(Malformed($"{path} must be a string.")); + return null; + } + } + + private static void WriteDescription(Utf8JsonWriter writer, string? description) + { + if (description is not null) + { + writer.WriteString("description", description); + } + } + + private static bool IsValidDescription(string? description) + { + return description is null || (!string.IsNullOrWhiteSpace(description) && description.Length <= MaxDescriptionLength); + } + + private static Error DescriptionInvalid(string subject) + { + return new Error( + SchemaErrorCodes.DescriptionInvalid, + $"{subject} must not be blank and must be at most {MaxDescriptionLength} characters; leave it out instead."); } private static Error Malformed(string message) diff --git a/src/FluentCMS.Core/Schema/FactMetadata.cs b/src/FluentCMS.Core/Schema/FactMetadata.cs index 756c7659e..262caa303 100644 --- a/src/FluentCMS.Core/Schema/FactMetadata.cs +++ b/src/FluentCMS.Core/Schema/FactMetadata.cs @@ -11,6 +11,10 @@ public static class FactMetadata public const string ValidUntil = "validUntil"; public const string LastVerified = "lastVerified"; + // A source is {"kind": "url", "reference": "https://..."}; the reference is the URL, file, person or system it names. + public const string SourceKind = "kind"; + public const string SourceReference = "reference"; + 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/FieldDefinition.cs b/src/FluentCMS.Core/Schema/FieldDefinition.cs index 594a6f966..eb64ed6fc 100644 --- a/src/FluentCMS.Core/Schema/FieldDefinition.cs +++ b/src/FluentCMS.Core/Schema/FieldDefinition.cs @@ -5,7 +5,7 @@ namespace FluentCMS.Core.Schema; // One field of a content type definition. Not a record: settings are JSON, which has no value equality. public sealed class FieldDefinition { - public FieldDefinition(string name, string type, bool required = false, JsonElement? settings = null) + public FieldDefinition(string name, string type, bool required = false, JsonElement? settings = null, string? description = null) { ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(type); @@ -19,6 +19,7 @@ public FieldDefinition(string name, string type, bool required = false, JsonElem Name = name; Type = type; Required = required; + Description = description; // A clone outlives the document it was read from. Settings = resolved.Clone(); } @@ -31,6 +32,9 @@ public FieldDefinition(string name, string type, bool required = false, JsonElem public bool Required { get; } + // What the field means, in the author's words; the schema document shows it to agents (SCH-06). + public string? Description { get; } + // Read by the field type named in Type; always a JSON object. public JsonElement Settings { get; } diff --git a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs index 4c692fb99..e59ae0616 100644 --- a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs +++ b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; namespace FluentCMS.Core.Schema; @@ -56,6 +57,15 @@ public IReadOnlyList ValidateSettings(string type, JsonElement settings) return fieldType.ValidateSettings(settings, this); } + // A type that is no longer installed is described as any value, so a stored definition that uses it still has a document. + public FieldValueDescription DescribeValue(string type, JsonElement settings) + { + ArgumentNullException.ThrowIfNull(type); + + return Find(type)?.DescribeValue(settings, this) + ?? new FieldValueDescription(new JsonObject(), null, $"A value of the field type '{type}', which is not installed."); + } + public IReadOnlyList ReferenceTargets(string type, JsonElement settings) { ArgumentNullException.ThrowIfNull(type); diff --git a/src/FluentCMS.Core/Schema/FieldValueDescription.cs b/src/FluentCMS.Core/Schema/FieldValueDescription.cs new file mode 100644 index 000000000..525d3f5c6 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldValueDescription.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Schema; + +// How a value of one field looks, for the schema document agents read (SCH-06): a JSON Schema for one value, an example that +// satisfies it and a sentence about it. The example comes from the field type, so it is always a valid value. +public sealed record FieldValueDescription(JsonObject Schema, JsonNode? Example, string Description) +{ + // Stands in for the id of an entry, entity or asset in examples. + public const string ExampleId = "0199a000-0000-7000-8000-000000000001"; +} diff --git a/src/FluentCMS.Core/Schema/IFieldType.cs b/src/FluentCMS.Core/Schema/IFieldType.cs index 82f71a5b0..b803e124d 100644 --- a/src/FluentCMS.Core/Schema/IFieldType.cs +++ b/src/FluentCMS.Core/Schema/IFieldType.cs @@ -17,6 +17,10 @@ public interface IFieldType // Nested types, such as the items of a list, are checked through the registry. IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegistry fieldTypes); + // How a value of a field with these settings looks, for the schema document agents read (SCH-06). + // Only called with settings that passed ValidateSettings; the example must satisfy the schema. + FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes); + // 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 ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) diff --git a/src/FluentCMS.Core/Schema/ISchemaDocuments.cs b/src/FluentCMS.Core/Schema/ISchemaDocuments.cs new file mode 100644 index 000000000..8e8502e17 --- /dev/null +++ b/src/FluentCMS.Core/Schema/ISchemaDocuments.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Schema; + +// The schema in a form an agent can write entries from without other input (SCH-06). +public interface ISchemaDocuments +{ + // The JSON Schema for entries of one content type, at its current version. + Task> GetTypeAsync(string slug, CancellationToken cancellationToken = default); + + // Every content type of the site under $defs. + Task GetSiteAsync(CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Schema/SchemaDocument.cs b/src/FluentCMS.Core/Schema/SchemaDocument.cs new file mode 100644 index 000000000..dc714bf05 --- /dev/null +++ b/src/FluentCMS.Core/Schema/SchemaDocument.cs @@ -0,0 +1,155 @@ +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Schema; + +// The schema as agents read it (SCH-06): a JSON Schema (draft 2020-12) for the entries of a content type, with a description and +// an example for every field and one example entry. On an entity type every field is a fact with its metadata (SCH-03). +public static class SchemaDocument +{ + public const string Dialect = "https://json-schema.org/draft/2020-12/schema"; + + private const string FactSentence = "It is a fact: give its value with its source, owner and validity."; + private const string ExampleTime = "2026-01-01T00:00:00Z"; + + public static JsonObject ForType(ContentType type, FieldTypeRegistry fieldTypes) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(fieldTypes); + + var document = Describe(type, fieldTypes); + document.Insert(0, "$schema", Dialect); + return document; + } + + // Every type of the site under $defs, keyed by slug. + public static JsonObject ForSite(IEnumerable types, FieldTypeRegistry fieldTypes) + { + ArgumentNullException.ThrowIfNull(types); + ArgumentNullException.ThrowIfNull(fieldTypes); + + var definitions = new JsonObject(); + foreach (var type in types.OrderBy(type => type.Slug, StringComparer.Ordinal)) + { + definitions[type.Slug] = Describe(type, fieldTypes); + } + + return new JsonObject + { + ["$schema"] = Dialect, + ["$id"] = "urn:fluentcms:schema", + ["title"] = "Content types", + ["description"] = "Every content type of the site. An entry of a type follows the schema under $defs named by the type's slug.", + ["$defs"] = definitions, + }; + } + + private static JsonObject Describe(ContentType type, FieldTypeRegistry fieldTypes) + { + var isEntity = type.Definition.IsEntity; + var properties = new JsonObject(); + var required = new JsonArray(); + var example = new JsonObject(); + + foreach (var field in type.Definition.Fields) + { + var value = fieldTypes.DescribeValue(field.Type, field.Settings); + var schema = isEntity ? Fact(value.Schema) : value.Schema; + var fieldExample = isEntity ? FactExample(value.Example) : value.Example; + + schema["description"] = Sentences(field.Description, value.Description, isEntity ? FactSentence : null); + schema["examples"] = new JsonArray(fieldExample?.DeepClone()); + properties[field.Name] = schema; + example[field.Name] = fieldExample; + + if (field.Required) + { + required.Add(field.Name); + } + } + + return new JsonObject + { + ["$id"] = $"urn:fluentcms:content-type:{type.Slug}:{type.Version}", + ["title"] = type.Name, + ["description"] = Sentences( + type.Definition.Description, + isEntity + ? $"An entity of the type '{type.Slug}', a source of facts: every field carries its source, owner and validity." + : $"An entry of the content type '{type.Slug}'."), + ["type"] = "object", + ["properties"] = properties, + ["required"] = required, + ["additionalProperties"] = false, + ["examples"] = new JsonArray(example), + ["x-fluentcms"] = new JsonObject { ["slug"] = type.Slug, ["version"] = type.Version, ["entity"] = isEntity }, + }; + } + + // Everything but the value may be null, so an agent states that a fact has no source yet instead of leaving it out (ENT-02). + private static JsonObject Fact(JsonObject value) + { + return new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + [FactMetadata.Value] = value, + [FactMetadata.Source] = new JsonObject + { + ["type"] = Strings(["object", "null"]), + ["description"] = "Where the value comes from, or null when it has none yet, which flags the fact as unsourced.", + ["properties"] = new JsonObject + { + [FactMetadata.SourceKind] = new JsonObject { ["enum"] = Strings(FactMetadata.SourceKinds) }, + [FactMetadata.SourceReference] = new JsonObject + { + ["type"] = "string", + ["minLength"] = 1, + ["description"] = "The URL, file, person or system the value comes from.", + }, + }, + ["required"] = Strings([FactMetadata.SourceKind, FactMetadata.SourceReference]), + ["additionalProperties"] = false, + }, + [FactMetadata.Owner] = Nullable("uuid", "The id of the user who owns the fact."), + [FactMetadata.ValidFrom] = Nullable("date-time", "When the value starts to hold."), + [FactMetadata.ValidUntil] = Nullable("date-time", "When the value stops holding."), + [FactMetadata.LastVerified] = Nullable("date-time", "When someone last confirmed the value."), + }, + ["required"] = Strings([FactMetadata.Value, .. FactMetadata.Properties]), + ["additionalProperties"] = false, + }; + } + + private static JsonObject FactExample(JsonNode? value) + { + return new JsonObject + { + [FactMetadata.Value] = value, + [FactMetadata.Source] = new JsonObject + { + [FactMetadata.SourceKind] = "url", + [FactMetadata.SourceReference] = "https://example.com/source", + }, + [FactMetadata.Owner] = FieldValueDescription.ExampleId, + [FactMetadata.ValidFrom] = ExampleTime, + [FactMetadata.ValidUntil] = null, + [FactMetadata.LastVerified] = ExampleTime, + }; + } + + private static JsonObject Nullable(string format, string description) + { + return new JsonObject { ["type"] = Strings(["string", "null"]), ["format"] = format, ["description"] = description }; + } + + private static JsonArray Strings(IEnumerable values) + { + return new JsonArray([.. values.Select(value => (JsonNode?)value)]); + } + + private static string Sentences(params string?[] sentences) + { + return string.Join(" ", sentences.Where(sentence => !string.IsNullOrWhiteSpace(sentence))); + } +} diff --git a/src/FluentCMS.Core/Schema/SchemaDocuments.cs b/src/FluentCMS.Core/Schema/SchemaDocuments.cs new file mode 100644 index 000000000..a7c2fae52 --- /dev/null +++ b/src/FluentCMS.Core/Schema/SchemaDocuments.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Schema; + +internal sealed class SchemaDocuments(IContentTypeDirectory directory, FieldTypeRegistry fieldTypes) : ISchemaDocuments +{ + public async Task> GetTypeAsync(string slug, CancellationToken cancellationToken = default) + { + var type = await directory.GetAsync(slug, cancellationToken); + + return type.IsSuccess + ? Result.Success(SchemaDocument.ForType(type.Value, fieldTypes)) + : Result.Failure(type.Errors); + } + + public async Task GetSiteAsync(CancellationToken cancellationToken = default) + { + var types = new List(); + + foreach (var summary in await directory.ListAsync(cancellationToken)) + { + // A type listed a moment ago is still there: types are never deleted. + types.Add((await directory.GetAsync(summary.Slug, cancellationToken)).Value); + } + + return SchemaDocument.ForSite(types, fieldTypes); + } +} diff --git a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs index 0eec13523..fbcdeb23d 100644 --- a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs +++ b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs @@ -21,4 +21,5 @@ public static class SchemaErrorCodes public const string ReferenceTargetNotEntity = "reference_target_not_entity"; public const string ReferenceFieldUnknown = "reference_field_unknown"; public const string FieldReferenced = "field_referenced"; + public const string DescriptionInvalid = "description_invalid"; } diff --git a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj index 8eee8c0ad..c292eb2aa 100644 --- a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj +++ b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs index 0dc8bc368..3101f6946 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs @@ -257,29 +257,14 @@ public async Task Of_two_concurrent_revisions_of_one_version_one_wins_and_the_ot public async Task The_reference_scenario_model_can_be_defined(string provider) { await using var scenario = await SchemaScenario.CreateAsync(provider); - (string Slug, bool IsEntity, FieldDefinition[] Fields)[] model = - [ - ("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, isEntity, fields) in model) - { - 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))}"); - } + + await scenario.CreateReferenceModelAsync(); Assert.Equal( - model.Select(type => type.Slug).Order(StringComparer.Ordinal), + ReferenceModel.Select(type => type.Slug).Order(StringComparer.Ordinal), (await scenario.RunAsync(directory => directory.ListAsync())).Select(summary => summary.Slug)); - foreach (var (slug, isEntity, _) in model) + foreach (var (slug, isEntity, _) in ReferenceModel) { Assert.Equal(isEntity, (await scenario.RunAsync(directory => directory.GetAsync(slug))).Value.Definition.IsEntity); } diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs new file mode 100644 index 000000000..aef21e874 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; +using NJsonSchema; +using static FluentCMS.IntegrationTests.Schema.SchemaScenario; + +namespace FluentCMS.IntegrationTests.Schema; + +// The schema document over stored types (SCH-06): what an agent reads before it writes an entry. +public sealed class SchemaDocumentsTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Every_type_of_the_reference_model_has_a_document_its_example_entry_satisfies(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateReferenceModelAsync(); + + foreach (var (slug, isEntity, _) in ReferenceModel) + { + var document = (await DocumentsAsync(scenario, documents => documents.GetTypeAsync(slug))).Value; + var example = document["examples"]![0]; + + Assert.Equal(isEntity, (bool)document["x-fluentcms"]!["entity"]!); + Assert.Empty(await ErrorsAsync(document, example)); + } + + var product = (await DocumentsAsync(scenario, documents => documents.GetTypeAsync("product"))).Value; + Assert.StartsWith("The list price, before discounts. A number from 0 to 99999.99.", (string?)product["properties"]!["price"]!["description"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_site_document_lists_every_stored_type(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateReferenceModelAsync(); + + var site = await DocumentsAsync(scenario, documents => documents.GetSiteAsync()); + + Assert.Equal( + ReferenceModel.Select(type => type.Slug).Order(StringComparer.Ordinal), + site["$defs"]!.AsObject().Select(definition => definition.Key)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_shows_in_the_document_and_an_unknown_slug_is_not_found(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateAsync("page", Field("title", "text")); + await scenario.ReviseAsync("page", 1, new ContentTypeDefinition([Field("title", "text", description: "Shown in the browser tab.")])); + + var page = (await DocumentsAsync(scenario, documents => documents.GetTypeAsync("page"))).Value; + var missing = await DocumentsAsync(scenario, documents => documents.GetTypeAsync("nothing")); + + Assert.Equal("urn:fluentcms:content-type:page:2", (string?)page["$id"]); + Assert.Equal("Shown in the browser tab. Plain text.", (string?)page["properties"]!["title"]!["description"]); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Assert.Single(missing.Errors).Code); + } + + private static async Task DocumentsAsync(SchemaScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + // An independent validator, so the test does not grade the document with the logic that wrote it. + private static async Task> ErrorsAsync(JsonNode document, JsonNode? instance) + { + var schema = await JsonSchema.FromJsonAsync(document.ToJsonString()); + return schema.Validate(instance?.ToJsonString() ?? "null").Select(error => error.ToString()).ToList(); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs index 56ad4ce54..ecab131cf 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs @@ -85,9 +85,40 @@ public async ValueTask DisposeAsync() await _database.DisposeAsync(); } - public static FieldDefinition Field(string name, string type, string settings = "{}", bool required = false) - { - return new FieldDefinition(name, type, required, JsonSerializer.Deserialize(settings)); + // The model behind the reference scenario (02-personas-and-scenarios): four entity types and the four page types that use them. + public static readonly (string Slug, bool IsEntity, FieldDefinition[] Fields)[] ReferenceModel = + [ + ("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, + [ + Field("name", "text", """{"maxLength": 200}""", required: true), + Field("price", "number", """{"min": 0, "max": 99999.99}""", required: true, description: "The list price, before discounts."), + Field("tags", "list", """{"itemType": "text"}"""), + 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")]), + ]; + + public async Task CreateReferenceModelAsync() + { + foreach (var (slug, isEntity, fields) in ReferenceModel) + { + var created = isEntity ? await CreateEntityAsync(slug, fields) : await CreateAsync(slug, fields); + Assert.True(created.IsSuccess, $"{slug}: {string.Join("; ", created.Errors.Select(error => error.Message))}"); + } + } + + public static FieldDefinition Field(string name, string type, string settings = "{}", bool required = false, string? description = null) + { + return new FieldDefinition(name, type, required, JsonSerializer.Deserialize(settings), description); } public static FieldDefinition Reference(string name, string target, string? field = null) diff --git a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj index e61662ba9..58be6ee11 100644 --- a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj +++ b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs index 01bd610e0..cf1d13012 100644 --- a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs +++ b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs @@ -124,13 +124,13 @@ 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 'entity' and 'fields'.", + "$.version is not part of a definition; it has 'entity', 'description' and 'fields'.", "$.fields[0].type must be a string.", "$.fields[1].name is missing.", "$.fields[2] must be an object.", "$.fields[3].required must be true or false.", "$.fields[3].settings must be an object.", - "$.fields[3].label is not part of a field; a field has name, type, required and settings.", + "$.fields[3].label is not part of a field; a field has name, type, required, description and settings.", "$.fields[4].name must be a string.", "$.fields[4].type is missing.", ], @@ -221,7 +221,7 @@ 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.", + "$.fields[0].fact is not part of a field; a field has name, type, required, description and settings.", Assert.Single(result.Errors).Message); } @@ -244,6 +244,50 @@ public void References_name_the_field_a_reference_targets() definition.References(FieldTypes)); } + [Fact] + public void Descriptions_are_written_and_read_back() + { + var definition = new ContentTypeDefinition( + [new FieldDefinition("price", "number", description: "The list price, before discounts.")], + description: "A sellable item."); + + var json = definition.ToJson(); + var readBack = ContentTypeDefinition.Parse(json).Value; + + Assert.Equal( + """{"entity":false,"description":"A sellable item.","fields":[{"name":"price","type":"number","required":false,"description":"The list price, before discounts.","settings":{}}]}""", + json); + Assert.Equal("A sellable item.", readBack.Description); + Assert.Equal("The list price, before discounts.", Assert.Single(readBack.Fields).Description); + Assert.Null(ContentTypeDefinition.Parse("""{"description": null, "fields": [{"name": "a", "type": "text", "description": null}]}""").Value.Description); + } + + [Fact] + public void A_description_is_a_string() + { + var result = ContentTypeDefinition.Parse("""{"description": 5, "fields": [{"name": "a", "type": "text", "description": true}]}"""); + + Assert.Equal(["$.description must be a string.", "$.fields[0].description must be a string."], result.Errors.Select(error => error.Message)); + } + + [Fact] + public void A_blank_or_too_long_description_is_rejected() + { + var definition = new ContentTypeDefinition( + [new FieldDefinition("price", "number", description: new string('a', ContentTypeDefinition.MaxDescriptionLength + 1))], + description: " "); + + var result = definition.Validate(FieldTypes); + + Assert.Equal( + [ + "The type's description must not be blank and must be at most 1000 characters; leave it out instead.", + "Field 'price': the description must not be blank and must be at most 1000 characters; leave it out instead.", + ], + result.Errors.Select(error => error.Message)); + Assert.All(result.Errors, error => Assert.Equal(SchemaErrorCodes.DescriptionInvalid, error.Code)); + } + [Fact] public void Field_settings_must_be_an_object() { diff --git a/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs b/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs index 9abeb6e94..abac7aa25 100644 --- a/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs +++ b/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs @@ -16,5 +16,6 @@ public void Every_fact_carries_a_source_an_owner_and_its_validity() public void A_source_is_a_url_a_file_a_person_or_a_system() { Assert.Equal(["url", "file", "person", "system"], FactMetadata.SourceKinds); + Assert.Equal(("kind", "reference"), (FactMetadata.SourceKind, FactMetadata.SourceReference)); } } diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs index c681a1521..37c9e48a4 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs @@ -101,6 +101,22 @@ public void Declares_reference_targets_as_a_slug_and_an_optional_field_name() } } + // The schema document hands this example to agents as a valid value (SCH-06). + [Fact] + public async Task Describes_its_values_with_a_schema_its_own_example_satisfies() + { + foreach (var json in AcceptedSettings) + { + var value = Registry.DescribeValue(FieldTypeName, Parse(json)); + var errors = await JsonSchemaValidation.ErrorsAsync(value.Schema, value.Example); + + Assert.False(string.IsNullOrWhiteSpace(value.Description), $"{json} has no description."); + Assert.True( + errors.Count == 0, + $"{json}: {value.Example?.ToJsonString() ?? "null"} does not satisfy {value.Schema.ToJsonString()}: {string.Join("; ", errors)}"); + } + } + // A definition comes from an agent, so a setting can hold anything; the answer is an error, never an exception. [Fact] public void Never_throws_whatever_a_setting_holds() diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs index 0cc2794da..b06461465 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core; using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; @@ -107,5 +108,10 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return []; } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription(new JsonObject(), null, "Anything."); + } } } diff --git a/tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs b/tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs new file mode 100644 index 000000000..d7bbaefe2 --- /dev/null +++ b/tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; +using NJsonSchema; + +namespace FluentCMS.Tests.Schema; + +// Checks documents with an independent validator, so the tests do not grade the schema with the logic that wrote it. +internal static class JsonSchemaValidation +{ + public static async Task> ErrorsAsync(JsonNode schema, JsonNode? instance) + { + var parsed = await JsonSchema.FromJsonAsync(schema.ToJsonString()); + return parsed.Validate(instance?.ToJsonString() ?? "null").Select(error => error.ToString()).ToList(); + } +} diff --git a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs index 5a7182aab..724467509 100644 --- a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core; using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; @@ -92,6 +93,16 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var stars = new FieldSettingsReader(Name, settings).PositiveInteger("stars") ?? 5; + + return new FieldValueDescription( + new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["maximum"] = stars }, + stars, + $"A rating from 1 to {stars} stars."); + } } public sealed class LinkFieldTypeTests : FieldTypeConformanceTests @@ -143,4 +154,12 @@ public IReadOnlyList ReferenceTargets(JsonElement settings, Fie { return [new ReferenceTarget(settings.GetProperty("to").GetString()!)]; } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + FieldValueDescription.ExampleId, + $"The id of an entry of '{settings.GetProperty("to").GetString()}' to see as well."); + } } diff --git a/tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs b/tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs new file mode 100644 index 000000000..5a0eb9117 --- /dev/null +++ b/tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core; +using FluentCMS.Core.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Schema; + +public sealed class SchemaDocumentTests : IDisposable +{ + private static readonly Guid Site = Guid.NewGuid(); + + private readonly ServiceProvider _provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + + private FieldTypeRegistry FieldTypes => _provider.GetRequiredService(); + + [Fact] + public async Task A_content_type_document_describes_every_field_and_its_example_entry_is_valid() + { + var document = SchemaDocument.ForType(ProductPage(), FieldTypes); + + Assert.Equal(SchemaDocument.Dialect, (string?)document["$schema"]); + Assert.Equal("urn:fluentcms:content-type:product-page:1", (string?)document["$id"]); + Assert.Equal("Product page", (string?)document["title"]); + Assert.Equal("The page that sells one product. An entry of the content type 'product-page'.", (string?)document["description"]); + Assert.False((bool)document["additionalProperties"]!); + Assert.Equal(["product", "headline"], document["required"]!.AsArray().Select(name => (string?)name)); + Assert.Equal( + ["product", "price", "headline", "body", "tags", "publishedOn", "featured", "extra"], + document["properties"]!.AsObject().Select(property => property.Key)); + Assert.Equal( + "The price shown on the page. The id of a 'product' entity; the field shows that entity's 'price' instead of a copy of it.", + (string?)document["properties"]!["price"]!["description"]); + Assert.Equal("Formatted text written as Markdown.", (string?)document["properties"]!["body"]!["description"]); + Assert.Null(document["properties"]!["price"]!["properties"]); + + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(document, ExampleEntry(document))); + } + + [Fact] + public async Task An_entity_type_document_makes_every_field_a_fact() + { + var document = SchemaDocument.ForType(Product(), FieldTypes); + var price = document["properties"]!["price"]!; + + Assert.Equal( + ["value", "source", "owner", "validFrom", "validUntil", "lastVerified"], + price["required"]!.AsArray().Select(name => (string?)name)); + Assert.Equal("number", (string?)price["properties"]!["value"]!["type"]); + Assert.Equal(["url", "file", "person", "system"], price["properties"]!["source"]!["properties"]!["kind"]!["enum"]!.AsArray().Select(kind => (string?)kind)); + Assert.EndsWith("It is a fact: give its value with its source, owner and validity.", (string?)price["description"]); + Assert.True((bool)document["x-fluentcms"]!["entity"]!); + + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(document, ExampleEntry(document))); + } + + // An unsourced fact is a valid entry; it is flagged, not refused (ENT-02). + [Fact] + public async Task A_fact_may_state_that_it_has_no_source_yet() + { + var document = SchemaDocument.ForType(Product(), FieldTypes); + var entry = ExampleEntry(document); + entry["price"]!["source"] = null; + entry["price"]!["owner"] = null; + + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(document, entry)); + } + + [Theory] + [InlineData("product-page", "missing required field")] + [InlineData("product-page", "unknown field")] + [InlineData("product-page", "date that is not a date")] + [InlineData("product-page", "id that is not a uuid")] + [InlineData("product", "value outside the enum")] + [InlineData("product", "fact without its source")] + [InlineData("product", "fact with a bare value")] + [InlineData("product", "source of an unknown kind")] + public async Task An_entry_that_breaks_the_document_fails_validation(string type, string breakage) + { + var document = SchemaDocument.ForType(type == "product" ? Product() : ProductPage(), FieldTypes); + var entry = ExampleEntry(document); + + switch (breakage) + { + case "missing required field": + entry.Remove("headline"); + break; + case "unknown field": + entry["subtitle"] = "Not in the schema"; + break; + case "date that is not a date": + entry["publishedOn"] = "next Tuesday"; + break; + case "id that is not a uuid": + entry["product"] = "laptop-15"; + break; + case "value outside the enum": + entry["currency"]!["value"] = "GBP"; + break; + case "fact without its source": + entry["price"]!.AsObject().Remove("source"); + break; + case "fact with a bare value": + entry["price"] = 1299; + break; + case "source of an unknown kind": + entry["price"]!["source"]!["kind"] = "rumour"; + break; + } + + Assert.NotEmpty(await JsonSchemaValidation.ErrorsAsync(document, entry)); + } + + [Fact] + public async Task The_site_document_holds_every_type_under_its_slug() + { + var document = SchemaDocument.ForSite([ProductPage(), Product()], FieldTypes); + var definitions = document["$defs"]!.AsObject(); + + Assert.Equal(["product", "product-page"], definitions.Select(definition => definition.Key)); + + foreach (var (slug, definition) in definitions) + { + var schema = definition!.DeepClone().AsObject(); + schema.Insert(0, "$schema", SchemaDocument.Dialect); + + Assert.True( + (await JsonSchemaValidation.ErrorsAsync(schema, ExampleEntry(schema))).Count == 0, + $"The example entry of '{slug}' does not satisfy its schema."); + } + } + + [Fact] + public void A_field_type_that_is_no_longer_installed_is_described_as_any_value() + { + var restored = ContentType.Restore( + Guid.NewGuid(), + Site, + "Review", + "review", + 2, + new ContentTypeDefinition([new FieldDefinition("rating", "sample.removed")])); + + var rating = SchemaDocument.ForType(restored, FieldTypes)["properties"]!["rating"]!.AsObject(); + + Assert.Equal(["description", "examples"], rating.Select(property => property.Key)); + Assert.Equal("A value of the field type 'sample.removed', which is not installed.", (string?)rating["description"]); + } + + public void Dispose() + { + _provider.Dispose(); + } + + private static JsonObject ExampleEntry(JsonObject document) + { + return document["examples"]![0]!.DeepClone().AsObject(); + } + + private ContentType Product() + { + return ContentType.Create( + Site, + "Product", + "product", + new ContentTypeDefinition( + [ + new FieldDefinition("name", "text", required: true, settings: Settings("""{"maxLength": 120}""")), + new FieldDefinition("price", "number", required: true, settings: Settings("""{"min": 0, "max": 99999.99}"""), description: "The list price."), + new FieldDefinition("currency", "enum", settings: Settings("""{"values": ["EUR", "USD"]}""")), + new FieldDefinition("hero", "media"), + ], + isEntity: true, + description: "A sellable item."), + FieldTypes).Value; + } + + private ContentType ProductPage() + { + return ContentType.Create( + Site, + "Product page", + "product-page", + new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", required: true, settings: Settings("""{"target": "product"}""")), + new FieldDefinition("price", "reference", settings: Settings("""{"target": "product", "field": "price"}"""), description: "The price shown on the page."), + new FieldDefinition("headline", "text", required: true, settings: Settings("""{"maxLength": 5}""")), + new FieldDefinition("body", "richText"), + new FieldDefinition("tags", "list", settings: Settings("""{"itemType": "enum", "itemSettings": {"values": ["new", "sale"]}}""")), + new FieldDefinition("publishedOn", "dateTime", settings: Settings("""{"dateOnly": true}""")), + new FieldDefinition("featured", "boolean"), + new FieldDefinition("extra", "json"), + ], + description: "The page that sells one product."), + FieldTypes).Value; + } + + private static JsonElement Settings(string json) + { + return JsonSerializer.Deserialize(json); + } +}