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
2 changes: 1 addition & 1 deletion src/FluentCMS.Core/Schema/SchemaBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public SchemaModel Build()
throw new SchemaValidationException(errors);
}

return SchemaModelFactory.Create(_types);
return SchemaModelFactory.Create(_types, _fieldTypes);
}

ISchemaBuilder ISchemaBuilder.AddContentType<T>() => AddContentType<T>();
Expand Down
6 changes: 3 additions & 3 deletions src/FluentCMS.Core/Schema/SchemaModelFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace FluentCMS.Core.Schema;
internal static class SchemaModelFactory
{
// Expects drafts that passed validation: unique names, relation targets that exist.
public static SchemaModel Create(IReadOnlyList<TypeDraft> drafts)
public static SchemaModel Create(IReadOnlyList<TypeDraft> drafts, IFieldTypeRegistry fieldTypes)
{
var reverseRelations = drafts
.Where(type => type.IsContentType)
Expand All @@ -24,10 +24,10 @@ public static SchemaModel Create(IReadOnlyList<TypeDraft> drafts)
ClrType = draft.ClrType,
Fields = draft.Fields.Select(Create).ToList().AsReadOnly(),
ReverseRelations = (draft.IsContentType ? reverseRelations[draft.Name] : []).OrderBy(relation => relation.Name, StringComparer.Ordinal).ToList().AsReadOnly(),
}));
}), fieldTypes);
}

private static SchemaField Create(FieldDraft draft) => new()
public static SchemaField Create(FieldDraft draft) => new()
{
Name = draft.Name,
FieldType = draft.FieldType,
Expand Down
100 changes: 100 additions & 0 deletions src/FluentCMS.Core/Schema/SchemaValidator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text.RegularExpressions;
using FluentCMS.Core.Schema.FieldTypes;
using FluentCMS.Core.Storage;
using FluentCMS.Schema;

namespace FluentCMS.Core.Schema;
Expand Down Expand Up @@ -51,6 +52,12 @@ public IReadOnlyList<SchemaError> Validate(IReadOnlyList<TypeDraft> types)

ValidateReverseRelations(unique.Where(type => type.IsContentType), contentTypes);
ValidateNesting(unique, components, blocks);
ValidateTables(unique.Where(type => type.IsContentType));

foreach (var type in unique.Where(type => type.IsContentType))
{
ValidateColumns(type, components);
}

return [.. _errors];
}
Expand Down Expand Up @@ -369,6 +376,99 @@ private void ValidateReverseRelations(IEnumerable<TypeDraft> contentTypes, Dicti
}
}

// A type's tables must not be a system table, nor the locale table of another type: 'page-i18n' beside a localized 'page'.
private void ValidateTables(IEnumerable<TypeDraft> contentTypes)
{
var types = contentTypes.Where(type => SchemaNaming.IsValidTypeName(type.Name)).ToList();
var localeTables = types.Where(type => type.Localized).ToDictionary(type => StorageNames.LocaleTable(type.Name), StringComparer.Ordinal);

foreach (var type in types)
{
var table = StorageNames.Table(type.Name);
if (StorageNames.SystemTables.Contains(table))
{
Report(SchemaErrorCodes.ReservedName, $"{Describe(type)} needs table {table}, which a system table already uses");
}
else if (localeTables.TryGetValue(table, out var owner))
{
Report(SchemaErrorCodes.ReservedName, $"{Describe(type)} needs table {table}, which is the locale table of {Describe(owner)}");
}
}
}

// Walks the fields that become columns, single components flattened, as the storage mapping does.
private void ValidateColumns(TypeDraft type, Dictionary<string, TypeDraft> components)
{
Dictionary<(bool Localized, string Column), string> holders = [];

foreach (var field in DistinctFields(type))
{
ValidateColumn(type, field, type.Name, "", type.Localized && field.Localizable, holders, components, []);
}
}

// A repeated name is reported once, by ValidateType; the repeat would only add a second error about the same column.
private static IEnumerable<FieldDraft> DistinctFields(TypeDraft type) =>
type.Fields.DistinctBy(field => field.Name, StringComparer.OrdinalIgnoreCase);

private void ValidateColumn(TypeDraft type, FieldDraft field, string owner, string prefix, bool localized, Dictionary<(bool Localized, string Column), string> holders, Dictionary<string, TypeDraft> components, HashSet<string> path)
{
var fieldType = fieldTypes.Find(field.FieldType);
if (fieldType is null || !SchemaNaming.IsValidFieldName(field.Name))
{
return;
}

var location = $"{owner}.{field.Name}";
var column = prefix + StorageNames.Column(field.Name);
var kind = fieldType.StorageKind(SchemaModelFactory.Create(field));

if (field.FieldType == FieldTypeNames.Component && kind == FieldStorageKind.Column)
{
if (SettingText(field, FieldSettings.Component) is { } name && components.TryGetValue(name, out var component) && path.Add(name))
{
foreach (var inner in DistinctFields(component))
{
ValidateColumn(type, inner, location, column + "_", localized, holders, components, path);
}

path.Remove(name);
}

return;
}

if (field.Unique && kind != FieldStorageKind.Column)
{
Report(SchemaErrorCodes.InvalidField, kind == FieldStorageKind.Reference
? $"field {location} is unique, but a {field.FieldType} field has no column to index"
: $"field {location} is unique, but a {field.FieldType} field is stored as JSON");
}

if (kind == FieldStorageKind.Reference)
{
return;
}

var table = localized ? StorageNames.LocaleTable(type.Name) : StorageNames.Table(type.Name);
var systemColumns = localized ? StorageNames.LocaleTableColumns : StorageNames.SystemColumns;

// A top-level field named like a system field was reported already, by name.
var reportedByName = prefix.Length == 0 && ContentItem.ReservedFieldNames.Contains(field.Name);

if (systemColumns.Contains(column))
{
if (!reportedByName)
{
Report(SchemaErrorCodes.ReservedName, $"field {location} maps to column {column} of {table}, which is a system column");
}
}
else if (!holders.TryAdd((localized, column), location))
{
Report(SchemaErrorCodes.DuplicateField, $"fields {holders[(localized, column)]} and {location} both map to column {column} of {table}");
}
}

// Depth counts the components and blocks between a content type and its deepest field. A cycle has no depth, so it is reported as such.
private void ValidateNesting(IReadOnlyList<TypeDraft> types, Dictionary<string, TypeDraft> components, Dictionary<string, TypeDraft> blocks)
{
Expand Down
78 changes: 10 additions & 68 deletions src/FluentCMS.Core/Storage/ContentModelBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using FluentCMS.Core.Schema;
using FluentCMS.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
Expand All @@ -9,41 +8,23 @@ namespace FluentCMS.Core.Storage;
// cms_{type}_i18n for a localized type. Property names are the column names.
internal sealed class ContentModelBuilder(SchemaModel schema, IStorageConventions conventions)
{
private readonly List<SchemaError> _errors = [];

// System entities must already be mapped, so a type whose table is taken is reported here.
// The schema was validated when it was built, so names and columns cannot clash here.
public void Apply(ModelBuilder modelBuilder)
{
var tables = modelBuilder.Model.GetEntityTypes()
.Select(entity => entity.GetTableName())
.OfType<string>()
.ToHashSet(StringComparer.Ordinal);

foreach (var type in schema.ContentTypes)
{
var fields = FieldStorage.Of(schema, type).ToList();
MapTable(modelBuilder, tables, type, StorageNames.Table(type), fields.Where(field => !field.Localized).ToList(), hasLocale: false);
MapTable(modelBuilder, type, StorageNames.Table(type), fields.Where(field => !field.Localized).ToList(), hasLocale: false);

if (type.Localized)
{
MapTable(modelBuilder, tables, type, StorageNames.LocaleTable(type), fields.Where(field => field.Localized).ToList(), hasLocale: true);
MapTable(modelBuilder, type, StorageNames.LocaleTable(type), fields.Where(field => field.Localized).ToList(), hasLocale: true);
}
}

if (_errors.Count > 0)
{
throw new SchemaValidationException(_errors);
}
}

private void MapTable(ModelBuilder modelBuilder, HashSet<string> tables, SchemaType type, string table, List<StoredField> fields, bool hasLocale)
private void MapTable(ModelBuilder modelBuilder, SchemaType type, string table, List<StoredField> fields, bool hasLocale)
{
if (!tables.Add(table))
{
Report(SchemaErrorCodes.ReservedName, $"{type.Kind.ToString().ToLowerInvariant()} '{type.Name}' needs table {table}, which is already taken");
return;
}

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(table, entity =>
{
entity.ToTable(table);
Expand All @@ -53,19 +34,9 @@ private void MapTable(ModelBuilder modelBuilder, HashSet<string> tables, SchemaT
entity.Property(type.Scope == ContentScope.Global ? typeof(Guid?) : typeof(Guid), StorageNames.SiteId);
entity.Property<short>(StorageNames.State);

// Column to the field that holds it; a system column has no field.
Dictionary<string, string?> columns = new(StringComparer.Ordinal)
{
[StorageNames.Id] = null,
[StorageNames.DocumentId] = null,
[StorageNames.SiteId] = null,
[StorageNames.State] = null,
};

if (hasLocale)
{
entity.Property<string>(StorageNames.Locale).HasMaxLength(StorageNames.LocaleLength).IsRequired();
columns[StorageNames.Locale] = null;
entity.HasIndex(StorageNames.DocumentId, StorageNames.Locale, StorageNames.State).IsUnique().HasDatabaseName($"uq_{table}_document_locale_state");
}
else
Expand All @@ -75,40 +46,19 @@ private void MapTable(ModelBuilder modelBuilder, HashSet<string> tables, SchemaT

foreach (var field in fields)
{
MapField(entity, type, table, field, columns, hasLocale);
MapField(entity, type, table, field, hasLocale);
}
});
}

private void MapField(EntityTypeBuilder entity, SchemaType type, string table, StoredField field, Dictionary<string, string?> columns, bool hasLocale)
private void MapField(EntityTypeBuilder entity, SchemaType type, string table, StoredField field, bool hasLocale)
{
if (field.Kind == StorageKind.Reference)
// [Indexed] on a reference needs nothing: cms_reference already has its index on the target.
if (field.Kind == FieldStorageKind.Reference)
{
// [Indexed] needs nothing: cms_reference already has its index on the target.
if (field.Field.Unique)
{
Report(SchemaErrorCodes.InvalidField, $"field {field.Location} is unique, but a {field.Field.FieldType} field has no column to index");
}

return;
}

if (columns.TryGetValue(field.Column, out var holder))
{
if (holder is null)
{
Report(SchemaErrorCodes.ReservedName, $"field {field.Location} maps to column {field.Column} of {table}, which is a system column");
}
else
{
Report(SchemaErrorCodes.DuplicateField, $"fields {holder} and {field.Location} both map to column {field.Column} of {table}");
}

return;
}

columns[field.Column] = field.Location;

var property = entity.Property(field.ClrType!, field.Column);
if (field.ClrType == typeof(string))
{
Expand All @@ -130,7 +80,7 @@ private void MapField(EntityTypeBuilder entity, SchemaType type, string table, S
property.HasPrecision(precision, field.Scale ?? 0);
}

if (field.Kind == StorageKind.Json)
if (field.Kind == FieldStorageKind.Json)
{
conventions.ConfigureJsonColumn(entity, property);
}
Expand All @@ -145,12 +95,6 @@ private void MapIndexes(EntityTypeBuilder entity, SchemaType type, string table,

if (field.Field.Unique)
{
if (field.Kind == StorageKind.Json)
{
Report(SchemaErrorCodes.InvalidField, $"field {field.Location} is unique, but a {field.Field.FieldType} field is stored as JSON");
return;
}

entity.HasIndex([.. scope, field.Column])
.IsUnique()
.HasFilter(StorageNames.PublishedOnly)
Expand All @@ -159,7 +103,7 @@ private void MapIndexes(EntityTypeBuilder entity, SchemaType type, string table,

if (field.Field.Indexed)
{
if (field.Kind == StorageKind.Json)
if (field.Kind == FieldStorageKind.Json)
{
conventions.ConfigureJsonIndex(entity, field.Column);
}
Expand All @@ -169,6 +113,4 @@ private void MapIndexes(EntityTypeBuilder entity, SchemaType type, string table,
}
}
}

private void Report(string code, string message) => _errors.Add(new SchemaError(code, message));
}
Loading
Loading