Repository navigation
Add the EF Core model builder for documents and content tables - #2729
Merged
Merged
Conversation
This was referenced Nov 2, 2026
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2688
Adds
CmsDbContextand the model builder that turns aSchemaModelinto the EF Core model of spec section 4.3:cms_document,cms_referenceandcms_versionas CLR entities, andcms_{type}andcms_{type}_i18nas property-bag entity types. It also addsContentScopeto the schema, because a global type changessite_idand its indexes. This is the provider-neutral model. Provider column types and SQLite's date convention arrive in #2689 through the newIStorageConventionshook. Everything is verified on SQLite only, per Q19.Dependencies:
Microsoft.EntityFrameworkCore10.0.14 andMicrosoft.EntityFrameworkCore.Relational10.0.14, referenced fromFluentCMS.Core.FluentCMS.Data.Testsalso referencesMicrosoft.EntityFrameworkCore.Sqlite10.0.14, which was already listed. The SDK gets no EF reference.Acceptance
Articletype 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.cms_{type}tableContentScope.Global) storesite_idnullSpec mismatch to settle
Appendix A puts
video_urlincms_article_i18n, but theArticleof section 4.2 does not markVideoUrl[Localizable]. The builder follows the flag, so the column lands incms_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, andCmsDbContexthas the singleDbContextOptions<CmsDbContext>constructor thatAddDbContext, pooling and design-time factories expect. Without it the constructor throws and says what to call.UseCmsModelalso replacesIModelCacheKeyFactory, so a caller cannot forget that.Model cache key. Context type, the SHA-256 of
SchemaJsonWriter's canonical JSON,IStorageConventions.CacheKeyand the design-time flag. The hash is computed once perSchemaModelinstance. 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 inFluentCMS.Core.Storage, withStorageConventionsas the neutral default of virtual no-ops.CacheKeyis 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 includingcms_version.snapshot, forjsonb,nvarchar(max)or ajson_validcheck.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 NULLis limited to what Appendix A shows, as general rules because the sample'sslugandbodyare not[Required]:[Required]textandlongText, 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.blocks, multi select, repeatable components, any otherMultiplefield) areNOT NULL DEFAULT '[]'.[Required], includingemail,urlandselect, because a draft has no honest placeholder for them and format checks would reject''.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.
integerislong: widening a populated column later is a migration, andSchemaValuealready holds whole numbers aslong.stateisshort, as Appendix A'ssmallint, so a property bag must hold a boxedshort;ContentState.DraftandContentState.Publishedare provided for that.status,status_afterandoriginare stored by enum name, as in the spike, so reordering an enum cannot corrupt rows.localeis 16 characters andtype,field,source_typeandtarget_type64, as in the spike.Default lengths. Appendix A sizes columns the sample leaves unsized, so
FieldStoragedefaultsslugto 200,urlto 2048 and singleselectto 64, or to its longest option if that is longer.emailis 254, which is my choice.textandlongTextwithout 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_referenceandcms_versionget anidv7 primary key beside their unique index. Section 4.3 names only the unique key; the ADR givescms_referenceanid, and this keeps all tables alike and lets a position change be an update.uq_{table}_..., leavingux_andix_to fields.[Indexed]on a localizable field isix_{table}_{field}on(site_id, locale, state, field). Locale is added because lists always filter on one locale, and the name uses the table, asux_cms_article_i18n_slugdoes.[Indexed]on a global type leavessite_idout, since it is always null.[Indexed]on a relation or media field adds nothing, becausecms_referencealready indexes the target.Single components. They nest by prefix (
home_geo_lat). The parent field'sLocalizabledecides 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.csis being changed by #2684, so these live inContentModelBuilderfor now, thrown together as aSchemaValidationException:state,documentIdorsiteId, none of which are reserved names today.seocomponent withmetaTitlebeside a fieldseoMetaTitle. The message names both.document,reference,versionorpage-i18nbeside a localizedpage. This compares against every entity mapped at that point, so system tables added later are covered if they map first.[Unique]on a relation, media or JSON field, which has no column to index.A field type with no storage mapping throws
InvalidOperationException, untilIFieldType.StorageKindreplaces the switch inFieldStorage.ContentScope.ITypeBuilder.ScopethrowsArgumentExceptionon a component or block, rather than reporting through the validator asLocalizeddoes, for the same reason. Inschema.jsona scope on a component or block, or an unknown value, is FCMS1213.Tagand its JSON twin are now global, as section 4.6 shows.Tests.
FluentCMS.Data.TestslinksSampleSchema.csand the sample types fromFluentCMS.Core.Tests, because a test project cannot reference another, andFluentCMS.Coremakes its internals visible to it.Architecture rule.
ApiDoesNotReferenceStorageOrCmsDbContextis no longer skipped.FluentCMS.Core.Apidoes not exist yet, so it usesWithoutRequiringPositiveResults()and starts checking with the first type there.Left for later.
tests/FluentCMS.Storage.Spike.Testsstays 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.