Skip to content

Add the EF Core model builder for documents and content tables - #2729

Merged
pournasserian merged 2 commits into
mainfrom
2688-ef-model-builder
Nov 2, 2026
Merged

pournasserian merged 2 commits into
mainfrom
2688-ef-model-builder

Conversation

@pournasserian

@pournasserian pournasserian commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Closes #2688

Adds CmsDbContext and the model builder that turns a SchemaModel into the EF Core model of spec section 4.3: cms_document, cms_reference and cms_version as CLR entities, and cms_{type} and cms_{type}_i18n as property-bag entity types. It also adds ContentScope to the schema, because a global type changes site_id and its indexes. This is the provider-neutral model. Provider column types and SQLite's date convention arrive in #2689 through the new IStorageConventions hook. Everything is verified on SQLite only, per Q19.

Dependencies: Microsoft.EntityFrameworkCore 10.0.14 and Microsoft.EntityFrameworkCore.Relational 10.0.14, referenced from FluentCMS.Core. FluentCMS.Data.Tests also references Microsoft.EntityFrameworkCore.Sqlite 10.0.14, which was already listed. The SDK gets no EF reference.

Acceptance

  • The sample Article type produces the tables and indexes shown in spec appendix A on all three providers. SQLite only (Q19). One column differs: see the spec mismatch below.
  • Non-localized types produce a single cms_{type} table
  • Global types (ContentScope.Global) store site_id null
  • The model builds in under one second for a schema of 50 types. Measured on a development machine, 50 types of 20 fields (78 entity types): 511 to 532 ms from a fresh process, EF start-up included. The first run after compiling took 1066 ms, which is over the target; the later runs did not repeat it. The test's bound is 3 seconds so it will not flake in CI.

Spec mismatch to settle

Appendix A puts video_url in cms_article_i18n, but the Article of section 4.2 does not mark VideoUrl [Localizable]. The builder follows the flag, so the column lands in cms_article. Either the appendix or the sample needs a one-word fix. I left the sample as section 4.2 has it and did not edit the spec.

Beyond the spec

How the schema reaches the context. options.UseCmsModel(schema, conventions) adds an internal options extension, and CmsDbContext has the single DbContextOptions<CmsDbContext> constructor that AddDbContext, pooling and design-time factories expect. Without it the constructor throws and says what to call. UseCmsModel also replaces IModelCacheKeyFactory, so a caller cannot forget that.

Model cache key. Context type, the SHA-256 of SchemaJsonWriter's canonical JSON, IStorageConventions.CacheKey and the design-time flag. The hash is computed once per SchemaModel instance. Equal schemas built separately share a model. The schema hash of #2687 replaces it. The schema is left out of the EF service provider identity, so all schemas share one model cache.

IStorageConventions. Public in FluentCMS.Core.Storage, with StorageConventions as the neutral default of virtual no-ops. CacheKey is part of the model cache key, and two instances that change the model differently must return different keys, such as a provider option for the column type. The default returns the type's full name. Three hooks:

  • ConfigureConventions: model-wide, for SQLite's UTC ticks.
  • ConfigureJsonColumn: every JSON column including cms_version.snapshot, for jsonb, nvarchar(max) or a json_valid check.
  • ConfigureJsonIndex: an [Indexed] JSON field, for a GIN index. The neutral default adds no index.

A test drives all three with a SQLite-like implementation.

Nullability. NOT NULL is limited to what Appendix A shows, as general rules because the sample's slug and body are not [Required]:

  • [Required] text and longText, because '' can stand in for an incomplete draft.
  • slug, always, because it is derived.
  • richText, always, because it has an empty document. There is no store default, as in Appendix A.
  • Lists (blocks, multi select, repeatable components, any other Multiple field) are NOT NULL DEFAULT '[]'.
  • Everything else is nullable even when [Required], including email, url and select, because a draft has no honest placeholder for them and format checks would reject ''.
  • Flattened component columns are nullable even when required, because the whole component may be absent.

So the content service (#2692) must write '' and the empty rich text document for missing values on a draft, and the validation pipeline must treat '' as missing at publish.

Column types. integer is long: widening a populated column later is a migration, and SchemaValue already holds whole numbers as long. state is short, as Appendix A's smallint, so a property bag must hold a boxed short; ContentState.Draft and ContentState.Published are provided for that. status, status_after and origin are stored by enum name, as in the spike, so reordering an enum cannot corrupt rows. locale is 16 characters and type, field, source_type and target_type 64, as in the spike.

Default lengths. Appendix A sizes columns the sample leaves unsized, so FieldStorage defaults slug to 200, url to 2048 and single select to 64, or to its longest option if that is longer. email is 254, which is my choice. text and longText without a limit stay unbounded, as [Text] documents. The validation pipeline does not know these defaults, and SQLite does not enforce them, so #2711 should check what a longer value does on the other providers.

Keys and indexes.

  • cms_reference and cms_version get an id v7 primary key beside their unique index. Section 4.3 names only the unique key; the ADR gives cms_reference an id, and this keeps all tables alike and lets a position change be an update.
  • Key indexes are named uq_{table}_..., leaving ux_ and ix_ to fields.
  • [Indexed] on a localizable field is ix_{table}_{field} on (site_id, locale, state, field). Locale is added because lists always filter on one locale, and the name uses the table, as ux_cms_article_i18n_slug does.
  • [Indexed] on a global type leaves site_id out, since it is always null.
  • [Indexed] on a relation or media field adds nothing, because cms_reference already indexes the target.

Single components. They nest by prefix (home_geo_lat). The parent field's Localizable decides the table, since the validator rejects it on component fields. [Indexed] and [Unique] on a component's field index its flattened column.

New schema errors, raised when the model builds. SchemaValidator.cs is being changed by #2684, so these live in ContentModelBuilder for now, thrown together as a SchemaValidationException:

  • FCMS1206: a field that takes a system column, such as state, documentId or siteId, none of which are reserved names today.
  • FCMS1202: two fields that map to one column, such as a seo component with metaTitle beside a field seoMetaTitle. The message names both.
  • FCMS1206: a type whose table is taken, such as document, reference, version or page-i18n beside a localized page. This compares against every entity mapped at that point, so system tables added later are covered if they map first.
  • FCMS1211: [Unique] on a relation, media or JSON field, which has no column to index.

A field type with no storage mapping throws InvalidOperationException, until IFieldType.StorageKind replaces the switch in FieldStorage.

ContentScope. ITypeBuilder.Scope throws ArgumentException on a component or block, rather than reporting through the validator as Localized does, for the same reason. In schema.json a scope on a component or block, or an unknown value, is FCMS1213. Tag and its JSON twin are now global, as section 4.6 shows.

Tests. FluentCMS.Data.Tests links SampleSchema.cs and the sample types from FluentCMS.Core.Tests, because a test project cannot reference another, and FluentCMS.Core makes its internals visible to it.

Architecture rule. ApiDoesNotReferenceStorageOrCmsDbContext is no longer skipped. FluentCMS.Core.Api does not exist yet, so it uses WithoutRequiringPositiveResults() and starts checking with the first type there.

Left for later. tests/FluentCMS.Storage.Spike.Tests stays for now, though the ADR says to delete it once this lands, because #2689 and #2690 may still want to read it. Identifier lengths are not checked: an index name can exceed PostgreSQL's 63 characters, which #2711 should cover.

@pournasserian
pournasserian merged commit d8dac13 into main Nov 2, 2026
5 checks passed
@pournasserian
pournasserian deleted the 2688-ef-model-builder branch November 2, 2026 14:45
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.

EF Core model builder: documents, content tables, locale rows, references and versions

1 participant