internal/functions/providers/schema/Get-SldgSqliteSchema.ps1
|
function Get-SldgSqliteSchema { <# .SYNOPSIS Reads the schema from a SQLite database. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'SchemaFilter', Justification = 'Provider interface parameter')] [CmdletBinding()] param ( [Parameter(Mandatory)] [SqlLabDataGenerator.Connection]$ConnectionInfo, [string[]]$TableFilter, [string[]]$SchemaFilter ) $conn = $ConnectionInfo.DbConnection $dialect = $ConnectionInfo.GetDialect() # SQLite stores schema in sqlite_master $tables = @() $cmd = $conn.CreateCommand() try { $cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" $reader = $cmd.ExecuteReader() try { while ($reader.Read()) { $tables += $reader['name'] } } finally { $reader.Close() $reader.Dispose() } } finally { $cmd.Dispose() } if ($TableFilter) { $tables = $tables | Where-Object { $_ -in $TableFilter } } $tableInfos = foreach ($tableName in $tables) { $safeTableName = $dialect.QuoteIdentifier($tableName) # Get column info via PRAGMA $columns = @() $cmd = $conn.CreateCommand() try { $cmd.CommandText = "PRAGMA table_info($safeTableName)" $reader = $cmd.ExecuteReader() try { while ($reader.Read()) { $columns += [PSCustomObject]@{ cid = $reader['cid'] name = $reader['name'] type = $reader['type'] notnull = $reader['notnull'] dflt = $reader['dflt_value'] pk = $reader['pk'] } } } finally { $reader.Close() $reader.Dispose() } } finally { $cmd.Dispose() } # Get foreign keys via PRAGMA $fks = @() $cmd = $conn.CreateCommand() try { $cmd.CommandText = "PRAGMA foreign_key_list($safeTableName)" $reader = $cmd.ExecuteReader() try { while ($reader.Read()) { $fks += [PSCustomObject]@{ id = $reader['id'] seq = $reader['seq'] table = $reader['table'] from = $reader['from'] to = $reader['to'] } } } finally { $reader.Close() $reader.Dispose() } } finally { $cmd.Dispose() } # Get unique indexes via PRAGMA $uniqueColumns = @{} $cmd = $conn.CreateCommand() try { $cmd.CommandText = "PRAGMA index_list($safeTableName)" $reader = $cmd.ExecuteReader() $uniqueIndexes = @() try { while ($reader.Read()) { if ($reader['unique'] -eq 1) { $uniqueIndexes += [string]$reader['name'] } } } finally { $reader.Close() $reader.Dispose() } } finally { $cmd.Dispose() } foreach ($idxName in $uniqueIndexes) { $safeIdxName = $dialect.QuoteIdentifier($idxName) $cmd = $conn.CreateCommand() try { $cmd.CommandText = "PRAGMA index_info($safeIdxName)" $reader = $cmd.ExecuteReader() try { while ($reader.Read()) { $uniqueColumns[[string]$reader['name']] = $true } } finally { $reader.Close() $reader.Dispose() } } finally { $cmd.Dispose() } } # Check for autoincrement (rowid alias with INTEGER PRIMARY KEY) $hasAutoIncrement = @{} foreach ($col in $columns) { if ($col.pk -gt 0 -and $col.type -match '^INTEGER$') { $hasAutoIncrement[$col.name] = $true } } # Extract CHECK constraints from CREATE TABLE SQL $checkConstraintMap = @{} $createCmd = $conn.CreateCommand() try { $createCmd.CommandText = "SELECT sql FROM sqlite_master WHERE type='table' AND name = $($dialect.ParameterPlaceholder('name'))" $nameParam = $createCmd.CreateParameter() $nameParam.ParameterName = $dialect.ParameterName('name') $nameParam.Value = $tableName [void]$createCmd.Parameters.Add($nameParam) $createSql = $createCmd.ExecuteScalar() if ($createSql) { # Match inline column CHECK constraints: CHECK(expression) # Use regex with timeout to prevent ReDoS on pathological CREATE TABLE statements $regexTimeout = [timespan]::FromSeconds(2) try { # Match per-column CHECK: "colname" TYPE ... CHECK(expr) # Also match table-level CHECK constraints and try to associate them by column name reference $checkRegex = [regex]::new('CHECK\s*\(([^)]+)\)', 'IgnoreCase', $regexTimeout) $checkMatches = $checkRegex.Matches($createSql) foreach ($m in $checkMatches) { $checkExpr = $m.Groups[1].Value.Trim() # Try to associate the check with a specific column by finding which column name appears in it foreach ($col in $columns) { $escapedName = [regex]::Escape($col.name) if ([regex]::IsMatch($checkExpr, "(?i)\b$escapedName\b", 'None', $regexTimeout)) { if (-not $checkConstraintMap.ContainsKey($col.name)) { $checkConstraintMap[$col.name] = [System.Collections.Generic.List[string]]::new() } $checkConstraintMap[$col.name].Add($checkExpr) } } } } catch [System.Text.RegularExpressions.RegexMatchTimeoutException] { Write-PSFMessage -Level Warning -Message ($script:strings.'Schema.SQLite.CheckParseTimeout' -f $tableName) } } } finally { $createCmd.Dispose() } # Build column objects $colObjects = foreach ($col in $columns) { $fk = $fks | Where-Object { $_.from -eq $col.name } | Select-Object -First 1 $fkInfo = if ($fk) { [SqlLabDataGenerator.ForeignKeyRef]@{ ForeignKeyName = "FK_${tableName}_$($fk.from)_$($fk.table)_$($fk.to)" ReferencedSchema = 'main' ReferencedTable = $fk.table ReferencedColumn = $fk.to } } else { $null } # SQLite has type affinities, not types: a declared 'VARCHAR(50)' is TEXT affinity, # 'INTEGER PRIMARY KEY' is the rowid, and anything may be declared. Map the declared # name through the affinity rules first, then onto the engine's canonical vocabulary. $declared = ($col.type -replace '\(.*\)', '').Trim() $dataType = switch -Regex ($declared.ToUpperInvariant()) { 'INT' { if ($declared -match '(?i)^(big|hugeint)') { 'bigint' } elseif ($declared -match '(?i)^(small|tiny)') { 'smallint' } else { 'int' } } 'CHAR|CLOB|TEXT' { ConvertTo-SldgCanonicalDataType -DataType $declared } 'BLOB' { 'varbinary' } 'REAL|FLOA|DOUB' { 'float' } 'NUMERIC|DECIMAL|MONEY' { 'decimal' } 'BOOL' { 'bit' } 'DATETIME|TIMESTAMP' { 'datetime2' } 'DATE' { 'date' } 'TIME' { 'time' } 'GUID|UUID' { 'uniqueidentifier' } 'JSON' { 'json' } default { if ($declared) { ConvertTo-SldgCanonicalDataType -DataType $declared } else { 'nvarchar' } } } # Extract max length from type like VARCHAR(255) $maxLength = $null if ($col.type -match '\((\d+)\)') { $maxLength = [int]$Matches[1] } [SqlLabDataGenerator.ColumnInfo]@{ ColumnName = $col.name DataType = $dataType MaxLength = $maxLength NumericPrecision = $null NumericScale = $null IsNullable = $col.notnull -eq 0 IsPrimaryKey = $col.pk -gt 0 IsIdentity = $hasAutoIncrement.ContainsKey($col.name) IsComputed = $false IsUnique = $col.pk -gt 0 -or $uniqueColumns.ContainsKey($col.name) DefaultValue = $col.dflt ForeignKey = $fkInfo SemanticType = $null Classification = $null GenerationRule = $null CheckConstraints = if ($checkConstraintMap.ContainsKey($col.name)) { @($checkConstraintMap[$col.name]) } else { @() } SchemaHint = $null ViewDetectedFormat = $null } } # Build FK list $fkList = foreach ($fk in $fks) { [SqlLabDataGenerator.ForeignKeyInfo]@{ ForeignKeyName = "FK_${tableName}_$($fk.from)_$($fk.table)_$($fk.to)" ParentSchema = 'main' ParentTable = $tableName ParentColumn = $fk.from ReferencedSchema = 'main' ReferencedTable = $fk.table ReferencedColumn = $fk.to } } [SqlLabDataGenerator.TableInfo]@{ SchemaName = 'main' TableName = $tableName FullName = "main.$tableName" Columns = $colObjects ColumnCount = $colObjects.Count ForeignKeys = @($fkList) } } $dbName = if ($conn.DataSource) { [System.IO.Path]::GetFileNameWithoutExtension($conn.DataSource) } else { 'SQLite' } [SqlLabDataGenerator.SchemaModel]@{ Database = $dbName Tables = @($tableInfos) TableCount = @($tableInfos).Count DiscoveredAt = Get-Date } } |