ModelSync keeps database structure close to your .NET code without turning the project into an ORM. It can generate provider-specific table SQL from attributed C# models, compare models with a live database, run ordered SQL migrations, synchronize stored procedures, and leave a readable migration report behind.
Framework-owned SQL has one authority: Core. Provider packages supply structured capabilities, identifier rules, mappings, connections, and thin execution adapters. Database create/reset plans and provider-specific drop behavior are compiled in Core; provider runners execute those plans without maintaining a second SQL implementation.
It is a good fit for Dapper, ADO.NET, hand-written SQL, and services where schema changes should remain visible in code review.
ModelSync is more than a DDL generator. Its migration runner can execute reviewed
table, stored procedure, trigger, seed, and custom SQL artifacts with ordering,
history, hashes, locking, and transaction policies. This is a SQL-first data
migration model: data transformations belong in versioned seed or CustomSql
scripts rather than EF-style C# Up/Down classes.
Database relationships are represented as schema metadata, not ORM navigation
properties. Primary and composite keys, unique constraints, indexes, foreign
keys, defaults, and checks can be planned and compared where the selected
provider supports them. ModelSync does not provide Include, change tracking,
LINQ translation, or automatic object-graph persistence.
Current package version: 1.5.1
Release status: This repository is prepared for the stable
1.5.1package contract. Read the 1.5.1 release notes and the 1.5.0 to 1.5.1 migration guide before upgrading a production migration job.
| I need to... | Use |
|---|---|
| Generate or execute table DDL from one model | TableGenerator |
| Compare attributed models with a live database | ModelSynchronizer |
| Run ordered table, procedure, trigger, seed, and custom SQL files | MigrationRunner |
| Synchronize procedure files directly | StoredProcedureSynchronizer |
| Validate, preview, and run migrations from a terminal or CI | modelsync CLI |
Start with the full usage guide when choosing between these workflows.
| Package | Purpose |
|---|---|
UmbrellaFrame.ModelSync.SqlServer |
SQL Server and Azure SQL |
UmbrellaFrame.ModelSync.MySql |
MySQL and MariaDB |
UmbrellaFrame.ModelSync.PostgreSQL |
PostgreSQL |
UmbrellaFrame.ModelSync.SQLite |
SQLite |
UmbrellaFrame.ModelSync.Oracle |
Oracle preview: table DDL and safe model comparison |
UmbrellaFrame.ModelSync.Analyzers |
Compile-time model checks |
UmbrellaFrame.ModelSync.Cli |
Migration CLI and Markdown/JSON reports |
Provider packages pull UmbrellaFrame.ModelSync.Core automatically.
dotnet add package UmbrellaFrame.ModelSync.Core --version 1.5.1
dotnet add package UmbrellaFrame.ModelSync.SqlServer --version 1.5.1
dotnet add package UmbrellaFrame.ModelSync.MySql --version 1.5.1
dotnet add package UmbrellaFrame.ModelSync.PostgreSQL --version 1.5.1
dotnet add package UmbrellaFrame.ModelSync.SQLite --version 1.5.1
dotnet add package UmbrellaFrame.ModelSync.Oracle --version 1.5.1
dotnet add package UmbrellaFrame.ModelSync.Analyzers --version 1.5.1Oracle remains a preview provider. Its migration runner, stored procedure synchronization, reset, and native lock features are not production-ready yet. See the provider support matrix before adopting it.
Oracle note: the preview package is published for evaluation, but its support surface is intentionally smaller than the four stable providers.
The modelsync CLI currently runs migrations for SQL Server, MySQL, MariaDB, PostgreSQL, and SQLite. Oracle is not exposed through the CLI until its migration runner reaches production-ready status.
using UmbrellaFrame.ModelSync.Core;
using UmbrellaFrame.ModelSync.SqlServer;
[SqlServerTableName("Products")]
public sealed class Product
{
[SqlServerColumnType(SqlServerColumnType.INT)]
[SqlServerColumnPrimaryKey(isAutoIncrement: true)]
public int Id { get; set; }
[SqlServerColumnType(SqlServerColumnType.NVARCHAR, "200")]
[SqlServerColumnNotNull]
[SqlServerColumnIndex("IX_Products_Name")]
public string Name { get; set; } = string.Empty;
[SqlServerColumnType(SqlServerColumnType.DECIMAL, "18,2")]
[SqlServerColumnDefault("0")]
[SqlServerColumnCheck("Price >= 0")]
public decimal Price { get; set; }
}
var generator = new SqlServerTableGenerator(connectionString);
var sql = generator.GenerateSqlServerTable<Product>(ifNotExists: true);
Console.WriteLine(sql); // review before execution
await generator.CreateTablesAsync();Generate...Table<T>() generates and caches SQL. CreateTablesAsync() executes the cached statements. Index SQL can be reviewed separately with GenerateIndexSql<T>().
Additive operations are explicit:
await generator.AddColumnAsync<Product>("Stock");Operations that may lose data require visible approval:
var allow = DestructiveOperationOptions.Allow();
generator.DropColumn<Product>("LegacyCode", allow);
generator.AlterColumnType<Product>("Price", allow);
await generator.DropTablesAsync(allow);ModelSync never treats this approval as permission for arbitrary automatic destructive model changes. Live synchronization still reports drop, rename, narrowing, and risky nullability changes instead of silently applying them.
Use a model synchronizer when the database already exists and you want a reviewable diff:
var options = new SqlServerModelSyncOptions
{
ConnectionString = connectionString,
DefaultSchema = "app",
HistorySchema = "sec",
ReportUnmappedTables = false
};
var result = await SqlServerModelSynchronizer
.FromAssemblies(options, typeof(Product).Assembly)
.CompareAsync(cancellationToken);
// Inspect AutomaticOperations, ManualOperations, SkippedOperations,
// and BlockedOperations before applying anything.
await result.ApplyAsync(cancellationToken);For SQL Server, MySQL/MariaDB, PostgreSQL, and SQLite, model-sync mutation now
uses the same migration lifecycle as registered scripts: deterministic ordering,
provider locking, transaction policy, history/hash tracking, and structured
execution results. CompareAsync() remains read-only.
options.TransactionPolicy = MigrationTransactionPolicy.Auto;
options.LockOptions.Enabled = true;
var execution = await result.ApplyWithResultAsync(cancellationToken);Review execution.Items before treating a deployment as complete. Oracle
preview still uses its limited direct execution path because its migration
runner and native lock are not production-ready.
Table policies can mix ownership in the same run:
options.DefaultTableMode = ModelSyncTableMode.ManualOnly;
options.TablePolicies
.ForType<AuditLog>(ModelSyncTableMode.ApplySafeChanges)
.ForTable("legacy", "OldOrders", ModelSyncTableMode.Ignore);ManualOnly changes are reported but never executed. ApplySafeChanges never authorizes destructive changes.
For CI or pull-request review, render the comparison without executing it:
var markdown = ModelSyncPlanMarkdownReport.Create(result);
var json = ModelSyncPlanJsonReport.Create(result);Changed default and check expressions are reported as review-required drift and are never silently replaced. Report SQL is bounded and literal values are redacted.
The 1.5 package line also closes several correctness and reporting defects:
| Area | Previous behavior | Corrected behavior |
|---|---|---|
| Check introspection | A discovered table check could be copied to unrelated columns | Only the deterministic CK_<Table>_<Column> constraint is attached to its matching column |
| Check convergence | CREATE TABLE emitted an unnamed inline check, so a second comparison could plan it again |
Generated checks are named consistently; SQLite also reads them from the stored table definition |
| Drift evidence | A model snapshot could be compared with a live snapshot even though they represent different evidence | verify-drift rejects incompatible model-to-live comparisons |
| Report safety | Plan reasons and fallback exception messages could retain SQL literal or secret-like text | JSON, Markdown, SQL previews, reasons, and fallback errors use centralized bounded redaction |
| Provider normalization | Catalog formatting differences could appear as schema changes | Supported quote, parenthesis, decimal, cast, referential-action, and identity variations are normalized before comparison |
| Analyzer identity | Look-alike user attribute names could satisfy mapping rules | Rules resolve real ModelSync symbols and base types across every provider |
| CLI option safety | Unknown, duplicate, or conflicting options could be accepted silently | Invalid option sets fail before comparison or execution; dry-run SQL is bounded and redacted |
| SQLite check lookup | A longer constraint name could match a shorter deterministic-name prefix | Only an exact quoted constraint name is accepted |
See the 1.5.0 release notes for the underlying root causes, provider coverage, and validation evidence.
Canonical snapshot contracts are available for tooling through SchemaSnapshotFactory; snapshots are evidence, not automatic rollback instructions.
Create deterministic model and live-database evidence from the same synchronizer:
var synchronizer = SqlServerModelSynchronizer
.FromAssemblies(options, typeof(Product).Assembly);
var modelSnapshot = synchronizer.CreateModelSnapshot(sourceCommit: gitCommit);
var liveSnapshot = await synchronizer.CreateDatabaseSnapshotAsync(
asBaseline: true,
sourceCommit: gitCommit,
cancellationToken);
var json = SchemaSnapshotSerializer.CreateJson(liveSnapshot);Snapshot creation is read-only. asBaseline labels the evidence; it does not authorize rollback or database mutation.
Use verify-drift with an approved live-database baseline and a later live-database
snapshot from the same provider and scope. Model and live snapshots serve different
review purposes; the CLI rejects model-to-live hash comparisons instead of returning a
misleading drift result. ModelSync-created check constraints use deterministic
CK_<Table>_<Column> names so supported providers, including SQLite, converge on the
next comparison. A differently named custom check remains review-required because
ModelSync cannot safely infer that arbitrary expressions are semantically equivalent.
When a property was deliberately renamed, declare its previous database name explicitly:
[DbPreviousColumnName("OldCode")]
public string Code { get; set; } = string.Empty;ModelSync reports a single review-only RenameColumn operation when OldCode exists and Code does not. It never guesses renames from similar names and never executes a rename automatically. Ambiguous hints are reported as unsupported.
Composite indexes and foreign keys can be described at table level:
[DbTableIndex(
"IX_Orders_Tenant_Code",
"TenantId", "Code",
IsUnique = true,
IncludedColumns = new[] { "DisplayName" },
Filter = "Code IS NOT NULL",
SortDirections = new[] { DbIndexSortDirection.Ascending, DbIndexSortDirection.Descending })]
[DbTableForeignKey(
"FK_Orders_Tenants",
typeof(Tenant),
new[] { "TenantId", "Region" },
new[] { "Id", "Region" },
OnDelete = DbReferentialAction.Cascade)]
public sealed class OrderSchema
{
// Provider-specific column attributes remain unchanged.
}These declarations feed the Model Synchronizer canonical plan. Provider capabilities decide whether SQL can be generated; unsupported include, filter, sort, foreign-key, or referential-action combinations are blocked rather than silently simplified. Filter expressions are reviewed SQL and must never contain untrusted input.
var runner = new SqlServerMigrationRunner(connectionString);
runner.RegisterScriptFile("Database/Scripts/Tables/001_CreateProducts.sql");
runner.RegisterScriptFile("Database/Scripts/StoredProcedures/010_GetProducts.sql");
runner.RegisterScriptFile("Database/Scripts/Triggers/020_ProductAudit.sql");
runner.RegisterScriptFile("Database/Scripts/Seeds/030_DefaultProducts.sql");
runner.RegisterScriptFile("Database/Scripts/CustomSql/999_AfterSetup.sql");
var plan = await runner.CompareRegisteredAsync(cancellationToken); // read-only
var result = await runner.RunWithResultAsync(cancellationToken);Execution order is Tables -> StoredProcedures -> Triggers -> Seeds -> CustomSql. History tables and SQL hashes make repeated runs predictable. Application-supplied SQL remains trusted project content; ModelSync does not attempt to prove arbitrary SQL safe.
dotnet tool install --global UmbrellaFrame.ModelSync.Cli --version 1.5.1Keep connection strings out of process arguments:
export MODELSYNC_CONNECTION_STRING='Data Source=modelsync-preview.db'
modelsync validate --scripts ./Database/Scripts
modelsync run \
--provider sqlite \
--connection-env MODELSYNC_CONNECTION_STRING \
--scripts ./Database/Scripts \
--dry-run
modelsync run \
--provider sqlite \
--connection-env MODELSYNC_CONNECTION_STRING \
--scripts ./Database/Scripts \
--apply \
--report-md ./artifacts/modelsync-report.md \
--report-json ./artifacts/modelsync-report.json--apply is required for mutation. --connection remains available for compatibility but can expose secrets in process listings. Ctrl+C is propagated to migration operations.
Reset is intentionally difficult to trigger accidentally:
var options = new MigrationRunnerOptions
{
ResetDatabase = true,
ResetOptions = new DatabaseResetOptions
{
Enabled = true,
Approval = DestructiveOperationOptions.Allow(),
ExpectedDatabaseName = "AppDb",
EnvironmentName = "Development",
AllowedEnvironments = new[] { "Development" },
BackupBeforeReset = true, // SQL Server only
BackupDirectory = @"C:\SqlBackups"
}
};System databases are rejected. SQL Server reset runs before the native migration lock is acquired, then readiness, infrastructure, history, and scripts continue under the normal lock. Backup paths are evaluated from the SQL Server service account's filesystem.
MySQL/MariaDB and PostgreSQL use the same approval and expected-database checks. PostgreSQL executes session termination, DROP DATABASE, and CREATE DATABASE as separate administrative commands because these operations cannot share one command pipeline. Live integration tests reset each supported engine twice and verify that tables, seeds, history, readiness, and locking converge after recreation.
ResetDatabase = true requires ResetOptions.Enabled = true, explicit reset approval, and ExpectedDatabaseName. Legacy DestructiveOptions.Allow() alone is intentionally rejected for database reset. Failed migration reports include bounded diagnostics and redact SQL string literal values from failed-batch previews.
Use database reset from a single deployment job or local development runner. Do not let multiple application instances start with ResetDatabase = true; the target database can be dropped before the provider-native target lock exists.
| Feature | SQL Server | MySQL/MariaDB | PostgreSQL | SQLite | Oracle preview |
|---|---|---|---|---|---|
| Table and index DDL | Yes | Yes | Yes | Yes | Yes |
| Safe live model sync | Yes | Yes | Yes | Yes | Partial |
| Ordered migration runner | Yes | Yes | Yes | Yes | No |
| Stored procedures | Yes | Yes | Yes | No | No |
| Native migration lock | Yes | Yes | Yes | SQLite write lock | No |
| Controlled DB reset | Yes | Yes | Yes | Limited | No |
The detailed and tested limitations live in the provider support matrix.
- ModelSync is not an ORM and does not provide LINQ, entity tracking, or runtime CRUD.
- Provider-specific raw default/check attributes and migration scripts contain reviewed SQL. Never build them from untrusted user input. The legacy Core attributes remain available only for 1.x source compatibility; new models should use provider-specific attributes.
- Prefer a deployment-time migration job. If migrations run during application startup, keep provider-native locking enabled.
- Compare APIs are read-only. Infrastructure is created only by explicit mutation APIs.
- Published NuGet versions are immutable. Older packages are not overwritten or unlisted to hide migration work.
| Topic | Document |
|---|---|
| Start here | Documentation index |
| Complete usage | Full Usage Guide |
| Provider details | Provider guides · Support matrix |
| Migration runner | Migration runner |
| Live synchronization | Model Synchronizer |
| CLI and reports | CLI guide · Reporting |
| CLI and scaffolder direction | Roadmap |
| Maturity roadmap | General recommendation roadmap |
| Current release | 1.5.1 notes · Migration guide |
Start with the release notes before upgrading. Behavior-sensitive changes are paired with a focused migration guide, while the complete history remains in CHANGELOG.md.
Development continues on main, but stable NuGet releases are intentionally grouped into meaningful 3-6 week cycles. Feature work goes through preview and RC channels; migration-sensitive minor releases normally remain in RC for at least seven days. Critical security, data-loss, and migration-correctness fixes may ship sooner as patches. Documentation-only changes do not create a package version. See the versioning and compatibility policy.
dotnet restore ModelSync.sln
dotnet build ModelSync.sln -c Release --no-restore
dotnet test ModelSync.sln -c Release --no-build --filter "Category!=Integration"
dotnet run --project tools/UmbrellaFrame.ModelSync.RepositoryChecks -- verify-allLive provider tests use compose.integration.yml and explicit local-only credentials. Start that environment with docker compose -f compose.integration.yml up -d; do not reuse its credentials outside tests.
The opt-in UmbrellaFrame.ModelSync.ScaleTest suite creates one million rows on SQL Server, MySQL, MariaDB, PostgreSQL, SQLite, and Oracle. It measures live schema comparison, safe column and index additions, idempotent re-runs, destructive-change blocking, and migration history where the provider supports it. The regular provider integration suite remains the authority for reset, lock, routine, trigger, seed, constraint, and foreign-key behavior. Run the scale suite locally after starting Docker:
$env:MODELSYNC_RUN_SCALE_INTEGRATION = "1"
dotnet test UmbrellaFrame.ModelSync.ScaleTest/UmbrellaFrame.ModelSync.ScaleTest.csproj -c Release --filter "Category=Scale"
The same matrix runs weekly and on demand through the Million Row Scale Tests GitHub Actions workflow. Scale timings are environment evidence, not a production latency guarantee.