Core
Version 5.0.0
v5.0 is a Core rearchitecture, aiming for better project health, with clear separation between layers and a better separation of concerns.
Changed
- Any policy’s findings can be configured, not just the built-in ones.
WithDiagnostic(code, enforcement)configures one finding andWithDiagnosticsFrom(source, enforcement)a producer’s whole family, replacingDestructiveActionOptionsandDataHazardOptions— a policy no longer needs its own options type and a builder method to be configurable, so a policy you register is configurable too. A policy reports at the severity it judges natural and the engine applies the override, soWithDestructiveActionsandWithDataHazardsremain as named shortcuts for the two built-ins. - A diagnostic’s
Sourceis what produced it, and only that. The differ reported findings under four topics (scope,directives,run-once,data-migrations) where the producer was the differ; those topics now live in the code, which is where a finding’s identity belongs. - Column alterations are unified. Dialects now receive a single
AlterColumnaction for a column’s type and nullability changes, which they can render as one or more statements. - Member diffs are built by factory.
PrimaryKeyDiff,ForeignKeyDiff,UniqueConstraintDiff,CheckConstraintDiff,ExclusionConstraintDiff,IndexDiffandTriggerDiffexposeAdded(definition),Removed(name)andCommentChanged(name, change)in place of a constructor. - Object diffs are built by factory too. Every schema-level diff — table, column, view, enum, domain, sequence, routine, composite type, extension and schema is constructed through
Added/Removed/Modifiedand refined withwith, replacing constructors. - Better domain mode naming.
DatabaseandSchemaare now the main entry points into the domain model. - The SQL dialect seam is the abstract
SqlDialectclass. A dialect overrides one method per migration action. Standard SQL is rendered by the base through an overridable identifier-quoting kernel. - Identifiers are case-sensitive. An identifier’s identity is its exact written text:
usersandUsersare no-longer considered equivalent. - Identifiers can be quoted.
CREATE TABLE app."Order Details" ("weird ""col""" int)all work, and lets a name collide with a keyword. Square brackets are accepted as an alternative spelling, soCREATE TABLE app.[Order Details] ([weird ]]col]]] int)reads the same (]]escapes a bracket). Quotes and brackets are syntax, not identity: casing is significant with or without them, and either spelling names the same object. The writer (and import) quotes only names that need it, always with double quotes, and extension names now render as quoted identifiers rather than string literals. - Parsing is lossless. The syntax tree now preserves every character of the source, including comments, whitespace, and layout.
- Management directives. The language now separates declarations (what the schema is) from directives (how the difference is managed). This includes RENAME and SCRIPT.
- Every namespace has moved. Namespaces are vertically sliced of the form
NSchema.<Feature>.<Capability>. - A slice’s domain layer is
Domain, notModel. - The shared primitives live in
NSchema.Diagnostics. - The schema model is
NSchema.Modelnow. It owns the top-level domain model for databases. - The model is layered by what contains what. A
DatabaseObjectbelongs to the database, aSchemaObjectto a schema, and anObjectMemberto a schema object. - An element’s
Addressis never null. A schema object addresses itself as anObjectAddressand a member as aMemberAddress, so neither needs narrowing or a cast. - A kind belongs to one level.
DatabaseObjectKind,SchemaObjectKindandMemberKindreplace the singleObjectKind, which spanned schemas’ contents and the database’s own. - An address is named for where it sits, not for what it names.
DatabaseAddressreplacesSchemaAddressand carries the kind as data (DatabaseAddress.Schema(name),DatabaseAddress.Extension(name)), so a new database-level kind needs no new address type.MemberAddresstakes an optional kind, matchingObjectAddress. - A script is identified, not addressed. A script decorates the schema rather than living in it, so
ScriptReferencereplaces the address it used to carry andScript.ReferencereplacesScript.Address. A script’s scope is a schema name rather than an address, and the run-once ledger keys on the reference. With extensions addressed as the database objects they are, no address carries an optional schema any more. - The diff interfaces name the same levels as the model.
IDatabaseElementDiffreplacesINamedObjectDiffas what every diff node shares, andIObjectMemberDiffis the member level — so an index and a trigger diff sit with the columns and constraints rather than alongside an extension.IMigratableDiffnarrows to a member diff that can carry a script, andTableDiff.EnumerateMembers()returns the member abstraction. - A diff node’s
Changereplaces itsKind.Kindnow means what sort of thing something is, so the diff says what happened to it instead:diff.Change == ChangeKind.Add. ChangeKind.Touchedreplaces a null change. A schema in the diff only because its contents changed isTouchedrather than carrying no change at all, soSchemaDiff.Changeis always present andIDatabaseObjectDiffcovers both schemas and extensions.Touchedis the enum’s default, so an unset change reads as “unchanged” rather than “create this”.- DataMigrations are Scripts now. This reflects the syntax changes introduce in [4.4.0] so the model becomes consistent.
- Templates accept object-level directives. A
TEMPLATEbody may now contain the object-levelRENAMEdirective (table, column, view, enum, domain, type, sequence, routine) alongside its declarations and scripts. - Scripts split into
ChangeScriptandDeploymentScript.Scriptis now an abstract base carrying the common behavior (name, SQL, scope, hash, reference, run condition); - “Desired” is Project now.
IDesiredSchemaProviderbecomesIProjectProvider— the project is the desired state by definition, andapp.DesiredSchemabecomesapp.Project, named for what it hands you like every surface beside it. Progress output follows:Loading project...,Validating project..., and the plan census now readsDeclared:/Recorded state:. AddDdlSchemasisAddProjectSourcenow. The files describe the whole project (schema, scripts, templates, config), not just schema DDL.- Result
use consistency. Lots of interfaces have been neatened up to return aResult<T>instead of throwing to allow for error/warning accumulation. - The diff now includes scripts. Rather than being tacked on to the plan, scripts are now a first-class part of the diff, carried where they run rather than in a central list.
- Cohesive plan artifact. There’s now a single
MigrationPlanmodel that represents the plan in its entirety rather than being spread acrossSqlPlan,PlannedMigration, etc. - Providers are required. Providers are now required for planning, because the SQL is built into the plan model.
- Plan errors are non-blocking. Even when the plan has errors, you can now still access the resultant plan.
- Planning always diffs recorded state against the project. There is no longer an option to plan against the live database.
- A teardown is a plan towards an empty schema.
PlanTarget.EmptyreplacesPlanTarget.Teardown, and it obeysPlanArguments.Scopelike any other plan. - Teardown plans run the policies. A teardown is fully destructive, so the default destructive-action policy blocks it; the blocked result still carries the complete plan. Set the destructive-action enforcement to
Allowto apply one. - Policies are enforced at apply.
Applynow re-runs all policies against the plan diff before executing. - Policies now cover project and plan.
IProjectPolicyreplacesISchemaPolicyandIPlanPolicyreplacesIDiffPolicy. SqlDialectreplacesISqlGenerator. (registered withUseSqlDialect<T>()).IStateLockManagerreplacesIStateLockCoordinator. Lines up with withISchemaStateManager.IPlanFileManagerreplacesIPlanFileWriter. It reads saved plans too, so “writer” undersold it.UseFileStatereplacesUseFileStateStore. It registers the lock alongside the store, so “store” undersold it — matchingUseEphemeralState, which does the same.UseStateStoreandUseStateLockstill register a single seam each.TableDiff.PrimaryKeysreplacesTableDiff.PrimaryKey. It has always been a list — replacing a key is a remove and an add — so the singular name misread as one change.- The serialization types live in
NSchema.Model.Serialization. They move out ofNSchema.Model.Services, which is for services over the model. options.AddModelConverters()composes the model’s JSON conventions. A consumer serializing NSchema types adds the converters to its ownJsonSerializerOptionsrather than naming them — persistence wants indented, complete output and a console stream wants terse single lines, so only the conventions are shared. The converters themselves are internal now.IDatabaseIntrospectorreplacesISchemaProvider. More honest about what it does now that the interface doesn’t serve both the current and desired sides, and named for what it returns.- Plugin
ConfigurereturnsResult. Configuration errors are diagnostics like everything else. - Opaque SQL is
SqlTextnow. Every schema-model field carrying SQL that NSchema stores verbatim but does not interpret is typedSqlTextinstead ofstring. - A qualified type’s schema is a component now.
SqlTypecarries the schema of a user-defined type (e.g.appinapp.order_status) as a structuralSchemaproperty rather than folded into its nam. PolicyEnforcementabsorbs theDestructiveActionPolicyenum. The shared enum gainsIgnore.WithDestructiveActionsandWithDataHazardsreplaceWithDestructiveActionPolicyandWithDataHazardPolicy. They set how a built-in policy is enforced, which the old names read as registering one —AddPlanPolicy<T>()is what registers.- The state ledger field is
scriptsnow. Pre-5.0executedScriptspayloads read as an empty ledger. Refresh (or untaint) existing state under the state-format compatibility policy’s major-version rules. - Configuration is part of the language.
ENGINE,PLUGIN,DATABASEandSTATEparse as ordinary statements, in the one grammar that carries declarations and directives. Which statements a given file should hold is a consumer’s rule, not the parser’s: each subsystem picks out the statements it understands and ignores the rest. DATABASEandSTATEreplacePROVIDERandBACKEND. Each names the thing it configures rather than the role that supplies it.- Every setting is environment-overridable.
NSCHEMA_<KEYWORD>_<SETTING>overrides one setting on the matching statement —NSCHEMA_DATABASE_CONNECTION_STRINGsetsconnection_stringonDATABASE— replacing the written value or supplying one the statement omits, so a secret need not be committed. The keyword scopes the name becauseDATABASEandSTATEmay both take a setting of the same name. - Plugins receive
PluginSettings.Configuretakes the statement’s label and setting values as a flat key/value map (settings.Values, orsettings.Value(name)for one), translated from the parsed statement by the configuration assembly.settings.Get<T>()binds them onto an options type — snake_case keys match properties, dotted keys nest, identifiers map to enum members — and reports an unbindable value, an unknown setting, and a failed[Required]/[Range]as error diagnostics, so a plugin declares its options rather than parsing them. INSchemaDatabasePluginandINSchemaStatePluginreplaceINSchemaProviderPluginandINSchemaBackendPlugin. Each is named for the statement that configures it.- Plugins are resolved by capability, not by name.
INSchemaPlugin.Labelis gone — the statement kind selects the capability interface, and the label in configuration is the user’s local name for a declaredPLUGIN, never the plugin’s own.ScaffoldContext.Versionis gone with it: the host authors thePLUGINstatement (it knows the package and the resolved version), so a plugin’s scaffold template contributes only its own configuration statement. NsqlReaderreplacesDdlReaderand diagnostics are structural.NsqlReader.Read/ReadFilereturnResult<NsqlDocument, NsqlDiagnostic>, the new diagnostic-typed result, with each finding carrying its source position.DdlReader.ReadreturnsResult<DdlDocument>. A syntax error is an error diagnostic instead of a thrown exception, and the parser now recovers at statement boundaries.DatabaseSchemais pure data now.FilterjoinedCombineoff the model, into the projection machinery.DiffDocument.From(diff)replacesDiffReader.Read(diff). The reader type and its.Defaultsingleton are both gone, and the namespace isNSchema.Diff.Rendering: “reader” named the wrong direction, since aDatabaseDiffis projected into display lines rather than parsed from anything.SchemaScopereplaces bare schema-name arrays.GetProject,GetSchema, and the plan/drift/import arguments take a scope record.- State locks receive complete metadata.
IStateLockManager.AcquiretakesAcquireLockArgumentswith the operation, TTL, and skip-lock; it creates theStateLockInfoatomically recorded byIStateLock.Acquire. - Project reads report every broken file at once. An unreadable or unparseable file (and no-files-matched) is an error diagnostic on the project.
- Index keys and exclusion elements are column-or-expression now.
IndexColumnandExclusionElementcarry mutually exclusiveColumn(an identifier) andExpression(verbatim SQL) properties. - References are value objects now.
Trigger.Functioncarries aRoutineReference(optionally schema-qualified; unqualified resolves via the engine’s search path) ObjectAddressaddresses a schema-level object. Always fully qualified. Each part compares as an identifier. State and plan-file payloads serialize the address structurally.- Object names are
SqlIdentifiernow. Every name-bearing property across the schema, diff, plan, and state models carries a value object. - Template migrations are decoupled from their tables. Migrations can be declared in any template for any table.
- Scripts execute as woven statements. The linearizer weaves the diff’s scripts into the ordering so scripts are now first-class actions.
- Planning and applying now require a state store. Use the new ephemeral store if you need to run without persistent state for CI or integration tests.
- State records what NSchema manages. Alongside the full observation and the run-once ledger, state carries the managed identity set: what an apply has created or adopted. A plan only ever covers managed/declared objects, so an object missing from the project only triggers a drop when the state contains a managed object, and objects outside the managed state are never touched.
- Teardown destroys what NSchema manages.
Plan(Empty)converges the managed set — not everything ever observed — towards nothing. - Managed extensions honor removal-by-absence. A declared extension becomes managed on apply and is dropped when un-declared.
- A foreign key into an undeclared table is a warning, not an error. The target may exist unmanaged (gradual adoption), so the structural policy advises instead of blocking.
- Constraints fold into table creates. A newly-created table’s foreign keys, unique and check constraints are now rendered inline in its
CREATE TABLErather than as trailingALTER TABLE ADD CONSTRAINTstatements.
Added
- Enforcement that cannot be honored says so. Configuring a structural finding to be reported more leniently reports
cannot-be-loweredrather than quietly doing nothing, once per finding however many times it occurs. - A finding says whether it may be overruled.
DiagnosticKindmarks a finding asStructural— NSchema cannot do what was asked, so silencing it would produce something wrong rather than something permitted — orAdvisory, a judgement the caller is entitled to overrule. Enforcement can raise any finding, but can only lower an advisory one, at whatever severity it currently carries. Reporting below error severity declares a finding advisory, so a warning or an informational finding is silenceable by default and an error is not — and because the kind is fixed when the finding is created, raising one later cannot change what a caller is allowed to silence. - A diagnostic’s source is a
DiagnosticSource. The producer’s name is typed rather than a bare string, and holds to the same shape as a code — a user groups and configures by it, so it has to be usable as a settings key too. - A diagnostic carries a code.
Diagnostic.Codenames a finding independently of how it is worded, so a message can be reworded without breaking anything that refers to it. A code is restricted to hyphen-separated lowercase words, so it is usable as a settings key, and an invalid one is rejected rather than rewritten. Every code is unique across NSchema — the producer is not always known at compile time, so the code alone addresses a finding. - A diagnostic collection folds to a result.
ToResult()andToResult(value)move ontoDiagnosticCollection<TDiagnostic>, so accumulating findings and returning them needs no collector. A collection can also be built from a collection expression. - A typed result lifts like a plain one.
Result<TValue, TDiagnostic>converts implicitly from a value and from a single diagnostic, and a value-lessResultconverts from a diagnostic — so a method returns one directly instead of naming the result type to construct it. SqlDialect.CanAlterForeignKeys. A dialect states whether a foreign key can be added to, or dropped from, a table that already exists. One that cannot (SQLite) keeps every key on theCREATE TABLEthat declares it, and the plan never separates one from its table.- The comparison seam is the
SqlEquivalenceclass. A provider can register (UseSqlEquivalence<T>()) one equality comparer per comparison context, deciding when two spellings mean the same thing. The neutral base compares types structurally and defaults on cosmetics-normalized text. - Default expressions are
SqlDefaultExpressionnow.Column.DefaultExpressionandDomainType.Defaultgraduate fromSqlTextto their own value object, so equivalence rules register against the specific context. - Change-script targets are value objects.
ChangeScriptnow takes aChangeTargetinstead of separate trigger and path values. - Atomic table-fragment merge.
Table.TryMergeMembersapplies a complete table-member fragment or reports its conflicts. - SQL type conversion risk.
SqlType.ConversionRiskToassesses whether converting stored values can fail. - Ephemeral state.
UseEphemeralState()registers an in-memory state store, which serves as the lock too, intended for disposable databases. - Object-granular targeting.
PlanningScopecovers a single list ofAddresses (PlanningScope.To(addresses)/scope.Addresses), mixing whole-schema and object-level targets. - Address parsing.
NsqlReader.ReadAddressparses aschema,schema.object, orschema.object.memberfragment into anAddress, resolving quoted segments - Address containment.
Address.Covers(other)expresses downward containment (a schema covers its objects and members; an object covers its members). - Scope and identity are address-based.
PlanningScopeis scoped when it holds any address (schema or object). ObjectAddresscarries an optionalKind. A null kind addresses every kind sharing the name (kind-free targeting); a set kind disambiguates same-named objects.Token.QuotedIdentifier. Synthesizes a quoted-identifier token (decoded text, quoted-and-escaped raw), the counterpart toToken.StringLiteral.- Configuration statements are built by factory.
SettingsStatement.Database(label)/.State(label)/.Plugin(label)/.Engine()/.Lock()replace the constructor, refined withWithSetting(key, value),WithDocComment(text)andWithLeadingComment(text). - A plugin declares its scaffolding questions.
INSchemaPlugin.GetScaffoldPrompts(context)returns theScaffoldPrompts a front-end should put to the user, and the answers arrive onScaffoldContext.AnswersforGetScaffoldTemplateto build its statement from. PLUGINdeclares plugin dependencies.PLUGIN <label> ( source = '…', version = '…' );separates dependency declaration from configuration.ENGINEasserts the engine and/or host version.ENGINE ( version = '…', host_version = '…' );—versionis checked against the engine (Core),host_versionagainst the host tool.- The engine handshake.
PluginHandshake.Validatechecks a loaded plugin assembly against the hosting engine before any of its types are instantiated. ConfigurationProvider.Loadloads a configuration. One call from ordered configuration layers (a later layer overrides an earlier one) to a validatedConfigurationDefinition.- The plugin lockfile.
LockFileManagerreads and writesnschema.lock(aLOCK ( source = '…', version = '…' );grammar) as aLockFileofLockedPluginpins.LockFile.Resolve(declaration)resolves a declaration against the lock. VersionRange.IsExact/ExactVersion/Highest. Report whether a range pins a single version and the version it pins, and select the highest of a supplied set of versions that the range admits.- Plugins split from configuration. The provider interfaces stay in
NSchema.Plugins(INSchemaPluginand friends, the handshake,ScaffoldContext); everything a project declares, now lives underNSchema.Configuration.PluginSettingsis inNSchema.Configuration.Plugins.
Removed
Address.SchemaName. An address answers where it sits through its own components.PRE|POST DEPLOYMENT '<name>' AS $$…$$;andMIGRATION ['<name>'] FOR <event> <path> AS $$…$$;no longer parse.- The
DROPstatements (DROP SCHEMA|TABLE|VIEW|ENUM|DOMAIN|TYPE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|EXTENSION) no longer parse. Remove the declaration instead. CREATE PARTIAL SCHEMAno longer parses. The managed identity set covers what it was reaching for: an object the state does not record as managed is never dropped, declared or not.RENAMED FROMclauses no longer parse. A rename is aRENAMEdirective now (see Management directives above).- The
NSCHEMAconfiguration statement no longer parses. DataMigrationhas been folded intoScriptand now requires a name for so they can maintain a stable identity.- Narrowed public surface. A variety of types that should never have been exposed have been made internal.
PolicyDiagnostics,PluginConfigureResult, and theDestructiveActionPolicyenum — all made redundant by first-class severity onResultand the sharedPolicyEnforcement.DdlSyntaxException,PlanFileDeserializationException, andStateDeserializationExceptionare now internal; the read seams surface these failures as diagnostics.MigrationAction.IsDestructivehas been removed. Destructiveness is judged from the diff byDestructiveActionPolicy, not per action.ViewDiff.DependsOnhas been removed.
Fixed
- Tables are created and dropped in foreign-key order. A table is now always created after the tables it references and dropped before them, across schemas, so a teardown no longer fails on a table another one still points at. Where tables point at each other no order can satisfy every key, so the ones it cannot are moved out of the way: on a drop the constraint goes first, and on a create it is added once both tables exist rather than riding the
CREATE TABLE. A dialect that cannot alter foreign keys says so withSqlDialect.CanAlterForeignKeys, and keeps every key on the table that declares it. - Schemas are no longer created implicitly. Creating an object without also creating its schema will no-longer auto-adopt the parent schema.
- Adoption-only plans now count as non-empty. Running a plan that changes nothing, and only imports objects into the managed set now correctly reports as having changed.