diff --git a/src/FluentCMS.Core/Schema/FieldTypes/BlocksFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/BlocksFieldType.cs new file mode 100644 index 000000000..aae8c15d7 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/BlocksFieldType.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// The value coerces to a JsonArray of objects that each name their block in "type", or stays a BlockList. Whether +// the block is one the field accepts, and the fields inside it, are checked by the content service. +internal sealed class BlocksFieldType() + : CoreFieldType( + FieldTypeNames.Blocks, + [typeof(BlockList)], + [FilterOperator.HasBlock, .. FilterSets.NullChecks], + sortable: false, + indexable: true, + new FieldSettingDescriptor { Key = FieldSettings.Blocks, Kind = FieldSettingKind.StringList, Required = true }) +{ + private const string Discriminator = "type"; + + public override FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Json; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + if (value is BlockList) + { + return Ok(value, out result, out message); + } + + if (!RawValue.TryReadJson(value, out var node, out var problem)) + { + return Fail(problem!, out result, out message); + } + + switch (node) + { + case null: + return Ok(null, out result, out message); + case JsonArray blocks: + for (var index = 0; index < blocks.Count; index++) + { + if (blocks[index] is not JsonObject block || block[Discriminator] is not JsonValue name || !name.TryGetValue(out var type) || type.Length == 0) + { + return Fail($"block {index} is not an object with a \"{Discriminator}\" naming its block", out result, out message); + } + } + + return Ok(node, out result, out message); + default: + return Fail("is not a list of blocks", out result, out message); + } + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var names = SettingValues.List(field, FieldSettings.Blocks); + JsonObject mapping = []; + foreach (var name in names) + { + mapping[name] = OpenApiNames.BlockRef(name); + } + + return new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["oneOf"] = new JsonArray([.. names.Select(name => (JsonNode)new JsonObject { ["$ref"] = OpenApiNames.BlockRef(name) })]), + ["discriminator"] = new JsonObject { ["propertyName"] = Discriminator, ["mapping"] = mapping }, + }, + }; + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/BooleanFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/BooleanFieldType.cs new file mode 100644 index 000000000..ae283a679 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/BooleanFieldType.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class BooleanFieldType() + : CoreFieldType(FieldTypeNames.Boolean, [typeof(bool), typeof(bool?)], FilterSets.Equality, sortable: true, indexable: true) +{ + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + switch (raw) + { + case null: + return Ok(null, out result, out message); + case bool flag: + return Ok(flag, out result, out message); + case string text when text.Equals("true", StringComparison.OrdinalIgnoreCase): + return Ok(true, out result, out message); + case string text when text.Equals("false", StringComparison.OrdinalIgnoreCase): + return Ok(false, out result, out message); + case string: + return Fail("is not true or false", out result, out message); + default: + return Expected("a boolean", raw, out result, out message); + } + } + + public override JsonObject OpenApiSchema(SchemaField field) => new() { ["type"] = "boolean" }; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/ComponentFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/ComponentFieldType.cs new file mode 100644 index 000000000..74e1b2986 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/ComponentFieldType.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// A single component is flattened into columns of its own, a repeatable one is JSON. The value coerces to a +// JsonObject, or a JsonArray of them when repeatable. The fields inside it are checked with the component's own +// schema by the content service, which has the schema model; a descriptor only sees one field. +internal sealed class ComponentFieldType() + : CoreFieldType( + FieldTypeNames.Component, + [typeof(object)], + [], + sortable: false, + indexable: false, + new FieldSettingDescriptor { Key = FieldSettings.Component, Kind = FieldSettingKind.String, Required = true }) +{ + public override FieldStorageKind StorageKind(SchemaField field) => field.Multiple ? FieldStorageKind.Json : FieldStorageKind.Column; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + if (!RawValue.TryReadJson(value, out var node, out var problem)) + { + return Fail(problem!, out result, out message); + } + + switch (node) + { + case null: + return Ok(null, out result, out message); + case JsonObject when !field.Multiple: + return Ok(node, out result, out message); + case JsonArray items when field.Multiple && items.All(item => item is JsonObject): + return Ok(node, out result, out message); + default: + return Fail(field.Multiple ? "is not a list of objects" : "is not an object", out result, out message); + } + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var reference = new JsonObject { ["$ref"] = OpenApiNames.ComponentRef(SettingValues.Text(field, FieldSettings.Component) ?? "") }; + return field.Multiple ? new JsonObject { ["type"] = "array", ["items"] = reference } : reference; + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/CoreFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/CoreFieldType.cs new file mode 100644 index 000000000..573fa5416 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/CoreFieldType.cs @@ -0,0 +1,45 @@ +using System.Collections.Frozen; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal abstract class CoreFieldType(string name, Type[] clrTypes, FilterOperator[] filterOperators, bool sortable, bool indexable, params FieldSettingDescriptor[] settings) : IFieldType +{ + public string Name { get; } = name; + + public IReadOnlyList ClrTypes { get; } = Array.AsReadOnly(clrTypes); + + public IReadOnlySet FilterOperators { get; } = filterOperators.ToFrozenSet(); + + public IReadOnlyList Settings { get; } = Array.AsReadOnly(settings); + + // A field stored as JSON cannot be sorted, whatever its type says. + public bool Sortable(SchemaField field) => sortable && StorageKind(field) != FieldStorageKind.Json; + + public virtual bool Indexable(SchemaField field) => indexable; + + public virtual FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Column; + + public abstract bool TryCoerce(object? value, SchemaField field, out object? result, out string? message); + + public abstract JsonObject OpenApiSchema(SchemaField field); + + protected static bool Ok(object? value, out object? result, out string? message) + { + result = value; + message = null; + return true; + } + + protected static bool Fail(string problem, out object? result, out string? message) + { + result = null; + message = problem; + return false; + } + + protected static bool Expected(string what, object? raw, out object? result, out string? message) => + Fail($"expected {what} but got {RawValue.Describe(raw)}", out result, out message); +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/DateFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/DateFieldType.cs new file mode 100644 index 000000000..c413d7c34 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/DateFieldType.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class DateFieldType() + : CoreFieldType(FieldTypeNames.Date, [typeof(DateOnly), typeof(DateOnly?)], FilterSets.Ordered, sortable: true, indexable: true) +{ + private const string Format = "yyyy-MM-dd"; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + switch (raw) + { + case null: + return Ok(null, out result, out message); + case DateOnly day: + return Ok(day, out result, out message); + case DateTime moment when moment.TimeOfDay == TimeSpan.Zero: + return Ok(DateOnly.FromDateTime(moment), out result, out message); + case DateTime: + return Fail("has a time of day, but the field holds a date only", out result, out message); + case string text: + return DateOnly.TryParseExact(text, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + ? Ok(parsed, out result, out message) + : Fail($"is not a date in the form {Format}", out result, out message); + default: + return Expected("a date", raw, out result, out message); + } + } + + public override JsonObject OpenApiSchema(SchemaField field) => new() { ["type"] = "string", ["format"] = "date" }; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/DateTimeFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/DateTimeFieldType.cs new file mode 100644 index 000000000..425cae3af --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/DateTimeFieldType.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// The value is kept as an instant in UTC, as the storage model keeps it; text without an offset is read as UTC. +internal sealed class DateTimeFieldType() + : CoreFieldType(FieldTypeNames.DateTime, [typeof(DateTimeOffset), typeof(DateTimeOffset?)], FilterSets.Ordered, sortable: true, indexable: true) +{ + private static readonly string[] Formats = + [ + "yyyy-MM-dd'T'HH:mm:ssK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK", + "yyyy-MM-dd'T'HH:mmK", + ]; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + switch (raw) + { + case null: + return Ok(null, out result, out message); + case DateTimeOffset moment: + return Ok(moment.ToUniversalTime(), out result, out message); + case DateTime moment: + var utc = moment.Kind == DateTimeKind.Local ? moment.ToUniversalTime() : DateTime.SpecifyKind(moment, DateTimeKind.Utc); + return Ok(new DateTimeOffset(utc), out result, out message); + case string text: + return DateTimeOffset.TryParseExact(text, Formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? Ok(parsed, out result, out message) + : Fail("is not an ISO 8601 date and time such as 2026-11-01T09:12:00Z", out result, out message); + default: + return Expected("a date and time", raw, out result, out message); + } + } + + public override JsonObject OpenApiSchema(SchemaField field) => new() { ["type"] = "string", ["format"] = "date-time" }; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/DecimalFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/DecimalFieldType.cs new file mode 100644 index 000000000..e8b59f086 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/DecimalFieldType.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class DecimalFieldType() + : CoreFieldType( + FieldTypeNames.Decimal, + [typeof(decimal), typeof(decimal?)], + FilterSets.Ordered, + sortable: true, + indexable: true, + new FieldSettingDescriptor { Key = FieldSettings.Precision, Kind = FieldSettingKind.Integer }, + new FieldSettingDescriptor { Key = FieldSettings.Scale, Kind = FieldSettingKind.Integer }) +{ + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + decimal number; + switch (raw) + { + case null: + return Ok(null, out result, out message); + case string text: + if (!decimal.TryParse(text, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out number)) + { + return Fail("is not a number", out result, out message); + } + + break; + case not null when raw is not bool && RawValue.IsNumber(raw): + if (!RawValue.TryToDecimal(raw, out number)) + { + return Fail("is not a number that fits in a decimal", out result, out message); + } + + break; + default: + return Expected("a number", raw, out result, out message); + } + + number = SchemaValue.NormalizeDecimal(number); + return Fits(number, field) is { } problem ? Fail(problem, out result, out message) : Ok(number, out result, out message); + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var schema = new JsonObject { ["type"] = "number" }; + if (field.Minimum is { } min) + { + schema["minimum"] = min; + } + + if (field.Maximum is { } max) + { + schema["maximum"] = max; + } + + return schema; + } + + // Precision counts all significant digits and scale those after the decimal point. A precision without a scale + // limits only the total number of digits. + private static string? Fits(decimal number, SchemaField field) + { + var scale = SettingValues.Int(field, FieldSettings.Scale); + var precision = SettingValues.Int(field, FieldSettings.Precision); + + var fraction = (decimal.GetBits(number)[3] >> 16) & 0xFF; + var whole = decimal.Truncate(Math.Abs(number)); + var wholeDigits = whole == 0 ? 0 : whole.ToString(CultureInfo.InvariantCulture).Length; + + if (scale is { } places && fraction > places) + { + return $"has {fraction} decimal places, more than the {places} the field allows"; + } + + if (precision is { } digits) + { + var allowedWholeDigits = digits - (scale ?? fraction); + if (wholeDigits > allowedWholeDigits) + { + return $"has more than the {digits} significant digits the field allows"; + } + } + + return null; + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/EmailFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/EmailFieldType.cs new file mode 100644 index 000000000..548facb01 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/EmailFieldType.cs @@ -0,0 +1,23 @@ +using System.Net.Mail; +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class EmailFieldType() + : StringFieldType(FieldTypeNames.Email, [typeof(string)], FilterSets.Text, sortable: true, indexable: true) +{ + private const int MaxAddressLength = 254; + + protected override string? Check(string text) + { + var isAddress = text.Length <= MaxAddressLength + && !text.Any(char.IsWhiteSpace) + && MailAddress.TryCreate(text, out var address) + && address.DisplayName.Length == 0 + && string.Equals(address.Address, text, StringComparison.OrdinalIgnoreCase); + + return isAddress ? null : "is not a valid email address"; + } + + protected override void Describe(JsonObject schema) => schema["format"] = "email"; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/FieldTypeNames.cs b/src/FluentCMS.Core/Schema/FieldTypes/FieldTypeNames.cs new file mode 100644 index 000000000..0cfc19c5c --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/FieldTypeNames.cs @@ -0,0 +1,21 @@ +namespace FluentCMS.Core.Schema.FieldTypes; + +internal static class FieldTypeNames +{ + public const string Text = "text"; + public const string LongText = "longText"; + public const string RichText = "richText"; + public const string Integer = "integer"; + public const string Decimal = "decimal"; + public const string Boolean = "boolean"; + public const string Date = "date"; + public const string DateTime = "dateTime"; + public const string Email = "email"; + public const string Url = "url"; + public const string Slug = "slug"; + public const string Select = "select"; + public const string Json = "json"; + public const string Relation = "relation"; + public const string Component = "component"; + public const string Blocks = "blocks"; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/FieldTypeRegistry.cs b/src/FluentCMS.Core/Schema/FieldTypes/FieldTypeRegistry.cs new file mode 100644 index 000000000..c8f7313d5 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/FieldTypeRegistry.cs @@ -0,0 +1,55 @@ +using System.Collections.Frozen; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// A name can be registered once: a plugin cannot replace a core type, and two plugins cannot share a name. +public sealed class FieldTypeRegistry : IFieldTypeRegistry +{ + private readonly FrozenDictionary _byName; + + public FieldTypeRegistry(IEnumerable additional) + { + ArgumentNullException.ThrowIfNull(additional); + + Dictionary byName = new(StringComparer.Ordinal); + foreach (var fieldType in CoreFieldTypes().Concat(additional)) + { + ArgumentNullException.ThrowIfNull(fieldType); + if (!byName.TryAdd(fieldType.Name, fieldType)) + { + throw new ArgumentException($"A field type named '{fieldType.Name}' is registered more than once.", nameof(additional)); + } + } + + _byName = byName.ToFrozenDictionary(StringComparer.Ordinal); + All = byName.Values.OrderBy(fieldType => fieldType.Name, StringComparer.Ordinal).ToList().AsReadOnly(); + } + + // The core types only. + public static FieldTypeRegistry Core { get; } = new([]); + + public IReadOnlyList All { get; } + + public IFieldType? Find(string name) => name is null ? null : _byName.GetValueOrDefault(name); + + private static IEnumerable CoreFieldTypes() => + [ + new TextFieldType(), + new LongTextFieldType(), + new RichTextFieldType(), + new IntegerFieldType(), + new DecimalFieldType(), + new BooleanFieldType(), + new DateFieldType(), + new DateTimeFieldType(), + new EmailFieldType(), + new UrlFieldType(), + new SlugFieldType(), + new SelectFieldType(), + new JsonFieldType(), + new RelationFieldType(), + new ComponentFieldType(), + new BlocksFieldType(), + ]; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/FilterSets.cs b/src/FluentCMS.Core/Schema/FieldTypes/FilterSets.cs new file mode 100644 index 000000000..24abe2cec --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/FilterSets.cs @@ -0,0 +1,23 @@ +using FluentCMS.Content; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal static class FilterSets +{ + public static readonly FilterOperator[] Text = + [ + FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.In, FilterOperator.NotIn, + FilterOperator.Contains, FilterOperator.StartsWith, FilterOperator.IsNull, FilterOperator.IsNotNull, + ]; + + public static readonly FilterOperator[] Ordered = + [ + FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.In, FilterOperator.NotIn, + FilterOperator.LessThan, FilterOperator.LessThanOrEqual, FilterOperator.GreaterThan, FilterOperator.GreaterThanOrEqual, + FilterOperator.Between, FilterOperator.IsNull, FilterOperator.IsNotNull, + ]; + + public static readonly FilterOperator[] Equality = [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.IsNull, FilterOperator.IsNotNull]; + + public static readonly FilterOperator[] NullChecks = [FilterOperator.IsNull, FilterOperator.IsNotNull]; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/IntegerFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/IntegerFieldType.cs new file mode 100644 index 000000000..60ddd63a7 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/IntegerFieldType.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class IntegerFieldType() + : CoreFieldType(FieldTypeNames.Integer, [typeof(int), typeof(int?), typeof(long), typeof(long?)], FilterSets.Ordered, sortable: true, indexable: true) +{ + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + switch (raw) + { + case null: + return Ok(null, out result, out message); + case string text: + return long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var parsed) + ? Ok(parsed, out result, out message) + : Fail("is not a whole number", out result, out message); + case not null when raw is not bool && RawValue.IsNumber(raw): + return RawValue.TryToDecimal(raw, out var number) && number == decimal.Truncate(number) && number >= long.MinValue && number <= long.MaxValue + ? Ok((long)number, out result, out message) + : Fail("is not a whole number that fits in 64 bits", out result, out message); + default: + return Expected("a whole number", raw, out result, out message); + } + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var schema = new JsonObject { ["type"] = "integer", ["format"] = "int64" }; + if (field.Minimum is { } min) + { + schema["minimum"] = min; + } + + if (field.Maximum is { } max) + { + schema["maximum"] = max; + } + + return schema; + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/JsonFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/JsonFieldType.cs new file mode 100644 index 000000000..54e075f14 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/JsonFieldType.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// The value is a JsonNode. Text is read as JSON text, so the string "x" is not valid but "\"x\"" is; a JsonElement is taken as it is. +internal sealed class JsonFieldType() + : CoreFieldType( + FieldTypeNames.Json, + [typeof(JsonElement), typeof(JsonNode)], + [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.Contains, .. FilterSets.NullChecks], + sortable: false, + indexable: true) +{ + public override FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Json; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + if (value is bool or string or JsonElement or JsonNode or null || !RawValue.IsNumber(value)) + { + return RawValue.TryReadJson(value switch { bool flag => JsonValue.Create(flag), _ => value }, out var node, out var problem) + ? Ok(node, out result, out message) + : Fail(problem!, out result, out message); + } + + return RawValue.TryToDecimal(value, out var number) + ? Ok(JsonValue.Create(number), out result, out message) + : Fail("is not a number that JSON can hold", out result, out message); + } + + public override JsonObject OpenApiSchema(SchemaField field) => []; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/LongTextFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/LongTextFieldType.cs new file mode 100644 index 000000000..0ad06942e --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/LongTextFieldType.cs @@ -0,0 +1,6 @@ +using FluentCMS.Content; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class LongTextFieldType() + : StringFieldType(FieldTypeNames.LongText, [typeof(string)], [FilterOperator.Contains, FilterOperator.StartsWith, .. FilterSets.NullChecks], sortable: false, indexable: false); diff --git a/src/FluentCMS.Core/Schema/FieldTypes/OpenApiNames.cs b/src/FluentCMS.Core/Schema/FieldTypes/OpenApiNames.cs new file mode 100644 index 000000000..3cb7dcf09 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/OpenApiNames.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Core.Schema.FieldTypes; + +// The names under #/components/schemas that component and block schemas get, shared by the field types that +// point to them and by the OpenAPI document transformers that emit them. +internal static class OpenApiNames +{ + public static string ComponentRef(string component) => $"#/components/schemas/{ComponentSchema(component)}"; + + public static string BlockRef(string block) => $"#/components/schemas/{BlockSchema(block)}"; + + public static string ComponentSchema(string component) => $"component-{component}"; + + public static string BlockSchema(string block) => $"block-{block}"; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/RawValue.cs b/src/FluentCMS.Core/Schema/FieldTypes/RawValue.cs new file mode 100644 index 000000000..d5140512d --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/RawValue.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// Reduces what a client or a mapper hands over to plain CLR values, so each field type has few shapes to handle. +internal static class RawValue +{ + // A JsonElement or a JsonNode becomes null, a string, a boolean, a long or a decimal; objects and arrays become JsonElements. + public static object? Unwrap(object? value) + { + if (value is JsonNode node) + { + value = JsonSerializer.SerializeToElement(node); + } + + if (value is not JsonElement element) + { + return value; + } + + switch (element.ValueKind) + { + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return null; + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Number when element.TryGetInt64(out var whole): + return whole; + case JsonValueKind.Number when element.TryGetDecimal(out var number): + return number; + default: + return element; + } + } + + public static string Describe(object? value) => value switch + { + null => "null", + string => "a string", + bool => "a boolean", + JsonElement { ValueKind: JsonValueKind.Object } => "an object", + JsonElement { ValueKind: JsonValueKind.Array } => "an array", + JsonElement { ValueKind: JsonValueKind.Number } => "a number", + _ when IsNumber(value) => "a number", + _ => $"a value of type {value.GetType().Name}", + }; + + public static bool IsNumber(object value) => + value is sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal; + + public static bool TryToDecimal(object value, out decimal number) + { + number = 0; + try + { + switch (value) + { + case decimal d: + number = d; + return true; + case float or double: + var floating = Convert.ToDouble(value, CultureInfo.InvariantCulture); + if (!double.IsFinite(floating)) + { + return false; + } + + number = (decimal)floating; + return true; + case sbyte or byte or short or ushort or int or uint or long or ulong: + number = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + return true; + } + } + catch (OverflowException) + { + } + + return false; + } + + // Reads JSON from a JsonElement, a JsonNode or JSON text. A JSON null and a null value both give a null node. + public static bool TryReadJson(object? value, out JsonNode? node, out string? message) + { + node = null; + message = null; + + switch (value) + { + case null: + return true; + case JsonElement { ValueKind: JsonValueKind.Null or JsonValueKind.Undefined }: + return true; + case JsonElement element: + node = JsonNode.Parse(element.GetRawText()); + return true; + case JsonNode existing: + node = existing.DeepClone(); + return true; + case string text: + try + { + node = JsonNode.Parse(text); + return true; + } + catch (JsonException exception) + { + message = $"is not valid JSON: {exception.Message}"; + return false; + } + + default: + message = $"expected JSON but got {Describe(value)}"; + return false; + } + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/RelationFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/RelationFieldType.cs new file mode 100644 index 000000000..8fed630e3 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/RelationFieldType.cs @@ -0,0 +1,91 @@ +using System.Collections; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// A single relation coerces to a Guid, or to null for an empty reference; a to-many relation to a Guid[] in order. +internal sealed class RelationFieldType() + : CoreFieldType( + FieldTypeNames.Relation, + [typeof(Ref<>), typeof(RefList<>)], + [FilterOperator.Equal, FilterOperator.In, .. FilterSets.NullChecks], + sortable: false, + indexable: false) +{ + public override FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Reference; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + if (raw is null) + { + return Ok(null, out result, out message); + } + + if (!field.Multiple) + { + if (!TryReadId(raw, out var id)) + { + return Fail("is not an entry id: expected a GUID", out result, out message); + } + + return Ok(id == Guid.Empty ? null : id, out result, out message); + } + + IEnumerable items; + switch (raw) + { + case JsonElement { ValueKind: JsonValueKind.Array } array: + items = array.EnumerateArray().Select(item => RawValue.Unwrap(item)); + break; + case string or JsonElement: + return Expected("a list of entry ids", raw, out result, out message); + case IEnumerable list: + items = list.Cast(); + break; + default: + return Expected("a list of entry ids", raw, out result, out message); + } + + List ids = []; + foreach (var item in items) + { + if (!TryReadId(item, out var id) || id == Guid.Empty) + { + return Fail($"item {ids.Count} is not an entry id: expected a GUID", out result, out message); + } + + ids.Add(id); + } + + return Ok(ids.ToArray(), out result, out message); + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var id = new JsonObject { ["type"] = "string", ["format"] = "uuid" }; + return field.Multiple ? new JsonObject { ["type"] = "array", ["items"] = id } : id; + } + + private static bool TryReadId(object? raw, out Guid id) + { + switch (raw) + { + case Guid guid: + id = guid; + return true; + case string text: + return Guid.TryParseExact(text, "D", out id); + case not null when raw.GetType() is { IsGenericType: true } type && type.GetGenericTypeDefinition() == typeof(Ref<>): + id = (Guid)type.GetProperty(nameof(Ref.Id))!.GetValue(raw)!; + return true; + default: + id = Guid.Empty; + return false; + } + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/RichTextFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/RichTextFieldType.cs new file mode 100644 index 000000000..a80445e17 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/RichTextFieldType.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// Checks that the value is a document: a JSON object of type doc whose content, if any, is an array. Nodes and +// marks are not validated here; the rich text parser does that when the document is read. +internal sealed class RichTextFieldType() + : CoreFieldType(FieldTypeNames.RichText, [typeof(RichText)], FilterSets.NullChecks, sortable: false, indexable: false) +{ + private static readonly JsonDocumentOptions Options = new() { MaxDepth = 128 }; + + public override FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Json; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + string json; + switch (raw) + { + case null: + return Ok(null, out result, out message); + case RichText document: + json = document.Json; + break; + case string text: + json = text; + break; + case JsonElement { ValueKind: JsonValueKind.Object } element: + json = element.GetRawText(); + break; + default: + return Expected("a rich text document", raw, out result, out message); + } + + return Check(json) is { } problem ? Fail(problem, out result, out message) : Ok(raw as RichText ?? new RichText(json), out result, out message); + } + + public override JsonObject OpenApiSchema(SchemaField field) => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["type"] = new JsonObject { ["const"] = "doc" }, + ["content"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "object" } }, + }, + ["required"] = new JsonArray("type"), + }; + + private static string? Check(string json) + { + try + { + using var document = JsonDocument.Parse(json, Options); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("type", out var type) + || type.ValueKind != JsonValueKind.String + || type.GetString() != "doc") + { + return "is not a rich text document: the root must be an object of type doc"; + } + + if (root.TryGetProperty("content", out var content) && content.ValueKind != JsonValueKind.Array) + { + return "is not a rich text document: content must be an array"; + } + + return null; + } + catch (JsonException exception) + { + return $"is not valid JSON: {exception.Message}"; + } + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/SelectFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/SelectFieldType.cs new file mode 100644 index 000000000..90279f54a --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/SelectFieldType.cs @@ -0,0 +1,96 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// Whether a value is one of the options is a validation rule, not a coercion rule. +internal sealed class SelectFieldType() + : CoreFieldType( + FieldTypeNames.Select, + [typeof(string), typeof(Enum)], + [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.In, FilterOperator.NotIn, FilterOperator.Contains, .. FilterSets.NullChecks], + sortable: true, + indexable: true, + new FieldSettingDescriptor { Key = FieldSettings.Options, Kind = FieldSettingKind.StringList, Required = true }) +{ + public override FieldStorageKind StorageKind(SchemaField field) => field.Multiple ? FieldStorageKind.Json : FieldStorageKind.Column; + + public override bool Indexable(SchemaField field) => !field.Multiple; + + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + if (raw is null) + { + return Ok(null, out result, out message); + } + + if (!field.Multiple) + { + return TryReadOption(raw, out var option) + ? Ok(option, out result, out message) + : Expected("a string", raw, out result, out message); + } + + IEnumerable items; + switch (raw) + { + case JsonElement { ValueKind: JsonValueKind.Array } array: + items = array.EnumerateArray().Select(item => RawValue.Unwrap(item)); + break; + case string or JsonElement: + return Expected("a list of strings", raw, out result, out message); + case IEnumerable list: + items = list.Cast(); + break; + default: + return Expected("a list of strings", raw, out result, out message); + } + + List options = []; + foreach (var item in items) + { + if (!TryReadOption(item, out var option)) + { + return Fail($"item {options.Count} is not a string: it is {RawValue.Describe(item)}", out result, out message); + } + + options.Add(option); + } + + return Ok(options.ToArray(), out result, out message); + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var item = new JsonObject { ["type"] = "string" }; + var options = SettingValues.List(field, FieldSettings.Options); + if (options.Count > 0) + { + item["enum"] = new JsonArray([.. options.Select(option => (JsonNode)JsonValue.Create(option)!)]); + } + + return field.Multiple + ? new JsonObject { ["type"] = "array", ["items"] = item, ["uniqueItems"] = true } + : item; + } + + private static bool TryReadOption(object? raw, out string option) + { + switch (raw) + { + case string text: + option = text; + return true; + case Enum member: + option = SchemaNaming.ToCamelCase(member.ToString()); + return true; + default: + option = ""; + return false; + } + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/SettingValues.cs b/src/FluentCMS.Core/Schema/FieldTypes/SettingValues.cs new file mode 100644 index 000000000..6d8fb8296 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/SettingValues.cs @@ -0,0 +1,21 @@ +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal static class SettingValues +{ + public static string? Text(SchemaField field, string key) => + field.Settings.TryGetValue(key, out var value) ? value as string : null; + + public static IReadOnlyList List(SchemaField field, string key) => + field.Settings.TryGetValue(key, out var value) && value is IEnumerable list ? [.. list] : []; + + public static int? Int(SchemaField field, string key) => field.Settings.TryGetValue(key, out var value) + ? value switch + { + long whole when whole is >= int.MinValue and <= int.MaxValue => (int)whole, + decimal number when number == decimal.Truncate(number) && number is >= int.MinValue and <= int.MaxValue => (int)number, + _ => null, + } + : null; +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/SlugFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/SlugFieldType.cs new file mode 100644 index 000000000..d75116d03 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/SlugFieldType.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// Uniqueness of a slug is not checked here: the partial unique index of the storage model enforces it for published +// entries and the validation pipeline for drafts. +internal sealed partial class SlugFieldType() + : StringFieldType(FieldTypeNames.Slug, [typeof(string)], FilterSets.Text, sortable: true, indexable: true, new FieldSettingDescriptor { Key = FieldSettings.Source, Kind = FieldSettingKind.String }) +{ + public const string Pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$"; + + // Letters that do not decompose into a base letter and a combining mark. + private static readonly Dictionary Transliterations = new() + { + ['ß'] = "ss", + ['æ'] = "ae", + ['œ'] = "oe", + ['ø'] = "o", + ['đ'] = "d", + ['ð'] = "d", + ['ł'] = "l", + ['ı'] = "i", + ['þ'] = "th", + }; + + public static string Slugify(string source, int? maxLength = null) + { + ArgumentNullException.ThrowIfNull(source); + + StringBuilder slug = new(source.Length); + foreach (var character in source.Normalize(NormalizationForm.FormD)) + { + if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark) + { + continue; + } + + var lower = char.ToLowerInvariant(character); + if (char.IsAsciiLetterOrDigit(lower)) + { + slug.Append(lower); + } + else if (Transliterations.TryGetValue(lower, out var replacement)) + { + slug.Append(replacement); + } + else if (slug.Length > 0 && slug[^1] != '-') + { + slug.Append('-'); + } + } + + var text = slug.ToString().TrimEnd('-'); + if (maxLength is { } limit && text.Length > limit) + { + text = text[..Math.Max(limit, 0)].TrimEnd('-'); + } + + return text; + } + + protected override string? Check(string text) => + SlugPattern().IsMatch(text) ? null : "is not a slug: lower case letters and digits separated by single hyphens"; + + protected override void Describe(JsonObject schema) => schema["pattern"] = Pattern; + + [GeneratedRegex(Pattern, RegexOptions.CultureInvariant)] + private static partial Regex SlugPattern(); +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/StringFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/StringFieldType.cs new file mode 100644 index 000000000..eade368fc --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/StringFieldType.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +// The types whose value is a string; each adds its own check of the text. +internal abstract class StringFieldType(string name, Type[] clrTypes, FilterOperator[] filterOperators, bool sortable, bool indexable, params FieldSettingDescriptor[] settings) + : CoreFieldType(name, clrTypes, filterOperators, sortable, indexable, settings) +{ + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + var raw = RawValue.Unwrap(value); + string text; + switch (raw) + { + case null: + return Ok(null, out result, out message); + case string s: + text = s; + break; + case char c: + text = c.ToString(); + break; + default: + return Expected("a string", raw, out result, out message); + } + + return Check(text) is { } problem ? Fail(problem, out result, out message) : Ok(text, out result, out message); + } + + public override JsonObject OpenApiSchema(SchemaField field) + { + var schema = new JsonObject { ["type"] = "string" }; + Describe(schema); + + if (field.MinLength is { } min) + { + schema["minLength"] = min; + } + + if (field.MaxLength is { } max) + { + schema["maxLength"] = max; + } + + if (field.Pattern is { } pattern && !schema.ContainsKey("pattern")) + { + schema["pattern"] = pattern; + } + + return schema; + } + + protected virtual string? Check(string text) => null; + + protected virtual void Describe(JsonObject schema) + { + } +} diff --git a/src/FluentCMS.Core/Schema/FieldTypes/TextFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/TextFieldType.cs new file mode 100644 index 000000000..c4cdd4f21 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/TextFieldType.cs @@ -0,0 +1,4 @@ +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class TextFieldType() + : StringFieldType(FieldTypeNames.Text, [typeof(string)], FilterSets.Text, sortable: true, indexable: true); diff --git a/src/FluentCMS.Core/Schema/FieldTypes/UrlFieldType.cs b/src/FluentCMS.Core/Schema/FieldTypes/UrlFieldType.cs new file mode 100644 index 000000000..25d93806d --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldTypes/UrlFieldType.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Nodes; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Schema.FieldTypes; + +internal sealed class UrlFieldType() + : StringFieldType(FieldTypeNames.Url, [typeof(string), typeof(Uri)], FilterSets.Text, sortable: true, indexable: true) +{ + public override bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) => + base.TryCoerce(value is Uri uri ? uri.OriginalString : value, field, out result, out message); + + protected override string? Check(string text) => + Uri.TryCreate(text, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https" && uri.Host.Length > 0 + ? null + : "is not an absolute http or https URL"; + + protected override void Describe(JsonObject schema) => schema["format"] = "uri"; +} diff --git a/src/FluentCMS.Core/Schema/KnownFieldTypes.cs b/src/FluentCMS.Core/Schema/KnownFieldTypes.cs deleted file mode 100644 index 3587205cf..000000000 --- a/src/FluentCMS.Core/Schema/KnownFieldTypes.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Frozen; - -namespace FluentCMS.Core.Schema; - -// The field types core registers. The field type registry replaces this list. -internal static class KnownFieldTypes -{ - public const string Select = "select"; - public const string Slug = "slug"; - public const string Relation = "relation"; - public const string Component = "component"; - public const string Blocks = "blocks"; - - public static IReadOnlySet Core { get; } = new[] - { - "text", - "longText", - "richText", - "integer", - "decimal", - "boolean", - "date", - "dateTime", - "email", - "url", - Slug, - Select, - "json", - Relation, - Component, - Blocks, - }.ToFrozenSet(StringComparer.Ordinal); -} diff --git a/src/FluentCMS.Core/Schema/SchemaBuilder.cs b/src/FluentCMS.Core/Schema/SchemaBuilder.cs index 33369a59e..d1aa473ff 100644 --- a/src/FluentCMS.Core/Schema/SchemaBuilder.cs +++ b/src/FluentCMS.Core/Schema/SchemaBuilder.cs @@ -1,5 +1,5 @@ -using System.Collections.Frozen; using System.Reflection; +using FluentCMS.Core.Schema.FieldTypes; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; @@ -9,16 +9,17 @@ public sealed class SchemaBuilder : ISchemaBuilder private readonly List _types = []; private readonly List _errors = []; private readonly HashSet _classes = []; - private readonly IReadOnlySet _fieldTypes; + private readonly IFieldTypeRegistry _fieldTypes; public SchemaBuilder() - : this([]) + : this(FieldTypeRegistry.Core) { } - internal SchemaBuilder(IEnumerable additionalFieldTypes) + public SchemaBuilder(IFieldTypeRegistry fieldTypes) { - _fieldTypes = KnownFieldTypes.Core.Union(additionalFieldTypes).ToFrozenSet(StringComparer.Ordinal); + ArgumentNullException.ThrowIfNull(fieldTypes); + _fieldTypes = fieldTypes; } public SchemaBuilder AddContentType() => AddContentType(typeof(T)); diff --git a/src/FluentCMS.Core/Schema/SchemaValidator.cs b/src/FluentCMS.Core/Schema/SchemaValidator.cs index 64cd6dfbc..dd337c0e0 100644 --- a/src/FluentCMS.Core/Schema/SchemaValidator.cs +++ b/src/FluentCMS.Core/Schema/SchemaValidator.cs @@ -1,17 +1,24 @@ using System.Globalization; using System.Text.RegularExpressions; +using FluentCMS.Core.Schema.FieldTypes; using FluentCMS.Schema; namespace FluentCMS.Core.Schema; // Checks the rules of section 4.2 of the backend specification and reports every violation, not only the first. -internal sealed class SchemaValidator(IReadOnlySet registeredFieldTypes) +internal sealed class SchemaValidator(IFieldTypeRegistry fieldTypes) { // Components and blocks may nest this many levels below a content type (decision 20). private const int MaxNestingDepth = 3; private const string BlockDiscriminator = "type"; + // A decimal holds 28 digits. + private const int MaxDecimalDigits = 28; + + // A select, component or blocks field without its setting is reported by the type-specific checks, with a message of its own. + private static readonly HashSet TypesThatReportMissingSettings = [FieldTypeNames.Select, FieldTypeNames.Component, FieldTypeNames.Blocks]; + private readonly List _errors = []; public IReadOnlyList Validate(IReadOnlyList types) @@ -106,10 +113,16 @@ private void ValidateField(TypeDraft type, FieldDraft field, Dictionary wrongKind = []; + if (fieldType is null) { Report(SchemaErrorCodes.FieldTypeNotRegistered, $"field type '{field.FieldType}' used by {location} is not registered"); } + else + { + wrongKind = ValidateSettingKeys(fieldType, field, location); + } if (field.Localizable && !(type.IsContentType && type.Localized)) { @@ -119,10 +132,87 @@ private void ValidateField(TypeDraft type, FieldDraft field, Dictionary ValidateSettingKeys(IFieldType fieldType, FieldDraft field, string location) + { + HashSet wrongKind = []; + + foreach (var (key, value) in field.Settings.OrderBy(setting => setting.Key, StringComparer.Ordinal)) + { + var descriptor = fieldType.Settings.FirstOrDefault(setting => setting.Key == key); + if (descriptor is null) + { + if (!fieldType.AcceptsAnySetting) + { + var accepted = fieldType.Settings.Count == 0 ? "it accepts none" : $"it accepts {string.Join(", ", fieldType.Settings.Select(setting => setting.Key))}"; + Report(SchemaErrorCodes.InvalidField, $"{fieldType.Name} field {location} has an unknown setting '{key}'; {accepted}"); + } + } + else if (!HasKind(value, descriptor.Kind)) + { + wrongKind.Add(key); + Report(SchemaErrorCodes.InvalidField, $"setting '{key}' of {fieldType.Name} field {location} must be {Describe(descriptor.Kind)}"); + } + } + + if (!TypesThatReportMissingSettings.Contains(fieldType.Name)) + { + foreach (var descriptor in fieldType.Settings.Where(setting => setting.Required && !field.Settings.ContainsKey(setting.Key))) + { + Report(SchemaErrorCodes.InvalidField, $"{fieldType.Name} field {location} is missing the required setting '{descriptor.Key}'"); + } + } + + return wrongKind; + } + + private void ValidateDecimalBounds(FieldDraft field, string location) + { + long? precision = field.Settings.TryGetValue(FieldSettings.Precision, out var p) && p is long digits ? digits : null; + long? scale = field.Settings.TryGetValue(FieldSettings.Scale, out var s) && s is long places ? places : null; + + var precisionFits = precision is null or (>= 1 and <= MaxDecimalDigits); + var scaleFits = scale is null or (>= 0 and <= MaxDecimalDigits); + + if (!precisionFits) + { + Report(SchemaErrorCodes.InvalidField, $"decimal field {location} has a precision of {precision}; it must be between 1 and {MaxDecimalDigits}"); + } + + if (!scaleFits) + { + Report(SchemaErrorCodes.InvalidField, $"decimal field {location} has a scale of {scale}; it must be between 0 and {MaxDecimalDigits}"); + } + + if (precisionFits && scaleFits && scale > precision) + { + Report(SchemaErrorCodes.InvalidField, $"decimal field {location} has a scale of {scale} above its precision of {precision}"); + } + } + + private static bool HasKind(object value, FieldSettingKind kind) => kind switch + { + FieldSettingKind.String => value is string, + FieldSettingKind.Boolean => value is bool, + FieldSettingKind.Integer => value is long, + FieldSettingKind.Decimal => value is long or decimal, + FieldSettingKind.StringList => value is IReadOnlyList, + _ => false, + }; + + private static string Describe(FieldSettingKind kind) => kind switch + { + FieldSettingKind.String => "a string", + FieldSettingKind.Boolean => "true or false", + FieldSettingKind.Integer => "a whole number", + FieldSettingKind.Decimal => "a number", + _ => "a list of strings", + }; + private void ValidateLimits(FieldDraft field, string location) { if (field.MinLength < 0 || field.MaxLength < 1 || field.MinLength > field.MaxLength) @@ -148,9 +238,9 @@ private void ValidateLimits(FieldDraft field, string location) } } - private void ValidateSettings(TypeDraft type, FieldDraft field, string location, Dictionary contentTypes, Dictionary components, Dictionary blocks) + private void ValidateSettings(TypeDraft type, FieldDraft field, string location, HashSet wrongKind, Dictionary contentTypes, Dictionary components, Dictionary blocks) { - if (field.Relation is not null && field.FieldType != KnownFieldTypes.Relation) + if (field.Relation is not null && field.FieldType != FieldTypeNames.Relation) { Report(SchemaErrorCodes.InvalidField, $"field {location} has a relation target, but its type is '{field.FieldType}', not 'relation'"); } @@ -162,7 +252,7 @@ private void ValidateSettings(TypeDraft type, FieldDraft field, string location, switch (field.FieldType) { - case KnownFieldTypes.Select: + case FieldTypeNames.Select when !wrongKind.Contains(FieldSettings.Options): var options = SettingList(field, FieldSettings.Options); if (options.Length == 0 || options.Distinct(StringComparer.Ordinal).Count() != options.Length) { @@ -171,7 +261,11 @@ private void ValidateSettings(TypeDraft type, FieldDraft field, string location, break; - case KnownFieldTypes.Slug: + case FieldTypeNames.Decimal: + ValidateDecimalBounds(field, location); + break; + + case FieldTypeNames.Slug: if (SettingText(field, FieldSettings.Source) is { } source && (string.Equals(source, field.Name, StringComparison.Ordinal) || type.Fields.All(other => other.Name != source))) { @@ -180,7 +274,7 @@ private void ValidateSettings(TypeDraft type, FieldDraft field, string location, break; - case KnownFieldTypes.Relation: + case FieldTypeNames.Relation: if (field.Relation is null) { Report(SchemaErrorCodes.InvalidField, $"relation field {location} has no target"); @@ -192,7 +286,7 @@ private void ValidateSettings(TypeDraft type, FieldDraft field, string location, break; - case KnownFieldTypes.Component: + case FieldTypeNames.Component when !wrongKind.Contains(FieldSettings.Component): if (SettingText(field, FieldSettings.Component) is not { } component) { Report(SchemaErrorCodes.InvalidField, $"component field {location} has no component"); @@ -204,7 +298,7 @@ private void ValidateSettings(TypeDraft type, FieldDraft field, string location, break; - case KnownFieldTypes.Blocks: + case FieldTypeNames.Blocks when !wrongKind.Contains(FieldSettings.Blocks): var allowed = SettingList(field, FieldSettings.Blocks); if (allowed.Length == 0) { @@ -286,13 +380,13 @@ IEnumerable Children(TypeDraft type) { foreach (var field in type.Fields) { - if (field.FieldType == KnownFieldTypes.Component + if (field.FieldType == FieldTypeNames.Component && SettingText(field, FieldSettings.Component) is { } name && components.TryGetValue(name, out var component)) { yield return component; } - else if (field.FieldType == KnownFieldTypes.Blocks) + else if (field.FieldType == FieldTypeNames.Blocks) { foreach (var block in SettingList(field, FieldSettings.Blocks)) { diff --git a/src/FluentCMS.Sdk/Content/FilterOperator.cs b/src/FluentCMS.Sdk/Content/FilterOperator.cs new file mode 100644 index 000000000..932458c90 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/FilterOperator.cs @@ -0,0 +1,47 @@ +namespace FluentCMS.Content; + +/// An operator a filter can apply to a field. The names in the REST grammar are the camelCase operators of the content query model. +public enum FilterOperator +{ + /// The value equals the operand (eq). + Equal, + + /// The value differs from the operand (ne). + NotEqual, + + /// The value is one of the operands (in). + In, + + /// The value is none of the operands (nin). + NotIn, + + /// The value is less than the operand (lt). + LessThan, + + /// The value is less than or equal to the operand (lte). + LessThanOrEqual, + + /// The value is greater than the operand (gt). + GreaterThan, + + /// The value is greater than or equal to the operand (gte). + GreaterThanOrEqual, + + /// The value lies between two operands, both included (between). + Between, + + /// The value contains the operand: a substring of text, or a member of a list (contains). + Contains, + + /// The text starts with the operand (startsWith). + StartsWith, + + /// The field has no value (null). + IsNull, + + /// The field has a value (notNull). + IsNotNull, + + /// The block list holds a block of the given type (hasBlock). + HasBlock, +} diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index b1cb5c55c..c47066a02 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -1,4 +1,19 @@ #nullable enable +FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.Between = 8 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.Contains = 9 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.Equal = 0 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.GreaterThan = 6 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.GreaterThanOrEqual = 7 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.HasBlock = 13 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.In = 2 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.IsNotNull = 12 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.IsNull = 11 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.LessThan = 4 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.LessThanOrEqual = 5 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.NotEqual = 1 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.NotIn = 3 -> FluentCMS.Content.FilterOperator +FluentCMS.Content.FilterOperator.StartsWith = 10 -> FluentCMS.Content.FilterOperator FluentCMS.Content.IEntryValidator FluentCMS.Content.IEntryValidator.ValidateAsync(T entry, FluentCMS.Content.ValidationContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask FluentCMS.Content.IFieldValidator @@ -215,7 +230,27 @@ FluentCMS.Schema.FieldRelation FluentCMS.Schema.FieldRelation.Ordered.get -> bool FluentCMS.Schema.FieldRelation.Reverse.get -> string? FluentCMS.Schema.FieldRelation.Target.get -> string! +FluentCMS.Schema.FieldSettingDescriptor +FluentCMS.Schema.FieldSettingDescriptor.$() -> FluentCMS.Schema.FieldSettingDescriptor! +FluentCMS.Schema.FieldSettingDescriptor.Equals(FluentCMS.Schema.FieldSettingDescriptor? other) -> bool +FluentCMS.Schema.FieldSettingDescriptor.FieldSettingDescriptor() -> void +FluentCMS.Schema.FieldSettingDescriptor.Key.get -> string! +FluentCMS.Schema.FieldSettingDescriptor.Key.init -> void +FluentCMS.Schema.FieldSettingDescriptor.Kind.get -> FluentCMS.Schema.FieldSettingKind +FluentCMS.Schema.FieldSettingDescriptor.Kind.init -> void +FluentCMS.Schema.FieldSettingDescriptor.Required.get -> bool +FluentCMS.Schema.FieldSettingDescriptor.Required.init -> void +FluentCMS.Schema.FieldSettingKind +FluentCMS.Schema.FieldSettingKind.Boolean = 1 -> FluentCMS.Schema.FieldSettingKind +FluentCMS.Schema.FieldSettingKind.Decimal = 3 -> FluentCMS.Schema.FieldSettingKind +FluentCMS.Schema.FieldSettingKind.Integer = 2 -> FluentCMS.Schema.FieldSettingKind +FluentCMS.Schema.FieldSettingKind.String = 0 -> FluentCMS.Schema.FieldSettingKind +FluentCMS.Schema.FieldSettingKind.StringList = 4 -> FluentCMS.Schema.FieldSettingKind FluentCMS.Schema.FieldSettings +FluentCMS.Schema.FieldStorageKind +FluentCMS.Schema.FieldStorageKind.Column = 0 -> FluentCMS.Schema.FieldStorageKind +FluentCMS.Schema.FieldStorageKind.Json = 1 -> FluentCMS.Schema.FieldStorageKind +FluentCMS.Schema.FieldStorageKind.Reference = 2 -> FluentCMS.Schema.FieldStorageKind FluentCMS.Schema.FormerNamesAttribute FluentCMS.Schema.FormerNamesAttribute.FormerNamesAttribute(params string![]! names) -> void FluentCMS.Schema.FormerNamesAttribute.Names.get -> System.Collections.Generic.IReadOnlyList! @@ -240,6 +275,20 @@ FluentCMS.Schema.IFieldBuilder.Searchable(bool searchable = true) -> FluentCMS.S FluentCMS.Schema.IFieldBuilder.Setting(string! name, object! value) -> FluentCMS.Schema.IFieldBuilder! FluentCMS.Schema.IFieldBuilder.Unique(bool unique = true) -> FluentCMS.Schema.IFieldBuilder! FluentCMS.Schema.IFieldBuilder.VisibleWhen(FluentCMS.Schema.Condition! condition) -> FluentCMS.Schema.IFieldBuilder! +FluentCMS.Schema.IFieldType +FluentCMS.Schema.IFieldType.AcceptsAnySetting.get -> bool +FluentCMS.Schema.IFieldType.ClrTypes.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Schema.IFieldType.FilterOperators.get -> System.Collections.Generic.IReadOnlySet! +FluentCMS.Schema.IFieldType.Indexable(FluentCMS.Schema.SchemaField! field) -> bool +FluentCMS.Schema.IFieldType.Name.get -> string! +FluentCMS.Schema.IFieldType.OpenApiSchema(FluentCMS.Schema.SchemaField! field) -> System.Text.Json.Nodes.JsonObject! +FluentCMS.Schema.IFieldType.Settings.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Schema.IFieldType.Sortable(FluentCMS.Schema.SchemaField! field) -> bool +FluentCMS.Schema.IFieldType.StorageKind(FluentCMS.Schema.SchemaField! field) -> FluentCMS.Schema.FieldStorageKind +FluentCMS.Schema.IFieldType.TryCoerce(object? value, FluentCMS.Schema.SchemaField! field, out object? result, out string? message) -> bool +FluentCMS.Schema.IFieldTypeRegistry +FluentCMS.Schema.IFieldTypeRegistry.All.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Schema.IFieldTypeRegistry.Find(string! name) -> FluentCMS.Schema.IFieldType? FluentCMS.Schema.IRichTextNodeContext FluentCMS.Schema.IRichTextNodeContext.Attributes.get -> System.Collections.Generic.IReadOnlyDictionary! FluentCMS.Schema.IRichTextNodeContext.Format.get -> string! @@ -453,6 +502,9 @@ override FluentCMS.Schema.BlockList.Equals(object? obj) -> bool override FluentCMS.Schema.BlockList.GetHashCode() -> int override FluentCMS.Schema.BlockList.ToString() -> string! override FluentCMS.Schema.Condition.ToString() -> string! +override FluentCMS.Schema.FieldSettingDescriptor.Equals(object? obj) -> bool +override FluentCMS.Schema.FieldSettingDescriptor.GetHashCode() -> int +override FluentCMS.Schema.FieldSettingDescriptor.ToString() -> string! override FluentCMS.Schema.Ref.GetHashCode() -> int override FluentCMS.Schema.Ref.ToString() -> string! override FluentCMS.Schema.RefList.Equals(object? obj) -> bool @@ -479,6 +531,8 @@ static FluentCMS.Schema.BlockList.operator !=(FluentCMS.Schema.BlockList? left, static FluentCMS.Schema.BlockList.operator ==(FluentCMS.Schema.BlockList? left, FluentCMS.Schema.BlockList? right) -> bool static FluentCMS.Schema.Condition.Field(string! name) -> FluentCMS.Schema.ConditionField! static FluentCMS.Schema.ContentItem.ReservedFieldNames.get -> System.Collections.Generic.IReadOnlySet! +static FluentCMS.Schema.FieldSettingDescriptor.operator !=(FluentCMS.Schema.FieldSettingDescriptor? left, FluentCMS.Schema.FieldSettingDescriptor? right) -> bool +static FluentCMS.Schema.FieldSettingDescriptor.operator ==(FluentCMS.Schema.FieldSettingDescriptor? left, FluentCMS.Schema.FieldSettingDescriptor? right) -> bool static FluentCMS.Schema.RefList.Create(System.ReadOnlySpan> items) -> FluentCMS.Schema.RefList! static FluentCMS.Schema.RichText.Empty.get -> FluentCMS.Schema.RichText! static FluentCMS.Schema.RichText.operator !=(FluentCMS.Schema.RichText? left, FluentCMS.Schema.RichText? right) -> bool diff --git a/src/FluentCMS.Sdk/Schema/FieldSettingDescriptor.cs b/src/FluentCMS.Sdk/Schema/FieldSettingDescriptor.cs new file mode 100644 index 000000000..7b46c9ac2 --- /dev/null +++ b/src/FluentCMS.Sdk/Schema/FieldSettingDescriptor.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Schema; + +/// A setting that a field type accepts in . +public sealed record FieldSettingDescriptor +{ + /// The key of the setting, in camelCase. + public required string Key { get; init; } + + /// The kind of value the setting holds. + public required FieldSettingKind Kind { get; init; } + + /// Whether every field of the type must set it. + public bool Required { get; init; } +} diff --git a/src/FluentCMS.Sdk/Schema/FieldSettingKind.cs b/src/FluentCMS.Sdk/Schema/FieldSettingKind.cs new file mode 100644 index 000000000..9b90b1f50 --- /dev/null +++ b/src/FluentCMS.Sdk/Schema/FieldSettingKind.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace FluentCMS.Schema; + +/// The kind of value a setting of a field type holds. +[SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "The members name the kinds of value a setting holds.")] +public enum FieldSettingKind +{ + /// A string. + String, + + /// A boolean. + Boolean, + + /// A whole number. + Integer, + + /// A number, whole or not. + Decimal, + + /// A list of strings. + StringList, +} diff --git a/src/FluentCMS.Sdk/Schema/FieldStorageKind.cs b/src/FluentCMS.Sdk/Schema/FieldStorageKind.cs new file mode 100644 index 000000000..bdf642b1f --- /dev/null +++ b/src/FluentCMS.Sdk/Schema/FieldStorageKind.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Schema; + +/// How the value of a field is stored. +public enum FieldStorageKind +{ + /// In a column of the content table of its type. + Column, + + /// As JSON in a single column. + Json, + + /// As rows of the reference table, with no column of its own. + Reference, +} diff --git a/src/FluentCMS.Sdk/Schema/IFieldType.cs b/src/FluentCMS.Sdk/Schema/IFieldType.cs new file mode 100644 index 000000000..d49be023f --- /dev/null +++ b/src/FluentCMS.Sdk/Schema/IFieldType.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; + +namespace FluentCMS.Schema; + +/// +/// Describes one field type: the CLR types it maps to, how it is stored, what a query may do with it, which +/// settings it accepts, how a raw value becomes the value of the field and how it appears in OpenAPI. +/// Rules such as required, length, range, pattern and option membership are not part of a descriptor; the +/// validation pipeline applies them. +/// +public interface IFieldType +{ + /// The name used in , in camelCase. + string Name { get; } + + /// The property types a class may use for a field of this type. An open generic type, such as Ref<>, stands for every closed form. + IReadOnlyList ClrTypes { get; } + + /// The filter operators a query may apply to the type. Whether a field is stored as JSON narrows what the engine can compile. + IReadOnlySet FilterOperators { get; } + + + /// The settings the type accepts in . + IReadOnlyList Settings { get; } + + /// + /// Whether the type accepts settings that does not list, without checking their value. + /// Core types do not; a plugin type that keeps free-form options can. The default is . + /// + bool AcceptsAnySetting => false; + + /// Whether a query may sort by the field. Core types answer for a field stored as , such as a multi select. + /// The field. + /// if the field can be sorted. + bool Sortable(SchemaField field); + + /// Whether the field may be marked as indexed. A JSON column is indexed only where the type says so: json and blocks are, a multi select is not. + /// The field. + /// if the field can be indexed. + bool Indexable(SchemaField field); + + /// How a field of this type is stored; some types depend on the field, such as a component that repeats. + /// The field. + /// The storage kind. + FieldStorageKind StorageKind(SchemaField field); + + /// + /// Turns a raw value into the canonical CLR value of the field. A raw value is a CLR value (a string, a number, a + /// boolean, a date, an enum member or a value that already has the right type), a JsonElement or a + /// JsonNode. The canonical value is a CLR value, except that a field holding JSON gives a JsonNode. + /// A value, or a JSON null, is accepted and gives ; whether the field + /// may be empty is a validation rule. + /// + /// The raw value. + /// The field. + /// The canonical value when the call succeeds. + /// A message that names what is wrong when the call fails. + /// if the type can hold the value. + bool TryCoerce(object? value, SchemaField field, out object? result, out string? message); + + /// Describes the value of the field as an OpenAPI 3.1 schema, without nullability or the required marker, which belong to the object that holds the field. + /// The field. + /// The schema fragment. + JsonObject OpenApiSchema(SchemaField field); +} diff --git a/src/FluentCMS.Sdk/Schema/IFieldTypeRegistry.cs b/src/FluentCMS.Sdk/Schema/IFieldTypeRegistry.cs new file mode 100644 index 000000000..86af4aefc --- /dev/null +++ b/src/FluentCMS.Sdk/Schema/IFieldTypeRegistry.cs @@ -0,0 +1,13 @@ +namespace FluentCMS.Schema; + +/// The field types a host knows: the ones core registers and the ones plugins add. +public interface IFieldTypeRegistry +{ + /// Every field type, ordered by name. + IReadOnlyList All { get; } + + /// Finds a field type by name. + /// The name as used in , such as richText. + /// The field type, or if none is registered under that name. + IFieldType? Find(string name); +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/DescriptorTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/DescriptorTests.cs new file mode 100644 index 000000000..13a011784 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/DescriptorTests.cs @@ -0,0 +1,189 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class DescriptorTests +{ + private static readonly FilterOperator[] Text = [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.In, FilterOperator.NotIn, FilterOperator.Contains, FilterOperator.StartsWith, FilterOperator.IsNull, FilterOperator.IsNotNull]; + + private static readonly FilterOperator[] Ordered = [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.In, FilterOperator.NotIn, FilterOperator.LessThan, FilterOperator.LessThanOrEqual, FilterOperator.GreaterThan, FilterOperator.GreaterThanOrEqual, FilterOperator.Between, FilterOperator.IsNull, FilterOperator.IsNotNull]; + + private static readonly FilterOperator[] NullChecks = [FilterOperator.IsNull, FilterOperator.IsNotNull]; + + public static TheoryData Descriptors => new() + { + { "text", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Text }, + { "longText", FieldStorageKind.Column, FieldStorageKind.Column, false, false, [FilterOperator.Contains, FilterOperator.StartsWith, FilterOperator.IsNull, FilterOperator.IsNotNull] }, + { "richText", FieldStorageKind.Json, FieldStorageKind.Json, false, false, NullChecks }, + { "integer", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Ordered }, + { "decimal", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Ordered }, + { "boolean", FieldStorageKind.Column, FieldStorageKind.Column, true, true, [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.IsNull, FilterOperator.IsNotNull] }, + { "date", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Ordered }, + { "dateTime", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Ordered }, + { "email", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Text }, + { "url", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Text }, + { "slug", FieldStorageKind.Column, FieldStorageKind.Column, true, true, Text }, + { "select", FieldStorageKind.Column, FieldStorageKind.Json, true, true, [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.In, FilterOperator.NotIn, FilterOperator.Contains, FilterOperator.IsNull, FilterOperator.IsNotNull] }, + { "json", FieldStorageKind.Json, FieldStorageKind.Json, false, true, [FilterOperator.Equal, FilterOperator.NotEqual, FilterOperator.Contains, FilterOperator.IsNull, FilterOperator.IsNotNull] }, + { "relation", FieldStorageKind.Reference, FieldStorageKind.Reference, false, false, [FilterOperator.Equal, FilterOperator.In, FilterOperator.IsNull, FilterOperator.IsNotNull] }, + { "component", FieldStorageKind.Column, FieldStorageKind.Json, false, false, [] }, + { "blocks", FieldStorageKind.Json, FieldStorageKind.Json, false, true, [FilterOperator.HasBlock, FilterOperator.IsNull, FilterOperator.IsNotNull] }, + }; + + public static TheoryData OpenApiFragments => new() + { + { "text", """{"type":"string"}""" }, + { "longText", """{"type":"string"}""" }, + { "richText", """{"type":"object","properties":{"type":{"const":"doc"},"content":{"type":"array","items":{"type":"object"}}},"required":["type"]}""" }, + { "integer", """{"type":"integer","format":"int64"}""" }, + { "decimal", """{"type":"number"}""" }, + { "boolean", """{"type":"boolean"}""" }, + { "date", """{"type":"string","format":"date"}""" }, + { "dateTime", """{"type":"string","format":"date-time"}""" }, + { "email", """{"type":"string","format":"email"}""" }, + { "url", """{"type":"string","format":"uri"}""" }, + { "slug", """{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"}""" }, + { "select", """{"type":"string","enum":["news","opinion"]}""" }, + { "json", "{}" }, + { "relation", """{"type":"string","format":"uuid"}""" }, + { "component", """{"$ref":"#/components/schemas/component-seo"}""" }, + { "blocks", """{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/block-hero"}],"discriminator":{"propertyName":"type","mapping":{"hero":"#/components/schemas/block-hero"}}}}""" }, + }; + + [Theory] + [MemberData(nameof(Descriptors))] + public void DescriptorsFollowTheStorageAndQueryModel(string name, FieldStorageKind column, FieldStorageKind repeated, bool sortable, bool indexable, FilterOperator[] operators) + { + var type = TestFields.Type(name); + + type.Name.ShouldBe(name); + type.StorageKind(TestFields.Field(name)).ShouldBe(column); + type.StorageKind(TestFields.Field(name, field => field.Multiple())).ShouldBe(repeated); + + var single = TestFields.Field(name); + var many = TestFields.Field(name, field => field.Multiple()); + type.Sortable(single).ShouldBe(sortable); + type.Sortable(many).ShouldBe(sortable && repeated != FieldStorageKind.Json); + type.Indexable(single).ShouldBe(indexable); + type.Indexable(many).ShouldBe(indexable && (repeated != FieldStorageKind.Json || column == FieldStorageKind.Json)); + type.FilterOperators.OrderBy(op => op).ShouldBe(operators.OrderBy(op => op)); + type.ClrTypes.ShouldNotBeEmpty(); + type.AcceptsAnySetting.ShouldBeFalse(); + } + + [Fact] + public void AFieldStoredAsJsonIsNeverSortable() + { + foreach (var type in FieldTypeRegistry.Core.All) + { + foreach (var multiple in new[] { false, true }) + { + var field = TestFields.Field(type.Name, f => f.Multiple(multiple)); + if (type.StorageKind(field) == FieldStorageKind.Json) + { + type.Sortable(field).ShouldBeFalse($"{type.Name}, multiple {multiple}"); + } + } + } + } + + [Theory] + [MemberData(nameof(OpenApiFragments))] + public void OpenApiFragmentHasTheExpectedShape(string name, string expected) + { + var actual = TestFields.Type(name).OpenApiSchema(TestFields.Field(name)); + + JsonNode.DeepEquals(actual, JsonNode.Parse(expected)).ShouldBeTrue(actual.ToJsonString()); + } + + [Fact] + public void OpenApiFragmentIsANewObjectEachTime() + { + var field = TestFields.Field("text"); + var type = TestFields.Type("text"); + + type.OpenApiSchema(field).ShouldNotBeSameAs(type.OpenApiSchema(field)); + } + + [Fact] + public void TextFragmentCarriesTheLimitsOfTheField() + { + var field = TestFields.Field("text", f => f.Length(2, 50).Pattern("^a")); + + JsonNode.DeepEquals(TestFields.Type("text").OpenApiSchema(field), JsonNode.Parse("""{"type":"string","minLength":2,"maxLength":50,"pattern":"^a"}""")).ShouldBeTrue(); + } + + [Fact] + public void SlugFragmentKeepsTheSlugPatternAndTheLength() + { + var field = TestFields.Field("slug", f => f.Length(null, 80).Pattern("^a")); + + JsonNode.DeepEquals(TestFields.Type("slug").OpenApiSchema(field), JsonNode.Parse("""{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","maxLength":80}""")).ShouldBeTrue(); + } + + [Fact] + public void NumberFragmentsCarryTheRange() + { + var integer = TestFields.Field("integer", f => f.Range(1, 10)); + var number = TestFields.Field("decimal", f => f.Range(0.5m, null).Setting("scale", 2)); + + JsonNode.DeepEquals(TestFields.Type("integer").OpenApiSchema(integer), JsonNode.Parse("""{"type":"integer","format":"int64","minimum":1,"maximum":10}""")).ShouldBeTrue(); + JsonNode.DeepEquals(TestFields.Type("decimal").OpenApiSchema(number), JsonNode.Parse("""{"type":"number","minimum":0.5}""")).ShouldBeTrue(); + } + + [Fact] + public void MultipleFieldsDescribeAnArray() + { + var select = TestFields.Field("select", f => f.Multiple()); + var relation = TestFields.Field("relation", f => f.Multiple()); + var component = TestFields.Field("component", f => f.Multiple()); + + JsonNode.DeepEquals(TestFields.Type("select").OpenApiSchema(select), JsonNode.Parse("""{"type":"array","items":{"type":"string","enum":["news","opinion"]},"uniqueItems":true}""")).ShouldBeTrue(); + JsonNode.DeepEquals(TestFields.Type("relation").OpenApiSchema(relation), JsonNode.Parse("""{"type":"array","items":{"type":"string","format":"uuid"}}""")).ShouldBeTrue(); + JsonNode.DeepEquals(TestFields.Type("component").OpenApiSchema(component), JsonNode.Parse("""{"type":"array","items":{"$ref":"#/components/schemas/component-seo"}}""")).ShouldBeTrue(); + } + + [Fact] + public void CoreTypesDeclareTheirSettings() + { + TestFields.Type("select").Settings.ShouldBe([new FieldSettingDescriptor { Key = "options", Kind = FieldSettingKind.StringList, Required = true }]); + TestFields.Type("component").Settings.ShouldBe([new FieldSettingDescriptor { Key = "component", Kind = FieldSettingKind.String, Required = true }]); + TestFields.Type("blocks").Settings.ShouldBe([new FieldSettingDescriptor { Key = "blocks", Kind = FieldSettingKind.StringList, Required = true }]); + TestFields.Type("slug").Settings.ShouldBe([new FieldSettingDescriptor { Key = "source", Kind = FieldSettingKind.String }]); + TestFields.Type("decimal").Settings.ShouldBe([new FieldSettingDescriptor { Key = "precision", Kind = FieldSettingKind.Integer }, new FieldSettingDescriptor { Key = "scale", Kind = FieldSettingKind.Integer }]); + + foreach (var name in new[] { "text", "longText", "richText", "integer", "boolean", "date", "dateTime", "email", "url", "json", "relation" }) + { + TestFields.Type(name).Settings.ShouldBeEmpty(name); + } + } + + [Fact] + public void ListsOfTheRegistryAndItsTypesCannotBeChanged() + { + var type = TestFields.Type("decimal"); + + Should.Throw(() => ((IList)type.ClrTypes).Add(typeof(int))); + Should.Throw(() => ((IList)type.Settings).Clear()); + Should.Throw(() => ((IList)FieldTypeRegistry.Core.All).Clear()); + FieldTypeRegistry.Core.All.Count.ShouldBe(16); + } + + [Fact] + public void ClrTypesNameThePropertyTypesOfEachType() + { + TestFields.Type("text").ClrTypes.ShouldBe([typeof(string)]); + TestFields.Type("richText").ClrTypes.ShouldBe([typeof(RichText)]); + TestFields.Type("integer").ClrTypes.ShouldContain(typeof(int)); + TestFields.Type("integer").ClrTypes.ShouldContain(typeof(long?)); + TestFields.Type("date").ClrTypes.ShouldContain(typeof(DateOnly)); + TestFields.Type("dateTime").ClrTypes.ShouldContain(typeof(DateTimeOffset?)); + TestFields.Type("relation").ClrTypes.ShouldBe([typeof(Ref<>), typeof(RefList<>)]); + TestFields.Type("blocks").ClrTypes.ShouldBe([typeof(BlockList)]); + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/FieldTypeRegistryTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/FieldTypeRegistryTests.cs new file mode 100644 index 000000000..c6500b916 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/FieldTypeRegistryTests.cs @@ -0,0 +1,144 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class FieldTypeRegistryTests +{ + private static readonly string[] CoreNames = + [ + "blocks", "boolean", "component", "date", "dateTime", "decimal", "email", "integer", "json", "longText", "relation", "richText", "select", "slug", "text", "url", + ]; + + [Fact] + public void CoreListsTheSixteenCoreTypesOrderedByName() + { + FieldTypeRegistry.Core.All.Select(type => type.Name).ShouldBe(CoreNames); + } + + [Fact] + public void FindReturnsTheDescriptorOfAName() + { + foreach (var name in CoreNames) + { + FieldTypeRegistry.Core.Find(name).ShouldNotBeNull().Name.ShouldBe(name); + } + } + + [Theory] + [InlineData("media")] + [InlineData("Text")] + [InlineData("")] + public void FindReturnsNullForAnUnknownName(string name) + { + FieldTypeRegistry.Core.Find(name).ShouldBeNull(); + } + + [Fact] + public void ExtrasJoinTheCoreTypesInNameOrder() + { + var media = new MediaFieldType(); + + var registry = new FieldTypeRegistry([media]); + + registry.All.Count.ShouldBe(17); + registry.All.Select(type => type.Name).ShouldBe([.. CoreNames.Append("media").Order(StringComparer.Ordinal)]); + registry.Find("media").ShouldBeSameAs(media); + FieldTypeRegistry.Core.All.Count.ShouldBe(16); + } + + [Fact] + public void ADuplicateExtraNameIsRejected() + { + var exception = Should.Throw(() => new FieldTypeRegistry([new MediaFieldType(), new MediaFieldType()])); + + exception.Message.ShouldContain("'media'"); + } + + [Fact] + public void AnExtraCannotReplaceACoreType() + { + var exception = Should.Throw(() => new FieldTypeRegistry([new NamedFieldType("text")])); + + exception.Message.ShouldContain("'text'"); + } + + [Fact] + public void ANullExtraIsRejected() + { + Should.Throw(() => new FieldTypeRegistry(null!)); + Should.Throw(() => new FieldTypeRegistry([null!])); + } + + [Fact] + public void AMediaFieldBuildsWhenTheMediaTypeIsRegistered() + { + var registry = new FieldTypeRegistry([new MediaFieldType()]); + + var model = new SchemaBuilder(registry) + .AddContentType
() + .AddContentType() + .AddContentType() + .AddContentType() + .AddContentType() + .Build(); + + var hero = model.FindContentType("article")!.Fields.Single(field => field.Name == "hero"); + hero.FieldType.ShouldBe("media"); + registry.Find(hero.FieldType)!.StorageKind(hero).ShouldBe(FieldStorageKind.Reference); + } + + [Fact] + public void AMediaFieldFailsWithoutTheMediaType() + { + var builder = new SchemaBuilder().AddCollection("article", type => type.Field("hero", "media")); + + var error = Should.Throw(() => builder.Build()).Errors.ShouldHaveSingleItem(); + + error.ToString().ShouldBe("FCMS1203: field type 'media' used by article.hero is not registered"); + } + + [Fact] + public void ABuilderWithoutARegistryUsesTheCoreTypes() + { + var model = new SchemaBuilder().AddCollection("article", type => type.Field("title", "text")).Build(); + + model.FindContentType("article").ShouldNotBeNull(); + } + + [Fact] + public void ABuilderRequiresARegistry() + { + Should.Throw(() => new SchemaBuilder(null!)); + } + + private sealed class NamedFieldType(string name) : IFieldType + { + public string Name { get; } = name; + + public IReadOnlyList ClrTypes => []; + + public IReadOnlySet FilterOperators => new HashSet(); + + public bool Sortable(SchemaField field) => false; + + public bool Indexable(SchemaField field) => false; + + public IReadOnlyList Settings => []; + + public FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Column; + + public bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + result = value; + message = null; + return true; + } + + public System.Text.Json.Nodes.JsonObject OpenApiSchema(SchemaField field) => []; + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/FreeFormFieldType.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/FreeFormFieldType.cs new file mode 100644 index 000000000..ea6fa36b3 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/FreeFormFieldType.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +// A plugin type that keeps free-form options, so it accepts any setting. +public sealed class FreeFormFieldType : IFieldType +{ + public string Name => "freeform"; + + public IReadOnlyList ClrTypes { get; } = [typeof(string)]; + + public IReadOnlySet FilterOperators { get; } = new HashSet(); + + public bool Sortable(SchemaField field) => false; + + public bool Indexable(SchemaField field) => false; + + public IReadOnlyList Settings { get; } = []; + + public bool AcceptsAnySetting => true; + + public FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Column; + + public bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + result = value; + message = null; + return true; + } + + public JsonObject OpenApiSchema(SchemaField field) => []; +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/JsonNodeInputTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/JsonNodeInputTests.cs new file mode 100644 index 000000000..75864b1b9 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/JsonNodeInputTests.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +// A raw value may be a CLR value, a JsonElement or a JsonNode; every type reads all three. +public class JsonNodeInputTests +{ + private static readonly Guid Id = Guid.Parse("0192f2a4-7b1c-7d2e-9f10-2a3b4c5d6e7f"); + + private static readonly string[] NewsAndOpinion = ["news", "opinion"]; + + private static readonly Guid[] OneId = [Id]; + + [Theory] + [InlineData("text")] + [InlineData("longText")] + public void TextReadsAStringNode(string type) + { + TestFields.Coerce(type, JsonValue.Create("hi")).ShouldBe("hi"); + TestFields.Reject(type, JsonNode.Parse("{}")).ShouldBe("expected a string but got an object"); + TestFields.Reject(type, JsonValue.Create(5)).ShouldBe("expected a string but got a number"); + } + + [Fact] + public void ScalarTypesReadValueNodes() + { + TestFields.Coerce("integer", JsonValue.Create(5)).ShouldBe(5L); + TestFields.Coerce("decimal", JsonValue.Create(1.5m)).ShouldBe(1.5m); + TestFields.Coerce("boolean", JsonValue.Create(true)).ShouldBe(true); + TestFields.Coerce("date", JsonValue.Create("2026-11-01")).ShouldBe(new DateOnly(2026, 11, 1)); + TestFields.Coerce("dateTime", JsonValue.Create("2026-11-01T09:12:00Z")).ShouldBe(new DateTimeOffset(2026, 11, 1, 9, 12, 0, TimeSpan.Zero)); + TestFields.Coerce("select", JsonValue.Create("news")).ShouldBe("news"); + TestFields.Coerce("relation", JsonValue.Create(Id.ToString())).ShouldBe(Id); + } + + [Fact] + public void ListsReadArrayNodes() + { + void Configure(IFieldBuilder field) => field.Multiple(); + + TestFields.Coerce("select", JsonNode.Parse("""["news","opinion"]"""), Configure).ShouldBe(NewsAndOpinion); + TestFields.Coerce("relation", JsonNode.Parse($"""["{Id}"]"""), Configure).ShouldBe(OneId); + } + + [Fact] + public void RichTextReadsAnObjectNode() + { + TestFields.Coerce("richText", JsonNode.Parse("""{"type":"doc","content":[]}""")).ShouldBeOfType().Json.ShouldBe("""{"type":"doc","content":[]}"""); + TestFields.Reject("richText", JsonNode.Parse("[]")).ShouldBe("expected a rich text document but got an array"); + } + + [Fact] + public void JsonComponentAndBlocksReadNodes() + { + TestFields.Coerce("json", JsonNode.Parse("""{"a":1}""")).ShouldBeOfType(); + TestFields.Coerce("json", JsonValue.Create("hi")).ShouldBeAssignableTo()!.GetValue().ShouldBe("hi"); + TestFields.Coerce("component", JsonNode.Parse("""{"title":"Hi"}""")).ShouldBeOfType(); + TestFields.Coerce("blocks", JsonNode.Parse("""[{"type":"hero"}]""")).ShouldBeOfType(); + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/MediaFieldType.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/MediaFieldType.cs new file mode 100644 index 000000000..9aa5b9418 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/MediaFieldType.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Media; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +// Stands in for the descriptor the media plugin registers: stored as references, filtered by id. +public sealed class MediaFieldType : IFieldType +{ + public string Name => "media"; + + public IReadOnlyList ClrTypes { get; } = [typeof(MediaRef), typeof(MediaRefList)]; + + public IReadOnlySet FilterOperators { get; } = new HashSet { FilterOperator.Equal, FilterOperator.In }; + + public bool Sortable(SchemaField field) => false; + + public bool Indexable(SchemaField field) => false; + + public IReadOnlyList Settings { get; } = []; + + public FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Reference; + + public bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + result = null; + message = null; + switch (value) + { + case null: + return true; + case Guid id: + result = new MediaRef(id); + return true; + case string text when Guid.TryParse(text, out var parsed): + result = new MediaRef(parsed); + return true; + default: + message = "is not a media id"; + return false; + } + } + + public JsonObject OpenApiSchema(SchemaField field) => new() { ["type"] = "string", ["format"] = "uuid" }; +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/ScalarFieldTypeTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/ScalarFieldTypeTests.cs new file mode 100644 index 000000000..e81a6d030 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/ScalarFieldTypeTests.cs @@ -0,0 +1,244 @@ +using System.Globalization; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class ScalarFieldTypeTests +{ + [Fact] + public void IntegerAcceptsWholeNumbersAsLong() + { + TestFields.Coerce("integer", 5).ShouldBe(5L); + TestFields.Coerce("integer", (short)-3).ShouldBe(-3L); + TestFields.Coerce("integer", long.MaxValue).ShouldBe(long.MaxValue); + TestFields.Coerce("integer", 3.0m).ShouldBe(3L); + TestFields.Coerce("integer", 4.0).ShouldBe(4L); + TestFields.Coerce("integer", "42").ShouldBe(42L); + TestFields.Coerce("integer", "-7").ShouldBe(-7L); + TestFields.Coerce("integer", TestFields.Json("5")).ShouldBe(5L); + TestFields.Coerce("integer", TestFields.Json("5.0")).ShouldBe(5L); + TestFields.Coerce("integer", TestFields.Json("\"8\"")).ShouldBe(8L); + TestFields.Coerce("integer", null).ShouldBeNull(); + } + + [Theory] + [InlineData("3.5")] + [InlineData("abc")] + [InlineData("")] + [InlineData("1,000")] + [InlineData("99999999999999999999")] + public void IntegerRejectsTextThatIsNotAWholeNumber(string text) + { + TestFields.Reject("integer", text).ShouldBe("is not a whole number"); + } + + [Fact] + public void IntegerRejectsOtherValues() + { + TestFields.Reject("integer", 3.5).ShouldBe("is not a whole number that fits in 64 bits"); + TestFields.Reject("integer", 1e30).ShouldBe("is not a whole number that fits in 64 bits"); + TestFields.Reject("integer", ulong.MaxValue).ShouldBe("is not a whole number that fits in 64 bits"); + TestFields.Reject("integer", double.NaN).ShouldBe("is not a whole number that fits in 64 bits"); + TestFields.Reject("integer", true).ShouldBe("expected a whole number but got a boolean"); + TestFields.Reject("integer", TestFields.Json("{}")).ShouldBe("expected a whole number but got an object"); + } + + [Fact] + public void IntegerDoesNotApplyRangeLimits() + { + TestFields.Coerce("integer", 500, field => field.Range(1, 10)).ShouldBe(500L); + } + + [Fact] + public void DecimalAcceptsNumbersAndNormalisesTrailingZeros() + { + TestFields.Coerce("decimal", 12.5m).ShouldBe(12.5m); + TestFields.Coerce("decimal", 12).ShouldBe(12m); + TestFields.Coerce("decimal", 1.5).ShouldBe(1.5m); + TestFields.Coerce("decimal", 1.5f).ShouldBe(1.5m); + TestFields.Coerce("decimal", "12.50").ShouldBe(12.5m); + TestFields.Coerce("decimal", "-0.25").ShouldBe(-0.25m); + TestFields.Coerce("decimal", "1e3").ShouldBe(1000m); + TestFields.Coerce("decimal", TestFields.Json("1.25")).ShouldBe(1.25m); + TestFields.Coerce("decimal", TestFields.Json("7")).ShouldBe(7m); + TestFields.Coerce("decimal", null).ShouldBeNull(); + ((decimal)TestFields.Coerce("decimal", 12.50m)!).ToString(CultureInfo.InvariantCulture).ShouldBe("12.5"); + } + + [Fact] + public void DecimalRejectsOtherValues() + { + TestFields.Reject("decimal", "abc").ShouldBe("is not a number"); + TestFields.Reject("decimal", "1,5").ShouldBe("is not a number"); + TestFields.Reject("decimal", "").ShouldBe("is not a number"); + TestFields.Reject("decimal", double.PositiveInfinity).ShouldBe("is not a number that fits in a decimal"); + TestFields.Reject("decimal", 1e30).ShouldBe("is not a number that fits in a decimal"); + TestFields.Reject("decimal", false).ShouldBe("expected a number but got a boolean"); + } + + [Fact] + public void DecimalHonoursTheScale() + { + TestFields.Coerce("decimal", 1.23m, field => field.Setting("scale", 2)).ShouldBe(1.23m); + TestFields.Coerce("decimal", "1.20", field => field.Setting("scale", 2)).ShouldBe(1.2m); + TestFields.Reject("decimal", 1.234m, field => field.Setting("scale", 2)).ShouldBe("has 3 decimal places, more than the 2 the field allows"); + } + + [Fact] + public void DecimalHonoursThePrecisionAndScaleTogether() + { + void Configure(FluentCMS.Schema.IFieldBuilder field) => field.Setting("precision", 5).Setting("scale", 2); + + TestFields.Coerce("decimal", 123.45m, Configure).ShouldBe(123.45m); + TestFields.Coerce("decimal", -999.99m, Configure).ShouldBe(-999.99m); + TestFields.Coerce("decimal", 0.5m, Configure).ShouldBe(0.5m); + TestFields.Reject("decimal", 1234.5m, Configure).ShouldBe("has more than the 5 significant digits the field allows"); + } + + [Fact] + public void DecimalWithOnlyAPrecisionLimitsTheTotalDigits() + { + void Configure(FluentCMS.Schema.IFieldBuilder field) => field.Setting("precision", 4); + + TestFields.Coerce("decimal", 1234m, Configure).ShouldBe(1234m); + TestFields.Coerce("decimal", 123.4m, Configure).ShouldBe(123.4m); + TestFields.Reject("decimal", 12345m, Configure).ShouldBe("has more than the 4 significant digits the field allows"); + TestFields.Reject("decimal", 123.45m, Configure).ShouldBe("has more than the 4 significant digits the field allows"); + } + + [Fact] + public void DecimalParsingIgnoresTheCurrentCulture() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + + TestFields.Coerce("decimal", "1.5").ShouldBe(1.5m); + TestFields.Reject("decimal", "1,5").ShouldBe("is not a number"); + TestFields.Coerce("integer", "-12").ShouldBe(-12L); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Fact] + public void BooleanAcceptsBooleansAndTheirText() + { + TestFields.Coerce("boolean", true).ShouldBe(true); + TestFields.Coerce("boolean", false).ShouldBe(false); + TestFields.Coerce("boolean", "TRUE").ShouldBe(true); + TestFields.Coerce("boolean", "false").ShouldBe(false); + TestFields.Coerce("boolean", TestFields.Json("true")).ShouldBe(true); + TestFields.Coerce("boolean", TestFields.Json("false")).ShouldBe(false); + TestFields.Coerce("boolean", null).ShouldBeNull(); + } + + [Fact] + public void BooleanRejectsOtherValues() + { + TestFields.Reject("boolean", "yes").ShouldBe("is not true or false"); + TestFields.Reject("boolean", "").ShouldBe("is not true or false"); + TestFields.Reject("boolean", 1).ShouldBe("expected a boolean but got a number"); + TestFields.Reject("boolean", TestFields.Json("0")).ShouldBe("expected a boolean but got a number"); + } + + [Fact] + public void DateAcceptsIsoDates() + { + var day = new DateOnly(2026, 11, 1); + + TestFields.Coerce("date", day).ShouldBe(day); + TestFields.Coerce("date", "2026-11-01").ShouldBe(day); + TestFields.Coerce("date", TestFields.Json("\"2026-11-01\"")).ShouldBe(day); + TestFields.Coerce("date", new DateTime(2026, 11, 1)).ShouldBe(day); + TestFields.Coerce("date", null).ShouldBeNull(); + } + + [Theory] + [InlineData("2026-13-01")] + [InlineData("2026-02-30")] + [InlineData("11/01/2026")] + [InlineData("1 November 2026")] + [InlineData("2026-11-01T10:00:00Z")] + [InlineData("")] + public void DateRejectsOtherText(string text) + { + TestFields.Reject("date", text).ShouldBe("is not a date in the form yyyy-MM-dd"); + } + + [Fact] + public void DateRejectsOtherValues() + { + TestFields.Reject("date", new DateTime(2026, 11, 1, 10, 0, 0)).ShouldBe("has a time of day, but the field holds a date only"); + TestFields.Reject("date", 20261101).ShouldBe("expected a date but got a number"); + } + + [Theory] + [InlineData("2026-11-01T09:12:00Z", "2026-11-01T09:12:00Z")] + [InlineData("2026-11-01T10:12:00+01:00", "2026-11-01T09:12:00Z")] + [InlineData("2026-11-01T04:12:00-05:00", "2026-11-01T09:12:00Z")] + [InlineData("2026-11-01T09:12:00", "2026-11-01T09:12:00Z")] + [InlineData("2026-11-01T09:12", "2026-11-01T09:12:00Z")] + [InlineData("2026-11-01T09:12Z", "2026-11-01T09:12:00Z")] + [InlineData("2026-11-01T09:12:00.1234567Z", "2026-11-01T09:12:00.1234567Z")] + [InlineData("2026-11-01T09:12:00.5+02:00", "2026-11-01T07:12:00.5Z")] + public void DateTimeAcceptsIsoTextAndKeepsAUtcInstant(string text, string expected) + { + var result = TestFields.Coerce("dateTime", text).ShouldBeOfType(); + + result.ShouldBe(DateTimeOffset.Parse(expected, CultureInfo.InvariantCulture)); + result.Offset.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public void DateTimeAcceptsTypedValuesAndJson() + { + var instant = new DateTimeOffset(2026, 11, 1, 9, 12, 0, TimeSpan.Zero); + + TestFields.Coerce("dateTime", instant).ShouldBe(instant); + TestFields.Coerce("dateTime", new DateTimeOffset(2026, 11, 1, 10, 12, 0, TimeSpan.FromHours(1))).ShouldBe(instant); + TestFields.Coerce("dateTime", new DateTime(2026, 11, 1, 9, 12, 0, DateTimeKind.Utc)).ShouldBe(instant); + TestFields.Coerce("dateTime", new DateTime(2026, 11, 1, 9, 12, 0, DateTimeKind.Unspecified)).ShouldBe(instant); + TestFields.Coerce("dateTime", TestFields.Json("\"2026-11-01T09:12:00Z\"")).ShouldBe(instant); + TestFields.Coerce("dateTime", null).ShouldBeNull(); + } + + [Theory] + [InlineData("2026-11-01")] + [InlineData("yesterday")] + [InlineData("2026-11-01 09:12:00")] + [InlineData("11/01/2026 09:12")] + [InlineData("")] + public void DateTimeRejectsOtherText(string text) + { + TestFields.Reject("dateTime", text).ShouldBe("is not an ISO 8601 date and time such as 2026-11-01T09:12:00Z"); + } + + [Fact] + public void DateTimeRejectsOtherValues() + { + TestFields.Reject("dateTime", 1762000000).ShouldBe("expected a date and time but got a number"); + TestFields.Reject("dateTime", new DateOnly(2026, 11, 1)).ShouldBe("expected a date and time but got a value of type DateOnly"); + } + + [Fact] + public void DateTimeParsingIgnoresTheCurrentCulture() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("ar-SA"); + + TestFields.Coerce("dateTime", "2026-11-01T09:12:00Z").ShouldBe(new DateTimeOffset(2026, 11, 1, 9, 12, 0, TimeSpan.Zero)); + TestFields.Coerce("date", "2026-11-01").ShouldBe(new DateOnly(2026, 11, 1)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/SettingsValidationTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/SettingsValidationTests.cs new file mode 100644 index 000000000..124603cc2 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/SettingsValidationTests.cs @@ -0,0 +1,281 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class SettingsValidationTests +{ + private static readonly string[] Layers = ["a", "b"]; + + private static SchemaError[] Errors(SchemaBuilder builder) => + Should.Throw(() => builder.Build()).Errors.ToArray(); + + private static SchemaBuilder FromJson(string fieldJson) => + new SchemaBuilder().AddSchemaJson($$"""{"collections":[{"name":"article","fields":[{{fieldJson}}]}]}"""); + + [Fact] + public void ALowerCasedKeyInSchemaJsonIsReported() + { + var error = Errors(FromJson("""{"name":"title","type":"text","maxlength":10}""")).ShouldHaveSingleItem(); + + error.Code.ShouldBe("FCMS1211"); + error.Message.ShouldBe("text field article.title has an unknown setting 'maxlength'; it accepts none"); + } + + [Fact] + public void AMisspeltFlagInSchemaJsonIsReported() + { + var error = Errors(FromJson("""{"name":"title","type":"text","requird":true}""")).ShouldHaveSingleItem(); + + error.Code.ShouldBe("FCMS1211"); + error.Message.ShouldContain("'requird'"); + } + + [Fact] + public void EveryUnknownKeyIsReportedInKeyOrder() + { + var errors = Errors(FromJson("""{"name":"title","type":"text","requird":true,"maxlength":10}""")); + + errors.Select(error => error.Code).ShouldBe(["FCMS1211", "FCMS1211"]); + errors[0].Message.ShouldContain("'maxlength'"); + errors[1].Message.ShouldContain("'requird'"); + } + + [Fact] + public void TheMessageListsWhatTheTypeAccepts() + { + var error = Errors(FromJson("""{"name":"price","type":"decimal","presision":5}""")).ShouldHaveSingleItem(); + + error.Message.ShouldBe("decimal field article.price has an unknown setting 'presision'; it accepts precision, scale"); + } + + [Fact] + public void TheKnownKeysAndRealPropertiesAreAccepted() + { + var model = FromJson("""{"name":"title","type":"text","maxLength":10,"required":true}""").Build(); + + model.FindContentType("article")!.Fields.Single().MaxLength.ShouldBe(10); + } + + [Fact] + public void AnUnknownKeyOnAFluentFieldIsReported() + { + var builder = new SchemaBuilder().AddCollection("article", type => type.Field("title", "text", field => field.Setting("colour", "red"))); + + Errors(builder).ShouldHaveSingleItem().Message.ShouldContain("unknown setting 'colour'"); + } + + [Fact] + public void AValueOfTheWrongKindIsReported() + { + var builder = new SchemaBuilder().AddCollection("article", type => type + .Field("price", "decimal", field => field.Setting(FieldSettings.Precision, "five")) + .Field("slug", "slug", field => field.Setting(FieldSettings.Source, 5)) + .Field("title", "text")); + + var errors = Errors(builder); + + errors.Select(error => error.Code).ShouldBe(["FCMS1211", "FCMS1211"]); + errors[0].Message.ShouldBe("setting 'precision' of decimal field article.price must be a whole number"); + errors[1].Message.ShouldBe("setting 'source' of slug field article.slug must be a string"); + } + + [Fact] + public void AListSettingMustBeAList() + { + var builder = new SchemaBuilder().AddCollection("article", type => type + .Field("category", "select", field => field.Setting(FieldSettings.Options, "news"))); + + var error = Errors(builder).ShouldHaveSingleItem(); + + error.Code.ShouldBe("FCMS1211"); + error.Message.ShouldBe("setting 'options' of select field article.category must be a list of strings"); + } + + [Fact] + public void AComponentOrBlocksSettingOfTheWrongKindIsReportedOnce() + { + var builder = new SchemaBuilder().AddCollection("article", type => type + .Field("seo", "component", field => field.Setting(FieldSettings.Component, 5)) + .Field("sections", "blocks", field => field.Setting(FieldSettings.Blocks, "hero"))); + + var errors = Errors(builder); + + errors.Select(error => error.Message).ShouldBe( + [ + "setting 'component' of component field article.seo must be a string", + "setting 'blocks' of blocks field article.sections must be a list of strings", + ]); + } + + [Theory] + [InlineData(0, null, "precision of 0; it must be between 1 and 28")] + [InlineData(-1, null, "precision of -1; it must be between 1 and 28")] + [InlineData(29, null, "precision of 29; it must be between 1 and 28")] + [InlineData(null, -1, "scale of -1; it must be between 0 and 28")] + [InlineData(null, 29, "scale of 29; it must be between 0 and 28")] + [InlineData(2, 3, "scale of 3 above its precision of 2")] + public void DecimalPrecisionAndScaleOutOfRangeAreReported(int? precision, int? scale, string messagePart) + { + var builder = new SchemaBuilder().AddCollection("article", type => type.Field("price", "decimal", field => Decimal(field, precision, scale))); + + var error = Errors(builder).ShouldHaveSingleItem(); + + error.Code.ShouldBe("FCMS1211"); + error.Message.ShouldBe($"decimal field article.price has a {messagePart}"); + } + + [Theory] + [InlineData(1, 0)] + [InlineData(5, 2)] + [InlineData(5, 5)] + [InlineData(28, 28)] + [InlineData(28, null)] + [InlineData(null, 3)] + [InlineData(null, 0)] + public void DecimalPrecisionAndScaleInRangeBuild(int? precision, int? scale) + { + var builder = new SchemaBuilder().AddCollection("article", type => type.Field("price", "decimal", field => Decimal(field, precision, scale))); + + builder.Build().FindContentType("article").ShouldNotBeNull(); + } + + private static void Decimal(IFieldBuilder field, int? precision, int? scale) + { + if (precision is { } digits) + { + field.Setting(FieldSettings.Precision, digits); + } + + if (scale is { } places) + { + field.Setting(FieldSettings.Scale, places); + } + } + + [Fact] + public void AMissingRequiredKeyIsReportedWithTheTypeFieldAndKey() + { + var registry = new FieldTypeRegistry([new PluginFieldType("map", [new FieldSettingDescriptor { Key = "provider", Kind = FieldSettingKind.String, Required = true }])]); + var builder = new SchemaBuilder(registry).AddCollection("place", type => type.Field("location", "map")); + + var error = Errors(builder).ShouldHaveSingleItem(); + + error.Code.ShouldBe("FCMS1211"); + error.Message.ShouldBe("map field place.location is missing the required setting 'provider'"); + } + + [Fact] + public void ARequiredKeyThatIsSetBuilds() + { + var registry = new FieldTypeRegistry([new PluginFieldType("map", [new FieldSettingDescriptor { Key = "provider", Kind = FieldSettingKind.String, Required = true }, new FieldSettingDescriptor { Key = "zoom", Kind = FieldSettingKind.Integer }])]); + var builder = new SchemaBuilder(registry).AddCollection("place", type => type.Field("location", "map", field => field.Setting("provider", "osm").Setting("zoom", 12))); + + builder.Build().FindContentType("place")!.Fields.Single().Settings["provider"].ShouldBe("osm"); + } + + [Fact] + public void AKindOfTheDecimalSettingAcceptsWholeAndFractionalNumbers() + { + var registry = new FieldTypeRegistry([new PluginFieldType("ratio", [new FieldSettingDescriptor { Key = "step", Kind = FieldSettingKind.Decimal }, new FieldSettingDescriptor { Key = "live", Kind = FieldSettingKind.Boolean }])]); + + new SchemaBuilder(registry).AddCollection("a", type => type.Field("x", "ratio", field => field.Setting("step", 1).Setting("live", true))).Build(); + new SchemaBuilder(registry).AddCollection("a", type => type.Field("x", "ratio", field => field.Setting("step", 0.5m))).Build(); + Errors(new SchemaBuilder(registry).AddCollection("a", type => type.Field("x", "ratio", field => field.Setting("live", "yes")))) + .ShouldHaveSingleItem().Message.ShouldBe("setting 'live' of ratio field a.x must be true or false"); + } + + [Fact] + public void ATypeWithoutDeclaredSettingsRejectsAnyKeyUnlessItAcceptsAny() + { + var strict = new FieldTypeRegistry([new PluginFieldType("map", [])]); + var open = new FieldTypeRegistry([new PluginFieldType("map", [], acceptsAnySetting: true)]); + + Errors(new SchemaBuilder(strict).AddCollection("place", type => type.Field("location", "map", field => field.Setting("zoom", 12)))) + .ShouldHaveSingleItem().Message.ShouldBe("map field place.location has an unknown setting 'zoom'; it accepts none"); + + var model = new SchemaBuilder(open).AddCollection("place", type => type.Field("location", "map", field => field.Setting("zoom", 12).Setting("layers", Layers))).Build(); + model.FindContentType("place")!.Fields.Single().Settings.Keys.ShouldBe(["layers", "zoom"]); + } + + [Fact] + public void ATypeThatAcceptsAnySettingStillChecksTheRequiredOnes() + { + var registry = new FieldTypeRegistry([new PluginFieldType("map", [new FieldSettingDescriptor { Key = "provider", Kind = FieldSettingKind.String, Required = true }], acceptsAnySetting: true)]); + + Errors(new SchemaBuilder(registry).AddCollection("place", type => type.Field("location", "map", field => field.Setting("zoom", 12)))) + .ShouldHaveSingleItem().Message.ShouldContain("missing the required setting 'provider'"); + } + + [Fact] + public void ATypeThatAcceptsAnySettingStillChecksTheKindOfTheDeclaredOnes() + { + var registry = new FieldTypeRegistry([new PluginFieldType("map", [new FieldSettingDescriptor { Key = "provider", Kind = FieldSettingKind.String, Required = true }], acceptsAnySetting: true)]); + var builder = new SchemaBuilder(registry).AddCollection("place", type => type.Field("location", "map", field => field.Setting("provider", 5).Setting("zoom", 12))); + + var error = Errors(builder).ShouldHaveSingleItem(); + + error.Code.ShouldBe("FCMS1211"); + error.Message.ShouldBe("setting 'provider' of map field place.location must be a string"); + } + + [Fact] + public void AnUnregisteredTypeIsReportedOnceWithoutSettingErrors() + { + var builder = new SchemaBuilder().AddCollection("article", type => type.Field("hero", "media", field => field.Setting("anything", 1))); + + Errors(builder).ShouldHaveSingleItem().Code.ShouldBe("FCMS1203"); + } + + [Fact] + public void AMediaTypeThatDeclaresNoSettingsRejectsOne() + { + var builder = new SchemaBuilder(new FieldTypeRegistry([new MediaFieldType()])) + .AddCollection("article", type => type.Field("hero", "media", field => field.Setting("folder", "heroes"))); + + Errors(builder).ShouldHaveSingleItem().Message.ShouldBe("media field article.hero has an unknown setting 'folder'; it accepts none"); + } + + [Fact] + public void TheTypeSpecificChecksAreNotReportedTwice() + { + var builder = new SchemaBuilder().AddCollection("article", type => type + .Field("category", "select") + .Field("seo", "component") + .Field("sections", "blocks")); + + Errors(builder).Select(error => error.Code).ShouldBe(["FCMS1211", "FCMS1211", "FCMS1211"]); + } + + private sealed class PluginFieldType(string name, FieldSettingDescriptor[] settings, bool acceptsAnySetting = false) : IFieldType + { + public string Name { get; } = name; + + public IReadOnlyList ClrTypes => [typeof(string)]; + + public IReadOnlySet FilterOperators => new HashSet(); + + public bool Sortable(SchemaField field) => false; + + public bool Indexable(SchemaField field) => false; + + public IReadOnlyList Settings { get; } = settings; + + public bool AcceptsAnySetting { get; } = acceptsAnySetting; + + public FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Column; + + public bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + result = value; + message = null; + return true; + } + + public JsonObject OpenApiSchema(SchemaField field) => []; + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/SlugifyTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/SlugifyTests.cs new file mode 100644 index 000000000..d49780b32 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/SlugifyTests.cs @@ -0,0 +1,72 @@ +using FluentCMS.Core.Schema.FieldTypes; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class SlugifyTests +{ + [Theory] + [InlineData("Hello World", "hello-world")] + [InlineData(" Hello World ", "hello-world")] + [InlineData("Crème Brûlée", "creme-brulee")] + [InlineData("Ünïcödé", "unicode")] + [InlineData("Straße", "strasse")] + [InlineData("Ørsted Æsir", "orsted-aesir")] + [InlineData("Hello, World! It's 2026.", "hello-world-it-s-2026")] + [InlineData("a---b___c", "a-b-c")] + [InlineData("--leading and trailing--", "leading-and-trailing")] + [InlineData("Already-a-slug", "already-a-slug")] + [InlineData("école", "ecole")] + public void NormalisesTextToASlug(string source, string expected) + { + var slug = SlugFieldType.Slugify(source); + + slug.ShouldBe(expected); + TestFields.Coerce("slug", slug).ShouldBe(expected); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("!!!")] + [InlineData("你好")] + public void ASourceWithoutLettersOrDigitsGivesAnEmptySlug(string source) + { + SlugFieldType.Slugify(source).ShouldBe(""); + } + + [Theory] + [InlineData("hello-world", 5, "hello")] + [InlineData("hello-world", 6, "hello")] + [InlineData("hello-world", 7, "hello-w")] + [InlineData("hello-world", 11, "hello-world")] + [InlineData("hello-world", 50, "hello-world")] + [InlineData("hello-world", 0, "")] + public void TheMaximumLengthBoundsTheSlugWithoutATrailingHyphen(string source, int maxLength, string expected) + { + SlugFieldType.Slugify(source, maxLength).ShouldBe(expected); + } + + [Fact] + public void ANullSourceIsRejected() + { + Should.Throw(() => SlugFieldType.Slugify(null!)); + } + + [Fact] + public void TheResultDoesNotDependOnTheCurrentCulture() + { + var previous = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("tr-TR"); + + SlugFieldType.Slugify("TITLE Is Ice").ShouldBe("title-is-ice"); + } + finally + { + Thread.CurrentThread.CurrentCulture = previous; + } + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/StringFieldTypeTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/StringFieldTypeTests.cs new file mode 100644 index 000000000..e0e398e4f --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/StringFieldTypeTests.cs @@ -0,0 +1,116 @@ +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class StringFieldTypeTests +{ + [Theory] + [InlineData("text")] + [InlineData("longText")] + public void TextAcceptsStrings(string type) + { + TestFields.Coerce(type, "hello").ShouldBe("hello"); + TestFields.Coerce(type, TestFields.Json("\"hello\"")).ShouldBe("hello"); + TestFields.Coerce(type, 'c').ShouldBe("c"); + TestFields.Coerce(type, "").ShouldBe(""); + TestFields.Coerce(type, null).ShouldBeNull(); + TestFields.Coerce(type, TestFields.Json("null")).ShouldBeNull(); + } + + [Theory] + [InlineData("text")] + [InlineData("longText")] + [InlineData("email")] + [InlineData("url")] + [InlineData("slug")] + public void StringTypesRejectOtherKinds(string type) + { + TestFields.Reject(type, 5).ShouldBe("expected a string but got a number"); + TestFields.Reject(type, true).ShouldBe("expected a string but got a boolean"); + TestFields.Reject(type, TestFields.Json("12")).ShouldBe("expected a string but got a number"); + TestFields.Reject(type, TestFields.Json("{}")).ShouldBe("expected a string but got an object"); + TestFields.Reject(type, TestFields.Json("[\"a\"]")).ShouldBe("expected a string but got an array"); + } + + [Fact] + public void TextDoesNotApplyLengthLimits() + { + TestFields.Coerce("text", "far too long", field => field.Length(null, 3)).ShouldBe("far too long"); + } + + [Theory] + [InlineData("a@b.com")] + [InlineData("first.last+tag@example.co.uk")] + [InlineData("User@Example.COM")] + public void EmailAcceptsAddresses(string address) + { + TestFields.Coerce("email", address).ShouldBe(address); + } + + [Theory] + [InlineData("not-an-email")] + [InlineData("a@")] + [InlineData("@b.com")] + [InlineData("Name ")] + [InlineData("a b@c.com")] + [InlineData(" a@b.com")] + [InlineData("a@b.com ")] + [InlineData("")] + public void EmailRejectsOtherText(string text) + { + TestFields.Reject("email", text).ShouldBe("is not a valid email address"); + } + + [Theory] + [InlineData("https://example.com/a?b=1")] + [InlineData("http://localhost:5000")] + [InlineData("HTTPS://Example.com")] + public void UrlAcceptsAbsoluteHttpAndHttps(string text) + { + TestFields.Coerce("url", text).ShouldBe(text); + } + + [Fact] + public void UrlAcceptsAUriAndReturnsItsText() + { + TestFields.Coerce("url", new Uri("https://example.com/a")).ShouldBe("https://example.com/a"); + } + + [Theory] + [InlineData("example.com")] + [InlineData("/relative/path")] + [InlineData("ftp://example.com")] + [InlineData("javascript:alert(1)")] + [InlineData("mailto:a@b.com")] + [InlineData("https://")] + [InlineData("")] + public void UrlRejectsOtherText(string text) + { + TestFields.Reject("url", text).ShouldBe("is not an absolute http or https URL"); + } + + [Theory] + [InlineData("hello")] + [InlineData("hello-world")] + [InlineData("a1-b2-c3")] + [InlineData("2026")] + public void SlugAcceptsLowerCaseWordsWithSingleHyphens(string text) + { + TestFields.Coerce("slug", text).ShouldBe(text); + } + + [Theory] + [InlineData("Hello")] + [InlineData("hello--world")] + [InlineData("-hello")] + [InlineData("hello-")] + [InlineData("hello world")] + [InlineData("hello_world")] + [InlineData("héllo")] + [InlineData("")] + public void SlugRejectsOtherText(string text) + { + TestFields.Reject("slug", text).ShouldStartWith("is not a slug"); + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/StructuredFieldTypeTests.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/StructuredFieldTypeTests.cs new file mode 100644 index 000000000..17ae2c577 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/StructuredFieldTypeTests.cs @@ -0,0 +1,272 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +public class StructuredFieldTypeTests +{ + private enum Mood + { + Happy, + VerySad, + } + + private static readonly string[] TwoOptions = ["news", "opinion"]; + + private static readonly string[] NewsAndVerySad = ["news", "verySad"]; + + private static readonly string[] NewsAndOutside = ["news", "outside"]; + + private const string EmptyDocument = """{"type":"doc","content":[{"type":"paragraph"}]}"""; + + [Fact] + public void SelectAcceptsAStringWithoutCheckingTheOptions() + { + TestFields.Coerce("select", "news").ShouldBe("news"); + TestFields.Coerce("select", "not-an-option").ShouldBe("not-an-option"); + TestFields.Coerce("select", TestFields.Json("\"opinion\"")).ShouldBe("opinion"); + TestFields.Coerce("select", null).ShouldBeNull(); + } + + [Fact] + public void SelectNamesAnEnumMemberInCamelCase() + { + TestFields.Coerce("select", Mood.Happy).ShouldBe("happy"); + TestFields.Coerce("select", Mood.VerySad).ShouldBe("verySad"); + } + + [Fact] + public void SelectRejectsOtherValues() + { + TestFields.Reject("select", 5).ShouldBe("expected a string but got a number"); + TestFields.Reject("select", TwoOptions).ShouldBe("expected a string but got a value of type String[]"); + TestFields.Reject("select", TestFields.Json("[\"news\"]")).ShouldBe("expected a string but got an array"); + } + + [Fact] + public void MultiSelectAcceptsAListOfStrings() + { + void Configure(IFieldBuilder field) => field.Multiple(); + + TestFields.Coerce("select", TwoOptions, Configure).ShouldBe(TwoOptions); + TestFields.Coerce("select", new List { "news", Mood.VerySad }, Configure).ShouldBe(NewsAndVerySad); + TestFields.Coerce("select", TestFields.Json("[\"news\",\"outside\"]"), Configure).ShouldBe(NewsAndOutside); + TestFields.Coerce("select", TestFields.Json("[]"), Configure).ShouldBe(Array.Empty()); + TestFields.Coerce("select", null, Configure).ShouldBeNull(); + } + + [Fact] + public void MultiSelectRejectsOtherValues() + { + void Configure(IFieldBuilder field) => field.Multiple(); + + TestFields.Reject("select", "news", Configure).ShouldBe("expected a list of strings but got a string"); + TestFields.Reject("select", TestFields.Json("{}"), Configure).ShouldBe("expected a list of strings but got an object"); + TestFields.Reject("select", 5, Configure).ShouldBe("expected a list of strings but got a number"); + TestFields.Reject("select", TestFields.Json("[\"news\",1]"), Configure).ShouldBe("item 1 is not a string: it is a number"); + TestFields.Reject("select", new object?[] { "news", null }, Configure).ShouldBe("item 1 is not a string: it is null"); + } + + [Fact] + public void JsonAcceptsAnyJsonValue() + { + var element = TestFields.Coerce("json", TestFields.Json("""{"a":[1,2],"b":{"c":true}}""")).ShouldBeAssignableTo()!; + element.ToJsonString().ShouldBe("""{"a":[1,2],"b":{"c":true}}"""); + + TestFields.Coerce("json", """{"a":1}""").ShouldBeAssignableTo()!["a"]!.GetValue().ShouldBe(1); + TestFields.Coerce("json", "[1,2,3]").ShouldBeAssignableTo()!.Count.ShouldBe(3); + TestFields.Coerce("json", TestFields.Json("\"text\"")).ShouldBeAssignableTo()!.GetValue().ShouldBe("text"); + TestFields.Coerce("json", TestFields.Json("12")).ShouldBeAssignableTo()!.GetValue().ShouldBe(12); + TestFields.Coerce("json", true).ShouldBeAssignableTo()!.GetValue().ShouldBeTrue(); + TestFields.Coerce("json", 12.5m).ShouldBeAssignableTo()!.GetValue().ShouldBe(12.5m); + TestFields.Coerce("json", null).ShouldBeNull(); + TestFields.Coerce("json", TestFields.Json("null")).ShouldBeNull(); + } + + [Fact] + public void JsonCopiesANodeSoTheCallerKeepsItsOwn() + { + var node = JsonNode.Parse("""{"a":1}""")!; + + var result = TestFields.Coerce("json", node).ShouldBeAssignableTo()!; + + result.ShouldNotBeSameAs(node); + result.ToJsonString().ShouldBe(node.ToJsonString()); + } + + [Theory] + [InlineData("not json")] + [InlineData("{\"a\":")] + [InlineData("")] + public void JsonRejectsTextThatIsNotJson(string text) + { + TestFields.Reject("json", text).ShouldStartWith("is not valid JSON"); + } + + [Fact] + public void JsonRejectsOtherObjects() + { + TestFields.Reject("json", new object()).ShouldBe("expected JSON but got a value of type Object"); + } + + [Fact] + public void RichTextAcceptsADocumentInAnyForm() + { + TestFields.Coerce("richText", EmptyDocument).ShouldBeOfType().Json.ShouldBe(EmptyDocument); + TestFields.Coerce("richText", RichText.Empty).ShouldBeSameAs(RichText.Empty); + TestFields.Coerce("richText", TestFields.Json(EmptyDocument)).ShouldBeOfType().Json.ShouldBe(EmptyDocument); + TestFields.Coerce("richText", """{"type":"doc"}""").ShouldBeOfType(); + TestFields.Coerce("richText", null).ShouldBeNull(); + } + + [Theory] + [InlineData("not json")] + [InlineData("{}")] + [InlineData("""{"type":"paragraph"}""")] + [InlineData("""{"type":"doc","content":{}}""")] + [InlineData("""[{"type":"doc"}]""")] + public void RichTextRejectsOtherText(string text) + { + TestFields.Reject("richText", text).ShouldStartWith("is not "); + } + + [Fact] + public void RichTextRejectsOtherValues() + { + TestFields.Reject("richText", 5).ShouldBe("expected a rich text document but got a number"); + TestFields.Reject("richText", TestFields.Json("[]")).ShouldBe("expected a rich text document but got an array"); + TestFields.Reject("richText", new RichText("{}")).ShouldStartWith("is not a rich text document"); + } + + [Fact] + public void RelationAcceptsAnEntryId() + { + var id = Guid.Parse("0192f2a4-7b1c-7d2e-9f10-2a3b4c5d6e7f"); + + TestFields.Coerce("relation", id).ShouldBe(id); + TestFields.Coerce("relation", id.ToString()).ShouldBe(id); + TestFields.Coerce("relation", TestFields.Json($"\"{id}\"")).ShouldBe(id); + TestFields.Coerce("relation", new Ref(id)).ShouldBe(id); + TestFields.Coerce("relation", null).ShouldBeNull(); + } + + [Fact] + public void AnEmptyReferenceMeansNoRelation() + { + TestFields.Coerce("relation", default(Ref)).ShouldBeNull(); + TestFields.Coerce("relation", Guid.Empty).ShouldBeNull(); + } + + [Theory] + [InlineData("not-a-guid")] + [InlineData("")] + [InlineData("0192f2a47b1c7d2e9f102a3b4c5d6e7f")] + public void RelationRejectsTextThatIsNotAnId(string text) + { + TestFields.Reject("relation", text).ShouldBe("is not an entry id: expected a GUID"); + } + + [Fact] + public void RelationRejectsOtherValues() + { + TestFields.Reject("relation", 5).ShouldBe("is not an entry id: expected a GUID"); + TestFields.Reject("relation", TestFields.Json("[\"0192f2a4-7b1c-7d2e-9f10-2a3b4c5d6e7f\"]")).ShouldBe("is not an entry id: expected a GUID"); + } + + [Fact] + public void ToManyRelationKeepsTheOrder() + { + void Configure(IFieldBuilder field) => field.Multiple(); + Guid[] ids = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + TestFields.Coerce("relation", ids, Configure).ShouldBe(ids); + TestFields.Coerce("relation", ids.Select(id => id.ToString()).ToList(), Configure).ShouldBe(ids); + TestFields.Coerce("relation", new RefList([.. ids.Select(id => new Ref(id))]), Configure).ShouldBe(ids); + TestFields.Coerce("relation", TestFields.Json($"[\"{ids[2]}\",\"{ids[0]}\"]"), Configure).ShouldBe(new Guid[] { ids[2], ids[0] }); + TestFields.Coerce("relation", TestFields.Json("[]"), Configure).ShouldBe(Array.Empty()); + TestFields.Coerce("relation", null, Configure).ShouldBeNull(); + } + + [Fact] + public void ToManyRelationRejectsOtherValues() + { + void Configure(IFieldBuilder field) => field.Multiple(); + + TestFields.Reject("relation", Guid.NewGuid().ToString(), Configure).ShouldBe("expected a list of entry ids but got a string"); + TestFields.Reject("relation", 5, Configure).ShouldBe("expected a list of entry ids but got a number"); + TestFields.Reject("relation", TestFields.Json("""["0192f2a4-7b1c-7d2e-9f10-2a3b4c5d6e7f","x"]"""), Configure).ShouldBe("item 1 is not an entry id: expected a GUID"); + TestFields.Reject("relation", new[] { Guid.Empty }, Configure).ShouldBe("item 0 is not an entry id: expected a GUID"); + } + + [Fact] + public void ASingleComponentIsAnObject() + { + TestFields.Coerce("component", TestFields.Json("""{"title":"Hi"}""")).ShouldBeOfType()["title"]!.GetValue().ShouldBe("Hi"); + TestFields.Coerce("component", """{"title":"Hi"}""").ShouldBeOfType(); + TestFields.Coerce("component", TestFields.Json("{}")).ShouldBeOfType(); + TestFields.Coerce("component", null).ShouldBeNull(); + } + + [Fact] + public void ARepeatableComponentIsAListOfObjects() + { + void Configure(IFieldBuilder field) => field.Multiple(); + + TestFields.Coerce("component", TestFields.Json("""[{"title":"A"},{"title":"B"}]"""), Configure).ShouldBeOfType().Count.ShouldBe(2); + TestFields.Coerce("component", TestFields.Json("[]"), Configure).ShouldBeOfType().Count.ShouldBe(0); + TestFields.Coerce("component", null, Configure).ShouldBeNull(); + } + + [Fact] + public void ComponentRejectsTheWrongShape() + { + void Repeatable(IFieldBuilder field) => field.Multiple(); + + TestFields.Reject("component", TestFields.Json("[{}]")).ShouldBe("is not an object"); + TestFields.Reject("component", TestFields.Json("5")).ShouldBe("is not an object"); + TestFields.Reject("component", "not json").ShouldStartWith("is not valid JSON"); + TestFields.Reject("component", new object()).ShouldBe("expected JSON but got a value of type Object"); + TestFields.Reject("component", TestFields.Json("{}"), Repeatable).ShouldBe("is not a list of objects"); + TestFields.Reject("component", TestFields.Json("[1]"), Repeatable).ShouldBe("is not a list of objects"); + } + + [Fact] + public void BlocksAcceptAListOfBlocksThatNameTheirType() + { + var node = TestFields.Coerce("blocks", TestFields.Json("""[{"type":"hero","heading":"Hi"},{"type":"hero"}]""")).ShouldBeOfType(); + node.Count.ShouldBe(2); + + TestFields.Coerce("blocks", """[{"type":"hero"}]""").ShouldBeOfType(); + TestFields.Coerce("blocks", TestFields.Json("[]")).ShouldBeOfType(); + TestFields.Coerce("blocks", null).ShouldBeNull(); + } + + [Fact] + public void BlocksKeepAnAlreadyTypedBlockList() + { + BlockList blocks = [new object()]; + + TestFields.Coerce("blocks", blocks).ShouldBeSameAs(blocks); + } + + [Fact] + public void BlocksDoNotCheckWhetherTheBlockIsAccepted() + { + TestFields.Coerce("blocks", TestFields.Json("""[{"type":"other"}]""")).ShouldBeOfType(); + } + + [Theory] + [InlineData("{}", "is not a list of blocks")] + [InlineData("5", "is not a list of blocks")] + [InlineData("[1]", "block 0 is not an object with a \"type\" naming its block")] + [InlineData("[{}]", "block 0 is not an object with a \"type\" naming its block")] + [InlineData("""[{"type":"hero"},{"type":""}]""", "block 1 is not an object with a \"type\" naming its block")] + [InlineData("""[{"type":5}]""", "block 0 is not an object with a \"type\" naming its block")] + public void BlocksRejectTheWrongShape(string json, string message) + { + TestFields.Reject("blocks", TestFields.Json(json)).ShouldBe(message); + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/FieldTypes/TestFields.cs b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/TestFields.cs new file mode 100644 index 000000000..0b592d576 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Schema/FieldTypes/TestFields.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Schema; +using Shouldly; + +namespace FluentCMS.Core.Tests.Schema.FieldTypes; + +internal static class TestFields +{ + private static readonly string[] SelectOptions = ["news", "opinion"]; + + private static readonly string[] BlockNames = ["hero"]; + + public static IFieldType Type(string name) => FieldTypeRegistry.Core.Find(name) ?? throw new InvalidOperationException($"no field type {name}"); + + // Builds a field of the given type through the schema builder, with the settings its type needs to be valid. + public static SchemaField Field(string typeName, Action? configure = null) + { + var model = new SchemaBuilder() + .AddComponent("seo", type => type.Field("title", "text")) + .AddBlock("hero", type => type.Field("heading", "text")) + .AddCollection("author", type => type.Field("name", "text")) + .AddCollection("sample", type => type.Field("subject", typeName, field => + { + Defaults(typeName, field); + configure?.Invoke(field); + })) + .Build(); + + return model.FindContentType("sample")!.Fields[0]; + } + + public static object? Coerce(string typeName, object? value, Action? configure = null) + { + var field = Field(typeName, configure); + var accepted = Type(typeName).TryCoerce(value, field, out var result, out var message); + + accepted.ShouldBeTrue(message); + message.ShouldBeNull(); + return result; + } + + public static string Reject(string typeName, object? value, Action? configure = null) + { + var field = Field(typeName, configure); + var accepted = Type(typeName).TryCoerce(value, field, out var result, out var message); + + accepted.ShouldBeFalse($"{typeName} accepted {value}"); + result.ShouldBeNull(); + return message.ShouldNotBeNull(); + } + + public static JsonElement Json(string text) + { + using var document = JsonDocument.Parse(text); + return document.RootElement.Clone(); + } + + private static void Defaults(string typeName, IFieldBuilder field) + { + switch (typeName) + { + case "select": + field.Setting(FieldSettings.Options, SelectOptions); + break; + case "component": + field.Setting(FieldSettings.Component, "seo"); + break; + case "blocks": + field.Setting(FieldSettings.Blocks, BlockNames); + break; + case "relation": + field.Relation("author"); + break; + } + } +} diff --git a/tests/FluentCMS.Core.Tests/Schema/SampleSchema.cs b/tests/FluentCMS.Core.Tests/Schema/SampleSchema.cs index e176dec55..f1dc69ca3 100644 --- a/tests/FluentCMS.Core.Tests/Schema/SampleSchema.cs +++ b/tests/FluentCMS.Core.Tests/Schema/SampleSchema.cs @@ -1,5 +1,7 @@ using System.Reflection; using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Tests.Schema.FieldTypes; using FluentCMS.Core.Tests.Schema.Samples; using FluentCMS.Schema; @@ -9,8 +11,8 @@ internal static class SampleSchema { public static string FilePath { get; } = Path.Combine(AppContext.BaseDirectory, "Schema", "Samples", "blog.schema.json"); - // The media plugin registers the media field type; here the builder is told it is there. - public static SchemaBuilder NewBuilder() => new(["media"]); + // The media plugin registers the media field type; here a stand-in descriptor is registered in its place. + public static SchemaBuilder NewBuilder() => new(new FieldTypeRegistry([new MediaFieldType()])); public static SchemaBuilder FromClasses() => NewBuilder() .AddContentType
() diff --git a/tests/FluentCMS.Core.Tests/Schema/SchemaJsonTests.cs b/tests/FluentCMS.Core.Tests/Schema/SchemaJsonTests.cs index bec9367ef..f2b0b5a16 100644 --- a/tests/FluentCMS.Core.Tests/Schema/SchemaJsonTests.cs +++ b/tests/FluentCMS.Core.Tests/Schema/SchemaJsonTests.cs @@ -1,4 +1,6 @@ using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; +using FluentCMS.Core.Tests.Schema.FieldTypes; using FluentCMS.Schema; using Shouldly; using Xunit; @@ -102,11 +104,11 @@ public void UnknownFieldPropertiesBecomeSettingsOfTheFieldType() { const string json = """ { "collections": [ { "name": "article", "fields": [ - { "name": "hero", "type": "text", "accepts": ["image", "video"], "maxSize": 5, "inline": true, "mode": "wide" } + { "name": "hero", "type": "freeform", "accepts": ["image", "video"], "maxSize": 5, "inline": true, "mode": "wide" } ] } ] } """; - var settings = Builder().AddSchemaJson(json).Build().FindContentType("article")!.FindField("hero")!.Settings; + var settings = new SchemaBuilder(new FieldTypeRegistry([new FreeFormFieldType()])).AddSchemaJson(json).Build().FindContentType("article")!.FindField("hero")!.Settings; settings.Keys.ShouldBe(["accepts", "inline", "maxSize", "mode"]); ((IEnumerable)settings["accepts"]).ShouldBe(["image", "video"]); diff --git a/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs b/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs index 4a66d9532..2662a7f6d 100644 --- a/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs +++ b/tests/FluentCMS.Core.Tests/Storage/FieldStorageTests.cs @@ -1,6 +1,8 @@ using FluentCMS.Core.Schema; +using FluentCMS.Core.Schema.FieldTypes; using FluentCMS.Core.Storage; using FluentCMS.Core.Tests.Schema; +using FluentCMS.Core.Tests.Schema.FieldTypes; using FluentCMS.Schema; using Shouldly; using Xunit; @@ -79,7 +81,7 @@ public void FieldTypeWithoutAMappingIsAnError() var schema = SampleSchema.NewBuilder().AddCollection("page", type => type.Field("hero", "media", field => field.Multiple())).Build(); FieldStorage.Of(schema, schema.FindContentType("page")!).ShouldHaveSingleItem().Kind.ShouldBe(StorageKind.Reference); - var custom = new SchemaBuilder(["color"]).AddCollection("page", type => type.Field("tint", "color")).Build(); + var custom = new SchemaBuilder(new FieldTypeRegistry([new FreeFormFieldType()])).AddCollection("page", type => type.Field("tint", "freeform")).Build(); Should.Throw(() => FieldStorage.Of(custom, custom.FindContentType("page")!).ToList()).Message.ShouldContain("page.tint"); } } diff --git a/tests/FluentCMS.Data.Tests/FluentCMS.Data.Tests.csproj b/tests/FluentCMS.Data.Tests/FluentCMS.Data.Tests.csproj index 60349ae8f..1f8081332 100644 --- a/tests/FluentCMS.Data.Tests/FluentCMS.Data.Tests.csproj +++ b/tests/FluentCMS.Data.Tests/FluentCMS.Data.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/FluentCMS.Sdk.Tests/Schema/FieldTypeContractTests.cs b/tests/FluentCMS.Sdk.Tests/Schema/FieldTypeContractTests.cs new file mode 100644 index 000000000..1a29af459 --- /dev/null +++ b/tests/FluentCMS.Sdk.Tests/Schema/FieldTypeContractTests.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Nodes; +using FluentCMS.Content; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Sdk.Tests.Schema; + +public class FieldTypeContractTests +{ + [Fact] + public void FilterOperatorsMatchTheQueryGrammarOneToOne() + { + Enum.GetNames().ShouldBe( + [ + "Equal", "NotEqual", "In", "NotIn", "LessThan", "LessThanOrEqual", "GreaterThan", "GreaterThanOrEqual", + "Between", "Contains", "StartsWith", "IsNull", "IsNotNull", "HasBlock", + ]); + } + + [Fact] + public void StorageKindsAreColumnJsonAndReference() + { + Enum.GetNames().ShouldBe(["Column", "Json", "Reference"]); + } + + [Fact] + public void SettingKindsAreTheValuesASchemaCanHold() + { + Enum.GetNames().ShouldBe(["String", "Boolean", "Integer", "Decimal", "StringList"]); + } + + [Fact] + public void ASettingIsOptionalByDefaultAndEqualByValue() + { + var setting = new FieldSettingDescriptor { Key = "scale", Kind = FieldSettingKind.Integer }; + + setting.Required.ShouldBeFalse(); + setting.ShouldBe(new FieldSettingDescriptor { Key = "scale", Kind = FieldSettingKind.Integer }); + setting.ShouldNotBe(new FieldSettingDescriptor { Key = "scale", Kind = FieldSettingKind.Integer, Required = true }); + } + + [Fact] + public void AFieldTypeRejectsUnlistedSettingsUnlessItSaysOtherwise() + { + IFieldType fieldType = new MinimalFieldType(); + + fieldType.AcceptsAnySetting.ShouldBeFalse(); + } + + private sealed class MinimalFieldType : IFieldType + { + public string Name => "minimal"; + + public IReadOnlyList ClrTypes => [typeof(string)]; + + public IReadOnlySet FilterOperators => new HashSet(); + + public bool Sortable(SchemaField field) => false; + + public bool Indexable(SchemaField field) => false; + + public IReadOnlyList Settings => []; + + public FieldStorageKind StorageKind(SchemaField field) => FieldStorageKind.Column; + + public bool TryCoerce(object? value, SchemaField field, out object? result, out string? message) + { + result = value; + message = null; + return true; + } + + public JsonObject OpenApiSchema(SchemaField field) => []; + } +}