Skip to content

Add the field type registry and the core field types - #2728

Merged
pournasserian merged 3 commits into
mainfrom
2684-field-type-registry
Nov 2, 2026
Merged

pournasserian merged 3 commits into
mainfrom
2684-field-type-registry

Conversation

@pournasserian

@pournasserian pournasserian commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Closes #2684

Every field type now describes itself in one place: IFieldType in the SDK (CLR types, storage kind, filter operators, sortable and indexable flags, accepted settings, coercion of raw values, OpenAPI fragment) with an IFieldTypeRegistry. Core registers the 16 core descriptors in FieldTypeRegistry, which also takes extra descriptors from plugins. SchemaBuilder and SchemaValidator use the registry in place of the fixed KnownFieldTypes list, and the validator now checks each field's settings against its descriptor, so a typo such as maxlength or requird in schema.json fails startup with FCMS1211 instead of being dropped silently (the review note on #2724).

Dependencies: none.

Acceptance

  • All field types in spec rows 1.3 to 1.6 are registered with tests for coercion and validation: blocks, boolean, component, date, dateTime, decimal, email, integer, json, longText, relation, richText, select, slug, text, url. Coercion and settings validation are tested; the declarative rules (Required, Length, Range, RegularExpression, option membership) belong to Declarative validation, custom validators and serialisable conditions #2685.
  • A test plugin registers a custom field type and it appears in the model: a media descriptor registered through new FieldTypeRegistry([new MediaFieldType()]) lets a [Media] class build, and the field is in the SchemaModel with storage kind Reference. Without it the build fails with FCMS1203.
  • Slug generation and uniqueness behave per spec section 4.2: SlugFieldType.Slugify and the slug pattern are done and tested. Uniqueness is not enforced here: the partial unique index in EF Core model builder: documents, content tables, locale rows, references and versions #2688 covers published entries and the draft-side check in IContentService and IContentService<T> with optimistic concurrency #2693 covers drafts.
  • The registry exposes what the OpenAPI transformer and the MCP schema resource need: All, Find, and per type StorageKind, FilterOperators, Sortable, Indexable, Settings and OpenApiSchema, with the shape of each fragment tested.

dotnet build -c Release -warnaserror and dotnet test -c Release pass.

Beyond the spec

  • IFieldType.AcceptsAnySetting is a default interface member (false). A plugin type that keeps free-form options returns true and then skips the unknown-key and value-kind checks; its required settings are still checked. Core types never accept unknown keys. This changed SchemaJsonTests.UnknownFieldPropertiesBecomeSettingsOfTheFieldType, which used a text field with arbitrary settings; it now uses a test type that accepts any setting.
  • Settings checks use FCMS1211. The error names the type, the field and the key: text field article.title has an unknown setting 'maxlength'; it accepts none. A missing required key on select, component and blocks is left to the existing type-specific checks so it is reported once, with their messages.
  • The TryCoerce out parameter is named message rather than error, because CA1716 forbids error on an interface member.
  • Sortable(SchemaField) and Indexable(SchemaField) are methods, like StorageKind(SchemaField). Core types answer false to Sortable for a field stored as JSON (multi select, repeatable component, richText, json, blocks) and false to Indexable for a multi select and a repeatable component. json and blocks are indexable (GIN on PostgreSQL, spec 4.3); blocks is not sortable. FilterOperators stays a property of the type; for select it includes Contains, meant for a multi select.
  • FieldSettingDescriptor is a sealed record with required Key and Kind and an optional Required, all init, so a member can be added later without a binary break.
  • A raw value is a CLR value, a JsonElement or a JsonNode, and every type reads all three (RawValue.Unwrap turns a node into an element first). The TryCoerce documentation says so, and that a field holding JSON gives a JsonNode.
  • ClrTypes, Settings and FieldTypeRegistry.All are read-only wrappers, so a cast cannot change the shared FieldTypeRegistry.Core.
  • The kind of a declared setting is checked whether or not the type accepts any setting; only the unknown-key check is skipped. A key whose kind is wrong is reported once: the type-specific check of that key (options, component, blocks) is skipped. decimal settings are also checked at build time, each as FCMS1211: precision between 1 and 28, scale between 0 and 28, scale not above precision.
  • The decimal fragment has no multipleOf for the scale, because validators that compute it in binary floating point reject valid values.
  • Filter operators: text-like types eq ne in nin contains startsWith null notNull; integer, decimal, date and dateTime the ordered set with lt lte gt gte between; boolean eq ne null notNull; longText contains startsWith null notNull; json eq ne contains null notNull (Q7, one level deep); relation eq in null notNull; blocks hasBlock null notNull; richText null notNull; component none (its fields are filtered through their own columns when single). longText is neither sortable nor indexable; json is indexable (GIN on PostgreSQL) but not sortable.
  • Canonical values: text, longText, email, url, slug and select give string (multi select string[]), integer long, decimal a normalised decimal (no trailing zeros), boolean bool, date DateOnly, dateTime a DateTimeOffset in UTC, richText a RichText, json a JsonNode, relation a Guid (Guid[] when to-many, null for an empty Ref<T>), component a JsonObject (JsonArray when repeatable), blocks a JsonArray (a BlockList is kept as is). url returns the text, not a Uri, because the column and the usual property type are strings; a Uri is accepted as input.
  • Coercion is strict: a number is never turned into text, and "true" and "false", in any letter case, are the only text read as a boolean. null and JSON null always coerce to null; whether a value may be empty is a validation rule. Text for dateTime must be ISO 8601 with a date and a time (2026-11-01T09:12:00Z, an offset, or no offset, which is read as UTC); a date alone is rejected. A DateTime for date must be at midnight.
  • decimal honours precision and scale by rejecting a value that does not fit, never by rounding. scale limits the digits after the point, precision the significant digits in total; a precision with no scale limits only the total.
  • richText coercion checks that the value is a JSON object of type doc whose content, if present, is an array. It does not run the ProseMirror parser or validator, as asked. richText, json, component and blocks all also accept a string holding JSON text.
  • component and blocks coercion checks only the outer shape (an object, a list of objects, each block naming its type). Checking the fields inside, and that a block is one the field lists, needs the schema model and is left to the content service and Declarative validation, custom validators and serialisable conditions #2685.
  • OpenAPI fragments omit nullability and the required marker, which belong to the object that holds the field. Component and block schemas are referenced as #/components/schemas/component-{name} and #/components/schemas/block-{name}, because a component and a block may share a name; OpenApiNames holds the convention for the transformer to reuse. A block field emits a oneOf with a type discriminator and mapping.
  • ClrTypes lists property types: Ref<>/RefList<> are open generics; component lists object; select lists string and Enum. Collections of these (List<string> and so on) are not listed.
  • Descriptor classes are internal; only FieldTypeRegistry is public in Core, with a public constructor taking the extras and FieldTypeRegistry.Core. A name can be registered once, so an extra cannot replace a core type and duplicates throw ArgumentException.
  • SlugFieldType.Slugify(string source, int? maxLength = null) maps ß æ œ ø đ ð ł ı þ to ASCII letters (they have no decomposition), and a source with no Latin letters or digits gives an empty slug. A cut at maxLength drops a trailing hyphen.
  • FieldSettingKind carries a CA1720 suppression because the members are named String, Integer and Decimal.
  • Existing tests touched: SampleSchema.NewBuilder now registers a test media descriptor, and SchemaJsonTests uses a free-form test type as noted above.
  • Left as they are, because each needs a decision beyond this issue: dateTime text with more than 7 fractional digits or an offset without a colon (+0100) is rejected although ISO 8601 allows both; a DateTimeOffset is rejected for date while a DateTime at midnight is accepted; Slugify turns It's into it-s and gives an empty slug for text with no Latin letters or digits, so the content service needs a fallback when it generates a slug.

@pournasserian
pournasserian merged commit 2c0e5d1 into main Nov 2, 2026
5 checks passed
@pournasserian
pournasserian deleted the 2684-field-type-registry branch November 2, 2026 14:50
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.

Field type registry and the core field types

1 participant