Private/SqlPackageHelpers.ps1
|
# SqlPackageHelpers.ps1 # Funciones helper para Invoke-SqlPackage <# .SYNOPSIS Lee y parsea el archivo sqlpackage.yaml del directorio actual. .DESCRIPTION Usa ConvertFrom-Yaml (powershell-yaml) para parsear el archivo de configuración. Valida que existan las secciones mínimas requeridas (properties). .PARAMETER Path Ruta al archivo sqlpackage.yaml. Por defecto: ./sqlpackage.yaml .OUTPUTS Hashtable con la configuración parseada. #> function Read-SqlPackageConfig { [CmdletBinding()] param( [Parameter()] [string]$Path = ".\sqlpackage.yaml" ) if (Get-Command -Name Ensure-YamlModule -ErrorAction SilentlyContinue) { Ensure-YamlModule } elseif (-not (Get-Command -Name ConvertFrom-Yaml -ErrorAction SilentlyContinue)) { try { Import-Module powershell-yaml -ErrorAction Stop | Out-Null } catch { throw "No se encontró el módulo 'powershell-yaml'. Instale con: Install-Module powershell-yaml -Scope CurrentUser -Force" } } if (-not (Test-Path $Path)) { throw "No se encontró '$Path'. Ejecute 'Invoke-SqlPackage -Init' para generarlo." } $raw = Get-Content $Path -Raw $config = ConvertFrom-Yaml $raw if (-not $config.properties) { throw "El archivo '$Path' no tiene la sección 'properties' requerida." } return $config } <# .SYNOPSIS Construye el array de argumentos para sqlpackage.exe. .DESCRIPTION Traduce la configuración YAML + credenciales .env en argumentos de línea de comando compatibles con sqlpackage.exe. .PARAMETER Action La acción de SqlPackage (Publish, DeployReport, Script, Extract, Export, Import). .PARAMETER Config Hashtable de configuración leída de sqlpackage.yaml. .PARAMETER EnvVars Hashtable de variables de entorno leídas de .env. .PARAMETER DacpacPath Ruta al archivo .dacpac (requerido para Publish, DeployReport, Script). .PARAMETER OutputPath Ruta de salida para el archivo generado (requerido para DeployReport, Script, Extract, Export). .PARAMETER SourcePath Ruta al archivo fuente .bacpac (requerido para Import). .OUTPUTS String[] — Array de argumentos para sqlpackage.exe. #> function Build-SqlPackageArgs { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('Publish', 'DeployReport', 'Script', 'Extract', 'Export', 'Import')] [string]$Action, [Parameter(Mandatory)] [hashtable]$Config, [Parameter(Mandatory)] [hashtable]$EnvVars, [Parameter()] [string]$DacpacPath, [Parameter()] [string]$OutputPath, [Parameter()] [string]$SourcePath, # El nombre de la base llega resuelto desde <Name> del .sqlproj (ADR 0011); el env solo # aporta conexion y credenciales. [Parameter(Mandatory)] [string]$Database ) $sqlArgs = @("/Action:$Action") # Conexión al servidor (credenciales desde .env) $server = $EnvVars['DB_SERVER'] $database = $Database $user = $EnvVars['DB_USER'] $password = $EnvVars['DB_PASSWORD'] if (-not $server -or -not $user -or -not $password) { throw "Faltan variables en el env file. Se requieren: DB_SERVER, DB_USER, DB_PASSWORD" } # Acciones que usan Source (el .dacpac como fuente) $dacpacActions = @('Publish', 'DeployReport', 'Script') # Acciones que leen del servidor (Extract, Export) $serverSourceActions = @('Extract', 'Export') if ($Action -in $dacpacActions) { if (-not $DacpacPath) { throw "Se requiere -DacpacPath para la acción '$Action'" } $sqlArgs += "/SourceFile:$DacpacPath" $sqlArgs += "/TargetServerName:$server" $sqlArgs += "/TargetDatabaseName:$database" $sqlArgs += "/TargetUser:$user" $sqlArgs += "/TargetPassword:$password" $sqlArgs += "/TargetTrustServerCertificate:True" $sqlArgs += "/TargetEncryptConnection:True" } elseif ($Action -in $serverSourceActions) { $sqlArgs += "/SourceServerName:$server" $sqlArgs += "/SourceDatabaseName:$database" $sqlArgs += "/SourceUser:$user" $sqlArgs += "/SourcePassword:$password" $sqlArgs += "/SourceTrustServerCertificate:True" $sqlArgs += "/SourceEncryptConnection:True" } elseif ($Action -eq 'Import') { if (-not $SourcePath) { throw "Se requiere la ruta al archivo .bacpac en la configuración (import.sourcePath)" } $sqlArgs += "/SourceFile:$SourcePath" $sqlArgs += "/TargetServerName:$server" $sqlArgs += "/TargetDatabaseName:$database" $sqlArgs += "/TargetUser:$user" $sqlArgs += "/TargetPassword:$password" $sqlArgs += "/TargetTrustServerCertificate:True" $sqlArgs += "/TargetEncryptConnection:True" } # Output path if ($OutputPath) { if ($Action -in $serverSourceActions) { $sqlArgs += "/TargetFile:$OutputPath" } else { $sqlArgs += "/OutputPath:$OutputPath" } } # Propiedades /p: (solo para acciones que las soportan) $propsActions = @('Publish', 'DeployReport', 'Script') if ($Action -in $propsActions -and $Config.properties) { foreach ($key in $Config.properties.Keys) { $sqlArgs += "/p:$key=$($Config.properties[$key])" } } # Variables SqlCmd /v: desde .env: cualquier variable con prefijo SQLVAR_ se pasa como # /v:<nombre>=<valor> (p. ej. SQLVAR_FotosApiLoginPassword -> /v:FotosApiLoginPassword=...). # Permite inyectar secretos (p. ej. contrasenas de logins en el modelo) en el deploy sin # commitearlos en el repo. if ($Action -in $propsActions) { foreach ($key in $EnvVars.Keys) { if ($key -like 'SQLVAR_*') { $varName = $key.Substring(7) $sqlArgs += "/v:$varName=$($EnvVars[$key])" } } } return $sqlArgs } <# .SYNOPSIS Busca el archivo .dacpac correspondiente al proyecto SQL actual. .DESCRIPTION Localiza el archivo .sqlproj en el directorio actual, extrae el nombre del proyecto y construye la ruta esperada del .dacpac en bin/Debug/. .OUTPUTS String — Ruta al archivo .dacpac. #> function Find-DacpacPath { [CmdletBinding()] param() $sqlproj = Get-ChildItem -Path "." -Filter "*.sqlproj" -File | Select-Object -First 1 if (-not $sqlproj) { throw "No se encontró un archivo .sqlproj en el directorio actual." } $projectName = [System.IO.Path]::GetFileNameWithoutExtension($sqlproj.Name) $dacpacPath = ".\bin\Debug\$projectName.dacpac" return $dacpacPath } <# .SYNOPSIS Indexa por Id las alertas de un DeployReport XML. .DESCRIPTION SqlPackage no escribe las consecuencias de una operación dentro de su <Item>: las deja en un bloque <Alerts> aparte y las enlaza por Id desde el objeto afectado. Un Alter de tabla que borra tres columnas se ve, en la rama <Operations>, exactamente igual que uno que no borra nada; la diferencia entera vive en <Alerts>. Esta función resuelve ese enlace: devuelve un índice Id → alerta que Show-DeployReport usa para colgar cada advertencia bajo el objeto que la provoca. .PARAMETER Report Documento XML del DeployReport ya cargado. .OUTPUTS Hashtable — Id (string) → PSCustomObject con Id, Kind, Message, IsDataLoss. #> function Get-DeployReportAlert { [CmdletBinding()] param( [Parameter(Mandatory)] [xml]$Report ) $index = @{} # ChildNodes + LocalName en vez de $Report.DeploymentReport.Alerts.Alert: el documento # declara un namespace por defecto y los nombres 'Name' y 'Value' chocan con propiedades # propias de XmlElement. $alertsNode = $Report.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'Alerts' } $alerts = $alertsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Alert' } foreach ($alert in $alerts) { $kind = $alert.GetAttribute('Name') $issues = $alert.ChildNodes | Where-Object { $_.LocalName -eq 'Issue' } foreach ($issue in $issues) { $id = $issue.GetAttribute('Id') $index[$id] = [PSCustomObject]@{ Id = $id Kind = $kind Message = $issue.GetAttribute('Value') IsDataLoss = ($kind -eq 'DataIssue') } } } return $index } <# .SYNOPSIS Parsea un DeployReport XML y muestra un resumen visual de las operaciones. .DESCRIPTION Lee el archivo XML generado por SqlPackage /Action:DeployReport y muestra cada operación detectada con formato y colores, junto con las alertas que cada objeto arrastra. Las alertas importan tanto como las operaciones. El 2026-08-10 un despliegue a IMPULSA mostró "Alter → [dbo].[CanastaPersona]" y nada más; ese Alter borraba tres columnas con datos y el despliegue abortó contra el guardián de BlockOnPossibleDataLoss. La advertencia existía —en la salida cruda de sqlpackage, sepultada entre 27 warnings de cuentas— pero el resumen que uno lee justo antes de teclear "y" la callaba. .PARAMETER ReportPath Ruta al archivo XML del DeployReport. .OUTPUTS PSCustomObject[] — Array de operaciones encontradas, o $null si no hay cambios. Cada elemento lleva Operation, Object, Issues (string[]) y HasDataLoss (bool). #> function Show-DeployReport { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ReportPath ) if (-not (Test-Path $ReportPath)) { throw "No se encontró el reporte: $ReportPath" } [xml]$report = Get-Content $ReportPath $operations = $report.DeploymentReport.Operations.Operation $alerts = Get-DeployReportAlert -Report $report $referenced = @{} $dataLossCount = 0 $dataLossObjects = 0 if ($null -eq $operations) { Show-DeployReportOrphanAlert -Alerts $alerts -Referenced $referenced | Out-Null Write-Host " No hay cambios pendientes. La base de datos está sincronizada." -ForegroundColor Green return $null } Write-Host "" Write-Host " Cambios detectados:" -ForegroundColor Cyan $results = @() foreach ($operation in @($operations)) { $op = $operation.GetAttribute('Name') $items = $operation.ChildNodes | Where-Object { $_.LocalName -eq 'Item' } foreach ($item in $items) { $itemAlerts = @() foreach ($ref in ($item.ChildNodes | Where-Object { $_.LocalName -eq 'Issue' })) { $id = $ref.GetAttribute('Id') $referenced[$id] = $true if ($alerts.ContainsKey($id)) { $itemAlerts += $alerts[$id] } } $lossAlerts = @($itemAlerts | Where-Object { $_.IsDataLoss }) $hasDataLoss = $lossAlerts.Count -gt 0 if ($hasDataLoss) { $dataLossCount += $lossAlerts.Count $dataLossObjects++ } $objectName = $item.GetAttribute('Value') Write-Host " $op → $objectName" -ForegroundColor $(if ($hasDataLoss) { 'Red' } else { 'Yellow' }) foreach ($alert in $itemAlerts) { Write-Host " $(Format-DeployReportAlert -Alert $alert)" ` -ForegroundColor $(if ($alert.IsDataLoss) { 'Red' } else { 'DarkYellow' }) } $results += [PSCustomObject]@{ Operation = $op Object = $objectName Issues = @($itemAlerts | ForEach-Object { $_.Message }) HasDataLoss = $hasDataLoss } } } $dataLossCount += (Show-DeployReportOrphanAlert -Alerts $alerts -Referenced $referenced) if ($dataLossCount -gt 0) { Write-Host "" Write-Host " PÉRDIDA DE DATOS: $dataLossCount advertencia(s) en $dataLossObjects objeto(s)." -ForegroundColor Red Write-Host " Con BlockOnPossibleDataLoss activo el despliegue abortará; sin él, los datos se van." -ForegroundColor Red } Write-Host "" return $results } <# .SYNOPSIS Formatea una alerta del DeployReport como una línea legible. #> function Format-DeployReportAlert { [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$Alert ) $label = if ($Alert.IsDataLoss) { 'PÉRDIDA DE DATOS' } else { $Alert.Kind } return "! ${label}: $($Alert.Message)" } <# .SYNOPSIS Muestra las alertas que ningún objeto del plan referencia. .DESCRIPTION Una alerta cuyo Id no aparece en ningún <Item> no tiene dónde colgarse. Callarla repetiría, en la otra rama del XML, el mismo modo de falla que este módulo corrige: información de pérdida de datos presente en el reporte y ausente del resumen. .OUTPUTS Int — Cantidad de alertas huérfanas de pérdida de datos, para el conteo del resumen. #> function Show-DeployReportOrphanAlert { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Alerts, [Parameter(Mandatory)] [hashtable]$Referenced ) $orphans = @($Alerts.Values | Where-Object { -not $Referenced.ContainsKey($_.Id) }) if ($orphans.Count -eq 0) { return 0 } Write-Host "" Write-Host " Alertas sin objeto asociado:" -ForegroundColor Red foreach ($alert in $orphans) { Write-Host " $(Format-DeployReportAlert -Alert $alert)" ` -ForegroundColor $(if ($alert.IsDataLoss) { 'Red' } else { 'DarkYellow' }) } return @($orphans | Where-Object { $_.IsDataLoss }).Count } <# .SYNOPSIS Copia las plantillas de configuración al directorio actual. .DESCRIPTION Copia sqlpackage.yaml y .env.example desde los templates del módulo al directorio de trabajo actual. No sobrescribe archivos existentes. .PARAMETER Force Sobrescribir archivos existentes. #> function New-SqlPackageConfig { [CmdletBinding()] param( [switch]$Force ) $templateDir = Join-Path $PSScriptRoot "..\Resources\Invoke-SqlPackage\templates" # sqlpackage.yaml $yamlTarget = Join-Path (Get-Location) "sqlpackage.yaml" $yamlSource = Join-Path $templateDir "sqlpackage.yaml" if ((Test-Path $yamlTarget) -and -not $Force) { Write-Host " sqlpackage.yaml ya existe (use -Force para sobrescribir)" -ForegroundColor Yellow } else { Copy-Item $yamlSource $yamlTarget -Force Write-Host " sqlpackage.yaml creado" -ForegroundColor Green } # .env $envTarget = Join-Path (Get-Location) ".env" $envExampleSource = Join-Path $templateDir ".env.example" if ((Test-Path $envTarget) -and -not $Force) { Write-Host " .env ya existe (use -Force para sobrescribir)" -ForegroundColor Yellow } else { Copy-Item $envExampleSource $envTarget -Force Write-Host " .env creado (configure las credenciales)" -ForegroundColor Green } } |