PR Check to ensure no unmigrated EF Core model changes

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.

Permalink to “Getting The Context(s)”

In our project we have multiple DbContexts, 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.

Permalink to “Checking For Changes”

When you make a EF Core migration, 3 things are created or change:

  1. The migration itself with the actual database change steps (the Up() and Down() methods) is created.
  2. A .Designer.cs file is created next to the migration that contains the current model snapshot for the migration.
  3. The top level DbContext-specific ModelSnapshot is updated with the changes.

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:

  1. Is the migration we created "empty"?
    We're using a regex to test for the empty migration, but 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"
}
  1. Did the ModelSnapshot.cs file change?
    This one is pretty easy since we already have the list of changed files, if it's not there it didn't change.
$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
Permalink to “The End Result”

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
Permalink to “Using it in PR checks”

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:

The ADO branch policy configuration screen showing the pipeline being configured to be a required PR check

it shows up in the PR gates:

A succeeding PR check