Skip to content

Add the validation pipeline, custom validators and condition evaluation - #2727

Merged
pournasserian merged 2 commits into
mainfrom
2685-validation-pipeline
Nov 2, 2026
Merged

pournasserian merged 2 commits into
mainfrom
2685-validation-pipeline

Conversation

@pournasserian

@pournasserian pournasserian commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Closes #2685

Adds the validation pipeline of spec 4.2 and 4.4 (B-14). The SDK gets the contract (IFieldValidator<TField>, IEntryValidator<T>, ValidationContext, ValidationResult, ValidationError, ValidationException, ValidationMode, ValidationErrorCodes). Core gets ValidationPipeline, which validates a SchemaType against an entry's values (a dictionary keyed by API field name) and an optional typed entry, in the order declarative rules, custom field validators, entry validators. It also gets ConditionEvaluator and ConditionJson, so a Condition evaluates the same from C# and from JSON. ConditionJson reuses the schema.json condition reader and writer; the only change to those files is that WriteCondition and ReadCondition are now internal.

Uniqueness, field type coercion, IContentService and the DI registration are not here (#2693, #2684, #2695). A comment in the pipeline marks where uniqueness and reference integrity slot in.

Dependencies

No new package in src. The test project FluentCMS.Core.Tests references Microsoft.Extensions.DependencyInjection 10.0.14 (same train as the EF packages) so validators can be registered in a ServiceCollection. The pipeline itself resolves validators with IServiceProvider.GetService(typeof(IEnumerable<...>)), so Core needs no DI abstractions package.

Acceptance

  • Validation results carry field paths and stable codes for the problem details format (ValidationError { Path, Code, Message }, codes below; the HTTP mapping is a later issue)
  • A draft with missing required fields saves; publishing it fails with the field list (verified at pipeline level: Draft mode passes, Publish mode returns the errors; the service call is IContentService and IContentService<T> with optimistic concurrency #2693)
  • Conditions round-trip to and from JSON and evaluate identically in C# and from JSON
  • Custom validators receive the full context and can reject per field

Error codes

Code Constant Meaning
FCMS2101 Required A required field has no value
FCMS2102 MinLength Fewer characters than MinLength, or fewer items on a multiple field
FCMS2103 MaxLength More characters than MaxLength, or more items on a multiple field
FCMS2104 BelowMinimum Below Minimum
FCMS2105 AboveMaximum Above Maximum
FCMS2106 PatternMismatch Does not match Pattern
FCMS2107 NotAnAllowedOption Not one of the select options, or a block type the field does not allow
FCMS2108 InvalidValue Reserved for the field type coercion failures #2693 adds
FCMS2109 Custom Default when a custom validator gives an empty code
FCMS2110 RequiredByCondition RequiredWhen holds and the field has no value

Beyond the spec

  • ValidationContext also has Path (the full path of the current field, so AddError(code, message) on seo.metaTitle lands at the right path), a read-only Errors list, and a public constructor so plugin authors can unit-test validators. The pipeline creates one context per field that shares the run's error list; for a field inside a component or block, Values are its siblings in that component or block.
  • ValidationContext.Container is the type that declares the field: the entry type at the top level, the component or block type inside one (Type stays the entry type).
  • AddError with an empty code uses FCMS2109. AddError(code, message) throws InvalidOperationException in an entry validator (no current field).
  • Order of errors: fields in declaration order (components and blocks inline, in item order), then custom field validators in the order the fields were visited, then entry validators. Custom validators still run when a declarative rule failed, so all problems come back at once.
  • Hidden fields (VisibleWhen false) are skipped completely: no declarative rule, no custom field validator, no descent into components or blocks.
  • If Required and RequiredWhen would both fail, only FCMS2101 is reported. A format rule (length, range, pattern, option) is skipped for an empty value.
  • Length: characters for a string; item count for a Multiple field and for a blocks field (message says "items"). Range, pattern and options on a Multiple field check each item, reported at field[i] (tags[1]).
  • Minimum/Maximum apply to numbers only. Date and date-time bounds wait for a date-aware range in the model, since nothing says whether a bound means days or seconds. A float beyond what a decimal holds counts as the nearest decimal bound, so it is out of range in its own direction.
  • Select membership compares the value (an enum member by its camelCase name) with Settings[options] ordinally; no check if the field has no options.
  • Pattern: RegexOptions.CultureInvariant, a 1 second timeout, one compiled Regex per field per pipeline. A timeout counts as a mismatch. The pattern matches anywhere in the value unless anchored; the RegularExpression doc says so.
  • Nested values: a component or block instance is an IReadOnlyDictionary<string, object?> (or IDictionary) keyed by API name, or a CLR object whose public properties are read by reflection, names converted to camelCase. A dictionary block carries its block name under type; a CLR block is matched to a block by its class (SchemaType.ClrType). A block with no or an unknown type, or one the field does not list, is FCMS2107 on field[i]. A null or empty component is not descended into, so required fields inside it are not reported; require the component itself instead.
  • IsEmpty treats a whitespace-only string as empty (the ConditionField.IsEmpty doc now says so), and a default Ref<T>, a default MediaRef and Guid.Empty as empty; any other IEnumerable is empty when it has no items.
  • Equal/In against a list value (multi select) match when any item matches; an empty list equals null. A Ref<T> compares as its id string.
  • Custom field validators are resolved by the field's CLR property type (nullable unwrapped; the value's runtime type for a type defined without a class), then by object. A null value is only passed to validators of reference types and of object; IFieldValidator<int> is skipped for a null int. They run for fields in components and blocks too.
  • Entry validators are resolved for the entry's runtime type and each of its base classes, most derived first, and only when an entry object is given.
  • ConditionJson offers Write(Condition, Utf8JsonWriter), Read(JsonElement), ToJson and Parse; invalid input throws JsonException with the schema reader's messages. Read runs the schema reader on an empty SchemaBuilder to get them, so there is no second parser.
  • ConditionEvaluator, ConditionJson and ValidationPipeline are public in Core; DeclarativeValidator is internal.

@pournasserian
pournasserian merged commit 5d501d6 into main Nov 2, 2026
5 checks passed
@pournasserian
pournasserian deleted the 2685-validation-pipeline branch November 2, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Declarative validation, custom validators and serialisable conditions

1 participant