In an ASP.NET Core application with multiple developers, occasionally a developer will check in model changes that aren't fully represented in the EF Core model, be that via having forgot to make a migration, having made changes due to feedback but not updating the migration, or by adding new database elements that aren't represented by migrations (view-mapped entities) and not taking the time to make an empty migration to update the model snapshot.
In my never-ending quest to reduce the amount of extranious checks (and brain power) it takes to review a PR, I set out to create a PR check that could ensure no PR would pass review without having its migrations be up to date with the models.
In our project we have multiple DbContext
s, so I wanted to make a script that would work for any number of contexts and wouldn't need to be updated automatically if we ever added or removed a context.
I'm using PowerShell for this and all our DbContexts are named nicely so a simple Get-ChildItems + filter worked just fine.
$DbContextNames = (Get-ChildItem -Path . -Recurse -File -Filter "*DbContext.cs" | Sort-Object -Property LastWriteTime -Descending).BaseName;
It also works just fine with just one context.
When you make a EF Core migration, 3 things are created or change:
Up()
and Down()
methods) is created..Designer.cs
file is created next to the migration that contains the current model snapshot for the migration.When you create a migration and all the existing migrations are up to date, an empty migration is created (nothing in the Up()
or Down()
methods) and the ModelSnapshot.cs remains unchanged.
It's possible for a migration to be created empty but for the ModelSnapshot.cs to still change (when you add a view-backed entity, for example) so we need to check both.
Once we make the migration with dotnet ef migrate test
(named it "test" for consistency), we need to gather the names of all the changed files.
Since our repository is already tracked in Git we can use that to make the process easier:
git add *
$ChangedFiles = (git diff --name-only HEAD)
To that end, for each found DbContext name above, we need to run 2 checks:
Select-String
runs its regex checks line by line if you just pass in a file so we have to grab the whole file in a single string to be able to run our regex across the multiple lines that we need to test.$CreatedMigrationFile = "../../../"+($ChangedFiles | Where { $_ -imatch ".*_test\.cs" })
$CreatedMigrationFileContent = (Get-Content -Raw $CreatedMigrationFile)
$EmptyMigrationSearchResult = Select-String -InputObject $CreatedMigrationFileContent -Pattern 'public partial class test : Migration\s+{\s+protected override void Up\(MigrationBuilder migrationBuilder\)\s+{\s+}\s+protected override void Down\(MigrationBuilder migrationBuilder\)\s+{\s+}\s+}'
if ($EmptyMigrationSearchResult.Matches.Length -eq 0) {
Write-Output "fail"
} else {
Write-Output "pass"
}
$ModelSnapshotFile = ($ChangedFiles | Where {$_ -imatch ".*ModelSnapshot\.cs" })
if ($ModelSnapshotFile -ne $null) {
Write-Output "fail"
} else {
Write-Output "pass"
}
Then we have to make sure we reset the changes made for this DbContext so the next one isn't tainted.
git reset --hard HEAD >$null
Then with a foreach loop to run the steps for each DbContext, some comments, extra short circuit checks, error collection, and logging, we end up with our final result!
#Prep work
dotnet tool restore >$null
#Get the list of all DbContext names so we can check them all, sorted by most recently modified so we check changed ones first
$DbContextNames = (Get-ChildItem -Path . -Recurse -File -Filter "*DbContext.cs" | Sort-Object -Property LastWriteTime -Descending).BaseName;
#Empty array for any errors we might find
$Errors = [System.Collections.ArrayList]::new();
#Check to see if we can compile at all
Write-Output "Checking compile..."
dotnet build >$null
if ($LASTEXITCODE -ne 0) {
Write-Output "fail"
exit 1
} else {
Write-Output "pass"
}
#Run though each DbContext, try and make a new migration. Ensure the created migration is empty and the modelcontextsnapshot doesn't change
foreach ($Context in $DbContextNames) {
Write-Output ""
Write-Output "[Checking $Context]";
#Create the migrations
Write-Output "Creating migration..."
dotnet ef migrations add --context:$Context test >$null
if ($LASTEXITCODE -ne 0) {
Write-Output "fail"
$Errors.Add("$Context failed to create migration") >$null
continue
} else {
Write-Output "pass"
}
#Track any new files created (like the migrations we just made)
git add *
$ChangedFiles = (git diff --name-only HEAD)
#Check to see if the migration we created is empty
$CreatedMigrationFile = "../../../"+($ChangedFiles | Where { $_ -imatch ".*_test\.cs" })
Write-Output "Checking if $CreatedMigrationFile is empty..."
$CreatedMigrationFileContent = (Get-Content -Raw $CreatedMigrationFile) #https://stackoverflow.com/a/55374303
$EmptyMigrationSearchResult = Select-String -InputObject $CreatedMigrationFileContent -Pattern 'public partial class test : Migration\s+{\s+protected override void Up\(MigrationBuilder migrationBuilder\)\s+{\s+}\s+protected override void Down\(MigrationBuilder migrationBuilder\)\s+{\s+}\s+}'
if ($EmptyMigrationSearchResult.Matches.Length -eq 0) {
Write-Output "fail"
$Errors.Add("$Context produced non-empty migration") >$null
} else {
Write-Output "pass"
}
#Check to see if the modelcontextsnapshot changed?
Write-Output "Checking if ModelSnapshot.cs file was changed..."
$ModelSnapshotFile = ($ChangedFiles | Where {$_ -imatch ".*ModelSnapshot\.cs" })
if ($ModelSnapshotFile -ne $null) {
Write-Output "fail"
$Errors.Add("$Context modified ModelSnapshot.cs file") >$null
} else {
Write-Output "pass"
}
#Clear any changes to ensure next DbContext has a clean slate
git reset --hard HEAD >$null
}
#Print any errors
Write-Output ""
Write-Output "[ERRORS (if any)]"
foreach ($Err in $Errors) {
Write-Output "##[warning]$Err"
}
# Exit with status code (will be 0 if no errors!)
exit $Errors.Count
Our team uses Azure DevOps, so I crafted this into a pipeline that would let us use it as a PR gate:
# Pipeline that confirms that all committed changes in Git didn't miss creating migrations
trigger: none
pool:
name: ADO Agents
steps:
- task: PowerShell@2
inputs:
showWarnings: true
ignoreLASTEXITCODE: true
workingDirectory: 'api/src'
targetType: 'inline'
script: |
#Prep work
dotnet tool restore >$null
#Get the list of all DbContext names so we can check them all, sorted by most recently modified so we check changed ones first
$DbContextNames = (Get-ChildItem -Path . -Recurse -File -Filter "*DbContext.cs" | Sort-Object -Property LastWriteTime -Descending).BaseName;
#Empty array for any errors we might find
$Errors = [System.Collections.ArrayList]::new();
#Check to see if we can compile at all
Write-Output "Checking compile..."
dotnet build >$null
if ($LASTEXITCODE -ne 0) {
Write-Output "fail"
exit 1
} else {
Write-Output "pass"
}
#Run though each DbContext, try and make a new migration. Ensure the created migration is empty and the modelcontextsnapshot doesn't change
foreach ($Context in $DbContextNames) {
Write-Output ""
Write-Output "[Checking $Context]";
#Create the migrations
Write-Output "Creating migration..."
dotnet ef migrations add --context:$Context test >$null
if ($LASTEXITCODE -ne 0) {
Write-Output "fail"
$Errors.Add("$Context failed to create migration") >$null
continue
} else {
Write-Output "pass"
}
#Track any new files created (like the migrations we just made)
git add *
$ChangedFiles = (git diff --name-only HEAD)
#Check to see if the migration we created is empty
$CreatedMigrationFile = "../../../"+($ChangedFiles | Where { $_ -imatch ".*_test\.cs" })
Write-Output "Checking if $CreatedMigrationFile is empty..."
$CreatedMigrationFileContent = (Get-Content -Raw $CreatedMigrationFile) #https://stackoverflow.com/a/55374303
$EmptyMigrationSearchResult = Select-String -InputObject $CreatedMigrationFileContent -Pattern 'public partial class test : Migration\s+{\s+protected override void Up\(MigrationBuilder migrationBuilder\)\s+{\s+}\s+protected override void Down\(MigrationBuilder migrationBuilder\)\s+{\s+}\s+}'
if ($EmptyMigrationSearchResult.Matches.Length -eq 0) {
Write-Output "fail"
$Errors.Add("$Context produced non-empty migration") >$null
} else {
Write-Output "pass"
}
#Check to see if the modelcontextsnapshot changed?
Write-Output "Checking if ModelSnapshot.cs file was changed..."
$ModelSnapshotFile = ($ChangedFiles | Where {$_ -imatch ".*ModelSnapshot\.cs" })
if ($ModelSnapshotFile -ne $null) {
Write-Output "fail"
$Errors.Add("$Context modified ModelSnapshot.cs file") >$null
} else {
Write-Output "pass"
}
#Clear any changes to ensure next DbContext has a clean slate
git reset --hard HEAD >$null
}
#Print any errors
Write-Output ""
Write-Output "[ERRORS (if any)]"
foreach ($Err in $Errors) {
Write-Output "##[warning]$Err"
}
# Exit with status code (will be 0 if no errors!)
exit $Errors.Count
And with adding it as a branch policy:
it shows up in the PR gates: