Public/Update-ApiToolsFromDatabase.ps1
|
function Update-ApiToolsFromDatabase { <# .SYNOPSIS Safe, markerless update of EF models + DbContext. Preserves only non-entity custom code. .DESCRIPTION 1) Scaffolds fresh models/DbContext into a temp workspace. 2) Copies entity models over the project. 3) Rebuilds OnModelCreating by taking the NEW scaffolded method body and inserting ONLY the user’s NON-ENTITY custom statements (from the OLD context) just before the partial call. - Removes any old EF entity blocks from the carry-over with brace-aware parsing. - Removes any old APITOOLS markers from the carry-over. - Removes OnModelCreatingPartial(modelBuilder) from the carry-over (let new scaffold place it). 4) Optional controller generation for newly added entities; optional migration. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [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 _NewStamp { Get-Date -Format "yyyyMMdd_HHmmss" } function _EnsureDir([string]$path) { if (-not (Test-Path $path)) { New-Item -ItemType Directory -Path $path | Out-Null } } function _ResolvePath([string]$p) { if (Test-Path $p) { (Resolve-Path $p).Path } else { Join-Path (Get-Location) $p } } function _ReadAll([string]$f) { if (Test-Path $f) { Get-Content $f -Raw } else { $null } } function _SplitCsv([string]$csv) { if ([string]::IsNullOrWhiteSpace($csv)) { @() } else { $csv.Split(',') | % { $_.Trim() } | ? { $_ } } } 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 _GetProjectName([string]$projectDir){ $csproj = Get-ChildItem -Path $projectDir -Filter "*.csproj" -File | Select-Object -First 1 if (-not $csproj) { _Fail "Could not find .csproj in '$projectDir'." } [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 '\bclass\s+\w+\s*:\s*DbContext\b' } | Select-Object -First 1 $hit?.FullName } function _EngineFromConnection([string]$cs) { $r = [ordered]@{} if ($cs -match '(?i)(npgsql|postgres|port\s*=\s*5432)') { $r.Engine='PostgreSQL'; $r.ScaffoldProvider='Npgsql.EntityFrameworkCore.PostgreSQL' } elseif ($cs -match '(?i)(server\s*=|data source\s*=|trusted_connection|sqlserver)') { $r.Engine='SqlServer'; $r.ScaffoldProvider='Microsoft.EntityFrameworkCore.SqlServer' } else { _Fail "Unable to detect database engine from connection string." } $r.DatabaseName = ($cs -split ';' | % { $_.Trim() } | ? { $_ -match '^(?i)(Database|Initial Catalog)\s*=' } | Select-Object -First 1) -replace '^(?i).+=\s*','' $r } function _ReadConnectionFromAppSettings([string]$projectRoot, [string]$appSettingsRel, [string]$name){ $p = Join-Path $projectRoot $appSettingsRel if (-not (Test-Path $p)) { return $null } try { (Get-Content $p -Raw | ConvertFrom-Json -Depth 64).ConnectionStrings.$name } catch { $null } } 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 } $openIdx = $content.IndexOf('{', $sig.Index) 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 } } } } $null } function _RemovePartialCall([string]$body){ [regex]::Replace($body, '\bOnModelCreatingPartial\s*\(\s*modelBuilder\s*\)\s*;\s*', '', 'Singleline').Trim() } # Remove any region between APITOOLS markers from the carry-over to avoid nesting old runs. function _StripOldMarkers([string]$body) { $b = $body $b = [regex]::Replace($b, '\/\/\s*<APITOOLS_CUSTOM_ONMODEL_START>.*?\/\/\s*<APITOOLS_CUSTOM_ONMODEL_END>\s*', '', 'Singleline') $b = [regex]::Replace($b, '\/\/\s*APITOOLS[^\r\n]*', '', 'Singleline') $b.Trim() } # Brace-aware stripper for EF entity blocks: modelBuilder.Entity<...>(entity => { ... }); function _StripEntityBlocks([string]$body) { if ([string]::IsNullOrWhiteSpace($body)) { return "" } $text = $body $cursor = 0 $sb = New-Object System.Text.StringBuilder while ($cursor -lt $text.Length) { $idx = [cultureinfo]::InvariantCulture.CompareInfo.IndexOf($text, 'modelBuilder.Entity<', $cursor, [System.Globalization.CompareOptions]::IgnoreCase) if ($idx -lt 0) { [void]$sb.Append($text.Substring($cursor)) break } # Append everything before the entity block [void]$sb.Append($text.Substring($cursor, $idx - $cursor)) # Find the opening brace of the lambda body "{" $parenDepth = 0 $i = $idx $openBrace = -1 while ($i -lt $text.Length) { $ch = $text[$i] if ($ch -eq '(') { $parenDepth++ } elseif ($ch -eq ')') { $parenDepth-- } elseif ($ch -eq '{') { $openBrace = $i; break } $i++ } if ($openBrace -lt 0) { # malformed; bail out and append remainder [void]$sb.Append($text.Substring($idx)) $cursor = $text.Length break } # Walk braces to the matching "}" and trailing ");" $braceDepth = 0 $j = $openBrace while ($j -lt $text.Length) { $ch = $text[$j] if ($ch -eq '{') { $braceDepth++ } elseif ($ch -eq '}') { $braceDepth-- if ($braceDepth -eq 0) { # move past closing brace and any whitespace/comments to the following semicolon $j++ while ($j -lt $text.Length -and [char]::IsWhiteSpace($text[$j])) { $j++ } if ($j -lt $text.Length -and $text[$j] -eq ')') { # close the .Entity(... ) ; sequence $j++ while ($j -lt $text.Length -and [char]::IsWhiteSpace($text[$j])) { $j++ } if ($j -lt $text.Length -and $text[$j] -eq ';') { $j++ } } break } } $j++ } # Skip the whole entity block $cursor = $j } $out = $sb.ToString() # Normalize leftover excessive blank lines $out = [regex]::Replace($out, "(`r?`n){3,}", "`r`n`r`n") $out.Trim() } # Extract ONLY safe custom statements from old context: strip markers, partial call, and EF entity blocks. function _ExtractCarryOver([string]$existingCtxContent) { $parsed = _ExtractOnModelCreating $existingCtxContent if ($null -eq $parsed) { return "" } $body = $parsed.Body $body = _StripOldMarkers $body $body = _RemovePartialCall $body $body = _StripEntityBlocks $body $body.Trim() } # Insert carry-over before the partial call in the NEW context (or append if missing) function _InsertCarryOver([string]$newCtxContent, [string]$carryOver) { if ([string]::IsNullOrWhiteSpace($carryOver)) { return $newCtxContent } $parsedNew = _ExtractOnModelCreating $newCtxContent if ($null -eq $parsedNew) { return $null } $bodyNew = $parsedNew.Body $partial = [regex]::Match($bodyNew, '\bOnModelCreatingPartial\s*\(\s*modelBuilder\s*\)\s*;', 'Singleline') $insertion = "`r`n // --- apitools: carried-over custom configuration (non-entity) ---`r`n" + ($carryOver.TrimEnd()) + "`r`n" + " // --- end carried-over custom configuration ---`r`n" $updatedBody = if ($partial.Success) { $bodyNew.Substring(0, $partial.Index) + $insertion + $bodyNew.Substring($partial.Index) } else { ($bodyNew.TrimEnd()) + "`r`n" + $insertion } $newCtxContent.Substring(0, $parsedNew.Start) + $updatedBody + $newCtxContent.Substring($parsedNew.End) } function _PropertySig($line){ $m = [regex]::Match($line, 'public\s+([\w<>\?\[\]]+)\s+(\w+)\s*\{\s*get;\s*set;\s*\}') if ($m.Success) { @{ Type=$m.Groups[1].Value; Name=$m.Groups[2].Value } } else { $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] } } } [pscustomobject]@{ Added=$added; Removed=$removed; Changed=$changed } } function _FindControllersPath([string]$proj){ $p = Join-Path $proj "Controllers"; _EnsureDir $p; $p } function _EnsureSqlServerPkgForPgWhenCodegen([string]$projectDir, [string]$engineName){ if ($engineName -ne 'PostgreSQL') { return } _Note "Ensuring Microsoft.EntityFrameworkCore.SqlServer for aspnet-codegenerator..." Push-Location $projectDir try { $packagesList = dotnet list package 2>&1 if (-not ($packagesList -match 'Microsoft\.EntityFrameworkCore\.SqlServer')) { $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 not found in '$AppSettingsPath' (key '$ConnectionStringName')." } } $engine = _EngineFromConnection $ConnectionString $modelsDir = Join-Path $ProjectPath $ModelsPath $ctxPathExisting = _FindContextFile -modelsDir $modelsDir -explicitName $ContextName if (-not $ctxPathExisting) { _Fail "Could not locate existing DbContext in '$modelsDir'." } $existingCtxContent = _ReadAll $ctxPathExisting $existingParsed = _ExtractOnModelCreating $existingCtxContent if ($null -eq $existingParsed) { _Fail "Could not parse OnModelCreating in existing DbContext." } $ctxClassName = if ($ContextName) { $ContextName } else { $m = [regex]::Match($existingCtxContent, '\bclass\s+(\w+)\s*:\s*DbContext'); if ($m.Success) { $m.Groups[1].Value } else { "ApplicationDbContext" } } # Carry over only SAFE custom statements. $carryOver = _ExtractCarryOver $existingCtxContent $projName = _GetProjectName $ProjectPath # Prepare temp scaffold workspace (relative paths for dotnet ef) $stamp = _NewStamp $tempRunRel = Join-Path $TempRoot ("scaffold_{0}" -f $stamp) $tempRunAbs = Join-Path $ProjectPath $tempRunRel $tempModelsRel = Join-Path $tempRunRel $ModelsPath $tempModelsAbs = Join-Path $ProjectPath $tempModelsRel _EnsureDir $tempRunAbs _EnsureDir $tempModelsAbs Push-Location $ProjectPath try { _Note "Scaffolding fresh models/context to: $tempRunRel" $args = @( 'ef','dbcontext','scaffold', $ConnectionString, $engine.ScaffoldProvider, '--output-dir', $tempModelsRel, '--context', $ctxClassName, '--namespace', "$projName.Models", '--force' ) if ($IncludeTables) { _SplitCsv $IncludeTables | % { $args += @('--table', $_) } } if ($ExcludeTables) { _SplitCsv $ExcludeTables | % { $args += @('--exclude-tables', $_) } } $output = dotnet @args 2>&1 if ($LASTEXITCODE -ne 0) { _Fail "dotnet ef scaffold failed.`n$($output | Out-String)" } if (-not (Test-Path $tempModelsAbs) -or (Get-ChildItem $tempModelsAbs -Filter "*.cs" -File).Count -eq 0) { _Fail "Scaffold produced no model files. Check connection and table filters." } # Diff $existingModelFiles = if (Test-Path $modelsDir) { Get-ChildItem -Path $modelsDir -Filter "*.cs" -File } else { @() } $newModelFiles = Get-ChildItem -Path $tempModelsAbs -Filter "*.cs" -File $existingNames = $existingModelFiles | % { $_.Name } $newNames = $newModelFiles | % { $_.Name } $added = $newNames | ? { $_ -notin $existingNames -and $_ -ne "$ctxClassName.cs" } $removed = $existingNames | ? { $_ -notin $newNames -and $_ -ne "$ctxClassName.cs" } $common = $newNames | ? { $_ -in $existingNames -and $_ -ne "$ctxClassName.cs" } $perEntityDiff = @{} foreach($n in $common){ $perEntityDiff[$n] = _ModelDiffReport -oldFile (Join-Path $modelsDir $n) -newFile (Join-Path $tempModelsAbs $n) } foreach($n in $added){ $perEntityDiff[$n] = _ModelDiffReport -oldFile $null -newFile (Join-Path $tempModelsAbs $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] $has = ($d.Added.Count -or $d.Removed.Count -or $d.Changed.Count) if ($has){ 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 $backupPath = Join-Path $backupDir ("backup_{0}" -f $stamp); _EnsureDir $backupPath _Note "Creating backup: $backupPath" Copy-Item -Path (Join-Path $ProjectPath '*') -Destination $backupPath -Recurse -Force -Exclude $TempRoot,'.git','.vs','bin','obj' } # Copy new entity models (including the new context file), then reopen context and merge carry-over. _Note "Refreshing entity models..." _EnsureDir $modelsDir Copy-Item -Path (Join-Path $tempModelsAbs '*') -Destination $modelsDir -Recurse -Force $projCtxPath = Join-Path $modelsDir "$ctxClassName.cs" if (-not (Test-Path $projCtxPath)) { _Fail "Scaffold did not produce expected context '$ctxClassName.cs'." } $newCtxContent = _ReadAll $projCtxPath $mergedCtx = _InsertCarryOver -newCtxContent $newCtxContent -carryOver $carryOver if ($null -eq $mergedCtx) { _Fail "Could not merge OnModelCreating in new context." } Set-Content -Path $projCtxPath -Value $mergedCtx -Encoding UTF8 # Optionally generate controllers for NEW entities only $controllersGenerated = 0 if ($RegenerateControllers) { _EnsureCodegenTool _EnsureSqlServerPkgForPgWhenCodegen -projectDir $ProjectPath -engineName $engine.Engine $controllersPath = _FindControllersPath $ProjectPath Push-Location $ProjectPath try { $null = dotnet build 2>&1 foreach($entityName in $added){ $modelName = [IO.Path]::GetFileNameWithoutExtension($entityName) $controllerFile = Join-Path $controllersPath ("{0}Controller.cs" -f $modelName) if (Test-Path $controllerFile) { continue } $projName = _GetProjectName $ProjectPath $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 } } # Optional migration (parity tracking) $migrationCreated = $false if ($CreateMigration) { 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 } } # Report $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) $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 = @( "Update-ApiToolsFromDatabase @ $stamp", "Engine: $($engine.Engine)", "Database: $($engine.DatabaseName)", "Context: $ctxClassName", "Models added: $($added.Count)", "Models removed: $($removed.Count)", "Models overwritten: $($common.Count)", ($controllersGenerated -gt 0) ? "Controllers created: $controllersGenerated (new entities)" : $null, ($migrationCreated) ? "Migration created: $MigrationName" : $null, "Custom (non-entity) statements preserved and inserted before OnModelCreatingPartial(modelBuilder)." ) | Where-Object { $_ -ne $null } $txt -join "`r`n" | Set-Content -Path $reportTxt -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 | % { $_.Added.Count } | Measure-Object -Sum).Sum $totalPropsRemoved = ($perEntityDiff.Values | % { $_.Removed.Count } | Measure-Object -Sum).Sum $totalPropsChanged = ($perEntityDiff.Values | % { $_.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." } _Info ""; _Info "Reports saved:"; _Info (" Text: {0}" -f $reportTxt); _Info (" JSON: {0}" -f $reportJson) return $summary } else { return [pscustomobject]@{ Action='UpdateFromDatabaseCancelled'; Engine=$engine.Engine; Database=$engine.DatabaseName Context=$ctxClassName; ProjectPath=$ProjectPath; WhatIf=$true } } } finally { Pop-Location # Keep temp for audit. Uncomment to auto-clean: # Remove-Item -Path $tempRunAbs -Recurse -Force -ErrorAction SilentlyContinue } } } |