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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/BlocksFieldType.cs
Original file line number Diff line number Diff line change
@@ -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<string>(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 },
},
};
}
}
30 changes: 30 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/BooleanFieldType.cs
Original file line number Diff line number Diff line change
@@ -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" };
}
45 changes: 45 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/ComponentFieldType.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
45 changes: 45 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/CoreFieldType.cs
Original file line number Diff line number Diff line change
@@ -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<Type> ClrTypes { get; } = Array.AsReadOnly(clrTypes);

public IReadOnlySet<FilterOperator> FilterOperators { get; } = filterOperators.ToFrozenSet();

public IReadOnlyList<FieldSettingDescriptor> 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);
}
35 changes: 35 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/DateFieldType.cs
Original file line number Diff line number Diff line change
@@ -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" };
}
40 changes: 40 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/DateTimeFieldType.cs
Original file line number Diff line number Diff line change
@@ -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" };
}
90 changes: 90 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/DecimalFieldType.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions src/FluentCMS.Core/Schema/FieldTypes/EmailFieldType.cs
Original file line number Diff line number Diff line change
@@ -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";
}
Loading
Loading