Public/Update-ApiToolsFromDatabase.ps1

function Update-ApiToolsFromDatabase {
<#
.SYNOPSIS
Updates an existing CRUD API project when the source database schema changes.
 
.DESCRIPTION
Update-ApiToolsFromDatabase performs a "smart copy-paste" refresh of EF Core models without
destroying custom configuration. It scaffolds fresh models and the DbContext to a temporary
workspace, then replaces entity model files and re-injects the developer’s existing
OnModelCreating custom block into the newly scaffolded DbContext (after the generated mappings).
 
Key steps:
1) Validate tools and locate existing DbContext.
2) Extract custom OnModelCreating block from existing context.
3) Scaffold fresh context + models to a temp folder via dotnet ef.
4) Replace entity models and merge DbContext with custom block appended.
5) Generate a human-friendly change report and machine-readable JSON.
6) Optional controller generation for newly added entities only.
7) Optional parity migration creation for DB-first tracking.
 
.PARAMETER ConnectionString
Database connection string. If omitted, reads from appsettings.json using the key from -ConnectionStringName.
 
.PARAMETER ProjectPath
Path to the existing API project directory (must contain a .csproj file).
 
.PARAMETER ModelsPath
Relative path to the Models folder. Defaults to "Models".
 
.PARAMETER ContextName
Optional DbContext class name. Provide this in multi-context solutions.
 
.PARAMETER TempRoot
Root directory for the temporary scaffold workspace. Defaults to ".apitools_temp".
 
.PARAMETER DryRun
Preview changes without applying. Shows added/removed/updated models with property-level diffs.
 
.PARAMETER BackupBeforeApply
Create a backup under .apitools_backups before modifying the project.
 
.PARAMETER IncludeTables
Comma-separated list of table names to include.
 
.PARAMETER ExcludeTables
Comma-separated list of table names to exclude.
 
.PARAMETER RegenerateControllers
Generate CRUD controllers for brand-new entities only (existing controllers are preserved).
 
.PARAMETER CreateMigration
Create an EF Core migration after updating models (for parity tracking in DB-first workflows).
 
.PARAMETER MigrationName
Name for the migration if -CreateMigration is provided. Invalid characters are replaced with underscores.
 
.PARAMETER AppSettingsPath
Relative path to appsettings.json. Defaults to "appsettings.json".
 
.PARAMETER ConnectionStringName
Key in ConnectionStrings section of appsettings.json. Defaults to "DefaultConnection".
 
.EXAMPLE
Update-ApiToolsFromDatabase -ProjectPath "./HospitalAPI"
 
.EXAMPLE
Update-ApiToolsFromDatabase -ProjectPath "./HospitalAPI" -DryRun
 
.EXAMPLE
Update-ApiToolsFromDatabase `
  -ProjectPath "./HospitalAPI" `
  -BackupBeforeApply `
  -RegenerateControllers `
  -CreateMigration `
  -MigrationName "AddEmailColumn"
 
.EXAMPLE
Update-ApiToolsFromDatabase `
  -ProjectPath "./ComplexAPI" `
  -ContextName "CatalogContext" `
  -IncludeTables "Products,Categories"
 
.INPUTS
None.
 
.OUTPUTS
PSCustomObject with Action, Engine, Database, Context, ProjectPath, ModelsPath, Added, Removed, Overwritten,
PropertyDiff, ControllersGenerated, MigrationCreated, Timestamp, Created, and DryRun/WhatIf indicators.
 
.NOTES
Author: Ruslan Dubas
Module: apitools
Requires: .NET SDK, dotnet-ef
Optional: dotnet-aspnet-codegenerator (needed only when -RegenerateControllers is used)
 
.LINK
https://github.com/yourusername/apitools
#>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory = $false, HelpMessage = "Database connection string. If omitted, reads from appsettings.json (DefaultConnection).")]
        [string]$ConnectionString,

        [Parameter(Mandatory = $true)]
        [string]$ProjectPath,

        [string]$ModelsPath = "Models",
        [string]$ContextName,
        [string]$TempRoot = ".apitools_temp",

        [switch]$DryRun,
        [switch]$BackupBeforeApply,

        [string]$IncludeTables,
        [string]$ExcludeTables,

        [switch]$RegenerateControllers,

        [switch]$CreateMigration,
        [string]$MigrationName = $("Update_" + (Get-Date -Format "yyyyMMdd_HHmmss")),

        [string]$AppSettingsPath = "appsettings.json",
        [string]$ConnectionStringName = "DefaultConnection"
    )

    begin {
        function _Info($m){ Write-Host $m -ForegroundColor White }
        function _Note($m){ Write-Host $m -ForegroundColor Cyan }
        function _Ok($m)  { Write-Host $m -ForegroundColor Green }
        function _Warn($m){ Write-Warning $m }
        function _Fail($m){ throw $m }

        function _EnsureDotnetTools {
            $errs=@()
            try { $null = dotnet --version 2>$null } catch { $errs += ".NET SDK not found." }
            try { $null = dotnet ef --version 2>$null } catch { $errs += "dotnet-ef tool not found. Install: dotnet tool install --global dotnet-ef" }
            if ($errs.Count -gt 0) { _Fail ("Missing tools:`n" + ($errs -join "`n")) }
        }

        function _EnsureCodegenTool {
            try { $null = dotnet aspnet-codegenerator --help 2>$null }
            catch { _Fail "dotnet-aspnet-codegenerator tool not found. Install: dotnet tool install --global dotnet-aspnet-codegenerator" }
        }

        function _NewStamp { Get-Date -Format "yyyyMMdd_HHmmss" }

        function _ResolvePath([string]$p) {
            if ([string]::IsNullOrWhiteSpace($p)) { return $null }
            if (Test-Path $p) { return (Resolve-Path $p).Path }
            return (Join-Path (Get-Location) $p)
        }

        function _EnsureDir([string]$path) {
            if (-not (Test-Path $path)) { New-Item -ItemType Directory -Path $path | Out-Null }
        }

        function _SplitCsv([string]$csv) {
            if ([string]::IsNullOrWhiteSpace($csv)) { @() } else { $csv.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } }
        }

        function _ReadAll([string]$file) { if (Test-Path $file) { Get-Content $file -Raw } else { $null } }

        function _GetProjectName([string]$projectDir){
            $csproj = Get-ChildItem -Path $projectDir -Filter "*.csproj" -File | Select-Object -First 1
            if (-not $csproj) { _Fail "Could not find .csproj file in '$projectDir'." }
            return [System.IO.Path]::GetFileNameWithoutExtension($csproj.Name)
        }

        function _FindContextFile([string]$modelsDir, [string]$explicitName) {
    if (-not (Test-Path $modelsDir)) { return $null }
    if ($explicitName) {
        $p = Join-Path $modelsDir ("{0}.cs" -f $explicitName)
        if (Test-Path $p) { return $p }
    }
    $hit = Get-ChildItem -Path $modelsDir -Filter "*.cs" -File |
           Where-Object { (_ReadAll $_.FullName) -match '\b(class|partial\s+class)\s+\w+\s*:\s*DbContext\b' } |
           Select-Object -First 1
    return $hit?.FullName
}

        function _ExtractOnModelCreating([string]$content) {
            $sig = [regex]::Match($content, 'protected\s+override\s+void\s+OnModelCreating\s*\(\s*ModelBuilder\s+\w+\s*\)', 'Singleline')
            if (-not $sig.Success) { return $null }
            $startIdx = $sig.Index
            $openIdx  = $content.IndexOf('{', $startIdx)
            if ($openIdx -lt 0) { return $null }
            $depth = 0
            for ($i=$openIdx; $i -lt $content.Length; $i++){
                $ch = $content[$i]
                if ($ch -eq '{'){ $depth++ }
                elseif ($ch -eq '}'){
                    $depth--
                    if ($depth -eq 0){
                        $bodyStart = $openIdx + 1
                        $bodyLen   = $i - $bodyStart
                        return @{ Body = $content.Substring($bodyStart, $bodyLen); Start = $bodyStart; End = $i }
                    }
                }
            }
            return $null
        }

        function _InsertCustomAfterGenerated([string]$newCtxContent, [string]$customBlock) {
            if ([string]::IsNullOrWhiteSpace($customBlock)) { return $newCtxContent }
            $markerStart = "// <APITOOLS_CUSTOM_ONMODEL_START>"
            $markerEnd   = "// <APITOOLS_CUSTOM_ONMODEL_END>"

            $s = $newCtxContent.IndexOf($markerStart)
            if ($s -ge 0) {
                $e = $newCtxContent.IndexOf($markerEnd, $s)
                if ($e -gt $s) { $newCtxContent = $newCtxContent.Remove($s, ($e + $markerEnd.Length) - $s) }
            }

            $parsed = _ExtractOnModelCreating $newCtxContent
            if ($null -eq $parsed) { return $null }

            $insertion =
                "`r`n $markerStart`r`n" +
                " // User custom configuration preserved by apitools`r`n" +
                ($customBlock.TrimEnd()) + "`r`n" +
                " $markerEnd`r`n"

            $updatedBody = ($parsed.Body.TrimEnd()) + "`r`n" + $insertion
            return $newCtxContent.Substring(0, $parsed.Start) + $updatedBody + $newCtxContent.Substring($parsed.End)
        }

        function _EngineFromConnection([string]$cs) {
            $r = [ordered]@{}
            if ($cs -match '(?i)(npgsql|postgres|port\s*=\s*5432)') {
                $r.Engine           = 'PostgreSQL'
                $r.ProviderPackage  = 'Npgsql.EntityFrameworkCore.PostgreSQL'
                $r.ScaffoldProvider = 'Npgsql.EntityFrameworkCore.PostgreSQL'
                $r.DbCtxMethod      = 'UseNpgsql'
            }
            elseif ($cs -match '(?i)(server\s*=|data source\s*=|trusted_connection|sqlserver)') {
                $r.Engine           = 'SqlServer'
                $r.ProviderPackage  = 'Microsoft.EntityFrameworkCore.SqlServer'
                $r.ScaffoldProvider = 'Microsoft.EntityFrameworkCore.SqlServer'
                $r.DbCtxMethod      = 'UseSqlServer'
            }
            else { _Fail "Unable to detect database engine from the provided connection string." }

            $db = $null
            foreach ($part in ($cs -split ';')) {
                if ($part -match '(?i)^(Database|Initial Catalog)\s*=\s*(.+)$') { $db = $Matches[2].Trim(); break }
            }
            $r.DatabaseName = $db
            return $r
        }

        function _ReadConnectionFromAppSettings([string]$projectRoot, [string]$appSettingsRel, [string]$name){
            $p = Join-Path $projectRoot $appSettingsRel
            if (-not (Test-Path $p)) { return $null }
            try {
                $json = Get-Content $p -Raw | ConvertFrom-Json -Depth 64
                return $json.ConnectionStrings.$name
            } catch { return $null }
        }

        function _PropertySig($line){
            $m = [regex]::Match($line, 'public\s+([\w<>\?\[\]]+)\s+(\w+)\s*\{\s*get;\s*set;\s*\}')
            if ($m.Success) { return @{ Type=$m.Groups[1].Value; Name=$m.Groups[2].Value } }
            return $null
        }

        function _ModelDiffReport([string]$oldFile,[string]$newFile){
            $old = if ($oldFile -and (Test-Path $oldFile)) { Get-Content $oldFile } else { @() }
            $new = if ($newFile -and (Test-Path $newFile)) { Get-Content $newFile } else { @() }

            $oldProps = @{}
            foreach($l in $old){ $sig=_PropertySig $l; if($sig){ $oldProps[$sig.Name]=$sig.Type } }
            $newProps = @{}
            foreach($l in $new){ $sig=_PropertySig $l; if($sig){ $newProps[$sig.Name]=$sig.Type } }

            $added   = @()
            $removed = @()
            $changed = @()

            foreach($k in $newProps.Keys){
                if(-not $oldProps.ContainsKey($k)){ $added += @{ Name=$k; Type=$newProps[$k] } }
                elseif($oldProps[$k] -ne $newProps[$k]){ $changed += @{ Name=$k; From=$oldProps[$k]; To=$newProps[$k] } }
            }
            foreach($k in $oldProps.Keys){
                if(-not $newProps.containsKey($k)){ $removed += @{ Name=$k; Type=$oldProps[$k] } }
            }

            return [pscustomobject]@{
                Added   = $added
                Removed = $removed
                Changed = $changed
            }
        }

        function _FindControllersPath([string]$proj){
            $p = Join-Path $proj "Controllers"
            if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p | Out-Null }
            return $p
        }

        function _EnsureSqlServerPkgForPgWhenCodegen([string]$projectDir, [string]$engineName){
            if ($engineName -ne 'PostgreSQL') { return }
            _Note "Verifying Microsoft.EntityFrameworkCore.SqlServer (required by aspnet-codegenerator even for PostgreSQL)..."
            Push-Location $projectDir
            try {
                $packagesList = dotnet list package 2>&1
                $hasSqlServer = ($packagesList -match 'Microsoft\.EntityFrameworkCore\.SqlServer')
                if (-not $hasSqlServer) {
                    _Warn "Installing Microsoft.EntityFrameworkCore.SqlServer (codegen dependency)..."
                    $null = dotnet add package Microsoft.EntityFrameworkCore.SqlServer 2>&1
                }
            } finally { Pop-Location }
        }
    }

    process {
        _EnsureDotnetTools

        $ProjectPath = (_ResolvePath $ProjectPath)
        if (-not (Test-Path $ProjectPath)) { _Fail "ProjectPath not found: $ProjectPath" }

        if ([string]::IsNullOrWhiteSpace($ConnectionString)) {
            $ConnectionString = _ReadConnectionFromAppSettings -projectRoot $ProjectPath -appSettingsRel $AppSettingsPath -name $ConnectionStringName
            if ([string]::IsNullOrWhiteSpace($ConnectionString)) {
                _Fail "ConnectionString not provided and could not be read from '$AppSettingsPath' (key '$ConnectionStringName')."
            }
        }

        $engine = _EngineFromConnection $ConnectionString
        Write-Verbose ("Detected engine: {0}" -f $engine.Engine)

        $modelsDir = Join-Path $ProjectPath $ModelsPath
        $ctxPathExisting = _FindContextFile -modelsDir $modelsDir -explicitName $ContextName
        if (-not $ctxPathExisting) {
            $msg = "Could not locate existing DbContext in '$modelsDir'."
            if ($ContextName) {
                $msg += " The specified context name '$ContextName' was not found."
            } else {
                $msg += " Provide -ContextName if multiple contexts exist, or ensure project was scaffolded with New-ApiToolsCrudApi."
            }
            _Fail $msg
        }

        $existingCtxContent = _ReadAll $ctxPathExisting
        $existingCtxParsed  = _ExtractOnModelCreating $existingCtxContent
        if ($null -eq $existingCtxParsed) { _Fail "Could not parse OnModelCreating in existing DbContext. Aborting to avoid data loss." }

        $ctxClassName = if ($ContextName) { $ContextName } else {
            $m = [regex]::Match($existingCtxContent, '\bclass\s+(\w+)\s*:\s*DbContext')
            if ($m.Success) { $m.Groups[1].Value } else { "$($engine.DatabaseName)Context" }
        }

        Write-Verbose ("Context class: {0}" -f $ctxClassName)
        $userCustomBlock = $existingCtxParsed.Body
        Write-Verbose ("Custom OnModelCreating block length: {0} chars" -f $userCustomBlock.Length)

        $projName = _GetProjectName $ProjectPath

        $stamp     = _NewStamp
$tempBase  = Join-Path $ProjectPath $TempRoot
$tempRun   = Join-Path $tempBase "scaffold_$stamp"
$tempModelsRelative = Join-Path $TempRoot "scaffold_$stamp"
$tempModels= Join-Path $ProjectPath $tempModelsRelative
_EnsureDir $tempModels

        Push-Location $ProjectPath
try {
    _Note "Scaffolding to temporary workspace: $tempRun"

    # Create a temporary output path for models
    $tempOutputPath = Join-Path $TempRoot "scaffold_$stamp\$ModelsPath"
    
   $args = @(
    'ef','dbcontext','scaffold',
    $ConnectionString,
    $engine.ScaffoldProvider,
    '--output-dir', $tempModelsRelative,
    '--context', $ctxClassName,
    '--namespace', "$projName.Models",
    '--force'
)
    if ($IncludeTables) { _SplitCsv $IncludeTables | ForEach-Object { $args += @('--table', $_) } }
    if ($ExcludeTables) { _SplitCsv $ExcludeTables | ForEach-Object { $args += @('--exclude-tables', $_) } }

    # Run scaffold from project directory (where .csproj is)
    $output = dotnet @args 2>&1
if ($LASTEXITCODE -ne 0) { 
    _Fail "dotnet ef scaffold failed. Output: $($output | Out-String)" 
}

    # Verify files were created
    if (-not (Test-Path $tempModels) -or (Get-ChildItem $tempModels -Filter "*.cs" -File).Count -eq 0) {
        _Fail "Scaffold completed but produced no model files. Check connection string and table filters."
    }

            $existingModelFiles = if (Test-Path $modelsDir) { Get-ChildItem -Path $modelsDir -Filter "*.cs" -File } else { @() }
            $newModelFiles      = Get-ChildItem -Path $tempModels -Filter "*.cs" -File

            $existingNames = $existingModelFiles | ForEach-Object { $_.Name }
            $newNames      = $newModelFiles      | ForEach-Object { $_.Name }

            $added   = $newNames | Where-Object { $_ -notin $existingNames -and $_ -ne "$ctxClassName.cs" }
            $removed = $existingNames | Where-Object { $_ -notin $newNames -and $_ -ne "$ctxClassName.cs" }
            $common  = $newNames | Where-Object { $_ -in $existingNames -and $_ -ne "$ctxClassName.cs" }

            $perEntityDiff = @{}
            foreach($n in $common){
                $perEntityDiff[$n] = _ModelDiffReport -oldFile (Join-Path $modelsDir $n) -newFile (Join-Path $tempModels $n)
            }
            foreach($n in $added){
                $perEntityDiff[$n] = _ModelDiffReport -oldFile $null -newFile (Join-Path $tempModels $n)
            }

            if ($DryRun) {
                _Info ""
                _Info "=== DRY RUN: Update-ApiToolsFromDatabase ==="
                _Info ("Engine: {0}" -f $engine.Engine)
                _Info ("Context: {0}" -f $ctxClassName)
                _Info ("Project: {0}" -f $ProjectPath)
                _Info ""

                _Note "SUMMARY:"
                Write-Host (" Models to ADD: {0}" -f $added.Count)   -ForegroundColor Green
                Write-Host (" Models to REMOVE: {0}" -f $removed.Count) -ForegroundColor Red
                Write-Host (" Models to UPDATE: {0}" -f $common.Count)  -ForegroundColor Yellow
                _Info ""

                if ($added.Count -gt 0) {
                    _Note "NEW MODELS:"
                    foreach($n in $added) { Write-Host (" + {0}" -f $n) -ForegroundColor Green }
                    _Info ""
                }
                if ($removed.Count -gt 0) {
                    _Warn "REMOVED MODELS:"
                    foreach($n in $removed) { Write-Host (" - {0}" -f $n) -ForegroundColor Red }
                    _Info ""
                    _Warn "Consider manually removing corresponding controllers if they exist."
                    _Info ""
                }
                if ($common.Count -gt 0) {
                    _Note "UPDATED MODELS (Property Changes):"
                    foreach($k in $perEntityDiff.Keys) {
                        $d = $perEntityDiff[$k]
                        $hasChanges = ($d.Added.Count -gt 0 -or $d.Removed.Count -gt 0 -or $d.Changed.Count -gt 0)
                        if ($hasChanges) {
                            Write-Host (" {0}:" -f $k) -ForegroundColor Yellow
                            foreach($p in $d.Added)   { Write-Host (" + {0} : {1}" -f $p.Name, $p.Type) -ForegroundColor Green }
                            foreach($p in $d.Removed) { Write-Host (" - {0} : {1}" -f $p.Name, $p.Type) -ForegroundColor Red }
                            foreach($p in $d.Changed) { Write-Host (" ~ {0} : {1} -> {2}" -f $p.Name, $p.From, $p.To) -ForegroundColor Cyan }
                        }
                    }
                }

                return [pscustomobject]@{
                    Action        = 'PreviewUpdateFromDatabase'
                    Engine        = $engine.Engine
                    Database      = $engine.DatabaseName
                    Context       = $ctxClassName
                    ProjectPath   = $ProjectPath
                    ModelsPath    = $ModelsPath
                    AddedModels   = $added
                    RemovedModels = $removed
                    Overwritten   = $common
                    Diff          = $perEntityDiff
                    DryRun        = $true
                }
            }

            if ($PSCmdlet.ShouldProcess("$($engine.Engine)::$($engine.DatabaseName)", "Apply Smart Update")) {

                if ($BackupBeforeApply) {
                    $backupDir = Join-Path $ProjectPath ".apitools_backups"
                    _EnsureDir $backupDir
                    $backupStamp = Join-Path $backupDir ("backup_{0}" -f $stamp)
                    _Note "Creating backup: $backupStamp"
                    _EnsureDir $backupStamp
                    Copy-Item -Path (Join-Path $ProjectPath '*') -Destination $backupStamp -Recurse -Force -Exclude $TempRoot,'.git','.vs','bin','obj'
                }

                $reportsDir = Join-Path $ProjectPath ".apitools_reports"
                _EnsureDir $reportsDir
                $reportTxt  = Join-Path $reportsDir ("update_{0}.txt" -f $stamp)
                $reportJson = Join-Path $reportsDir ("update_{0}.json" -f $stamp)

                _Note "Refreshing entity models..."
                _EnsureDir $modelsDir
                Copy-Item -Path (Join-Path $tempModels '*') -Destination $modelsDir -Recurse -Force

                $tempCtxPath = Join-Path $tempModels "$ctxClassName.cs"
                $projCtxPath = Join-Path $modelsDir  "$ctxClassName.cs"
                if (-not (Test-Path $tempCtxPath)) { _Fail "Scaffold did not produce expected context '$ctxClassName.cs'." }

                $newBaselineCtx = _ReadAll $tempCtxPath
                $mergedCtx      = _InsertCustomAfterGenerated -newCtxContent $newBaselineCtx -customBlock $userCustomBlock
                if ($null -eq $mergedCtx) { _Fail "Could not merge OnModelCreating in new context. Aborting to avoid data loss." }
                Set-Content -Path $projCtxPath -Value $mergedCtx -Encoding UTF8

                $controllersGenerated = 0
                if ($RegenerateControllers.IsPresent) {
                    _EnsureCodegenTool
                    _EnsureSqlServerPkgForPgWhenCodegen -projectDir $ProjectPath -engineName $engine.Engine

                    $controllersPath = _FindControllersPath $ProjectPath
                    Push-Location $ProjectPath
                    try {
                        $null = dotnet build 2>&1
                        foreach($entityName in $added){
                            $modelName = [System.IO.Path]::GetFileNameWithoutExtension($entityName)
                            $controllerFile = Join-Path $controllersPath ("{0}Controller.cs" -f $modelName)
                            if (Test-Path $controllerFile) { continue }

                            $modelFqn = "$projName.Models.$modelName"
                            $ctxFqn   = "$projName.Models.$ctxClassName"

                            $out = dotnet aspnet-codegenerator controller `
                                -name ("{0}Controller" -f $modelName) `
                                -async -api `
                                -m $modelFqn `
                                -dc $ctxFqn `
                                -outDir $controllersPath `
                                -f 2>&1

                            if ($LASTEXITCODE -eq 0 -and (Test-Path $controllerFile)) { $controllersGenerated++ }
                        }
                    } finally { Pop-Location }
                }

                $migrationCreated = $false
                if ($CreateMigration.IsPresent) {
                    if ([string]::IsNullOrWhiteSpace($MigrationName)) { $MigrationName = "Update_" + (_NewStamp) }
                    $MigrationName = ($MigrationName -replace '[^\w]', '_')
                    Push-Location $ProjectPath
                    try {
                        $out = dotnet ef migrations add $MigrationName 2>&1
                        if ($LASTEXITCODE -eq 0) { $migrationCreated = $true }
                    } finally { Pop-Location }
                }

                $summary = [pscustomobject]@{
                    Action               = 'UpdateFromDatabase'
                    Engine               = $engine.Engine
                    Database             = $engine.DatabaseName
                    Context              = $ctxClassName
                    ProjectPath          = $ProjectPath
                    ModelsPath           = $ModelsPath
                    Added                = $added
                    Removed              = $removed
                    Overwritten          = $common
                    PropertyDiff         = $perEntityDiff
                    ControllersGenerated = $controllersGenerated
                    MigrationCreated     = $migrationCreated
                    Timestamp            = $stamp
                    Created              = $true
                }

                $txt = @()
                $txt += "Update-ApiToolsFromDatabase @ $stamp"
                $txt += "Engine: $($engine.Engine)"
                $txt += "Database: $($engine.DatabaseName)"
                $txt += "Context: $ctxClassName"
                $txt += "Models added: $($added.Count)"
                $txt += "Models removed: $($removed.Count)"
                $txt += "Models overwritten: $($common.Count)"
                if ($controllersGenerated -gt 0) { $txt += "Controllers created: $controllersGenerated (new entities)" }
                if ($migrationCreated)           { $txt += "Migration created: $MigrationName" }
                $txt += "Custom OnModelCreating preserved and re-injected after generated mappings."
                Set-Content -Path $reportTxt -Value ($txt -join "`r`n") -Encoding UTF8
                $summary | ConvertTo-Json -Depth 8 | Set-Content -Path $reportJson -Encoding UTF8

                _Ok  "✓ Models updated and DbContext merged successfully."
                _Info ""
                _Info "CHANGES APPLIED:"
                Write-Host (" Models added: {0}" -f $added.Count)   -ForegroundColor Green
                Write-Host (" Models removed: {0}" -f $removed.Count) -ForegroundColor Red
                Write-Host (" Models overwritten: {0}" -f $common.Count)  -ForegroundColor Yellow

                if ($common.Count -gt 0 -or $added.Count -gt 0) {
                    $totalPropsAdded   = ($perEntityDiff.Values | ForEach-Object { $_.Added.Count }   | Measure-Object -Sum).Sum
                    $totalPropsRemoved = ($perEntityDiff.Values | ForEach-Object { $_.Removed.Count } | Measure-Object -Sum).Sum
                    $totalPropsChanged = ($perEntityDiff.Values | ForEach-Object { $_.Changed.Count } | Measure-Object -Sum).Sum
                    if ($totalPropsAdded -or $totalPropsRemoved -or $totalPropsChanged) {
                        Write-Host (" Properties: +{0} / -{1} / ~{2}" -f $totalPropsAdded, $totalPropsRemoved, $totalPropsChanged) -ForegroundColor Gray
                    }
                }

                if ($removed.Count -gt 0) {
                    _Info ""
                    _Warn "⚠ Models removed from database:"
                    foreach($n in $removed) { Write-Host (" - {0}" -f $n) -ForegroundColor Red }
                    _Info ""
                    _Warn "Consider manually removing corresponding controllers if they exist."
                }

                if ($controllersGenerated -gt 0) {
                    Write-Host (" Controllers created: {0}" -f $controllersGenerated) -ForegroundColor Green
                }
                if ($migrationCreated) {
                    Write-Host (" Migration created: {0}" -f $MigrationName) -ForegroundColor Green
                }

                _Info ""
                _Info "Reports saved:"
                _Info (" Text: {0}" -f $reportTxt)
                _Info (" JSON: {0}" -f $reportJson)
                if ($BackupBeforeApply) {
                    _Info (" Backup: {0}" -f $backupStamp)
                }

                return $summary
            }
            else {
                return [pscustomobject]@{
                    Action      = 'UpdateFromDatabaseCancelled'
                    Engine      = $engine.Engine
                    Database    = $engine.DatabaseName
                    Context     = $ctxClassName
                    ProjectPath = $ProjectPath
                    WhatIf      = $true
                }
            }
        }
        finally {
            Pop-Location
            # Leave temp workspace for audit. Uncomment to auto-clean:
            # if (-not $DryRun) { Remove-Item -Path $tempRun -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}