yeg
5 天以前 4370ec50a74d8bd14b8c509cf205946082493a91
探查脚本改为只报结论:几千个库时不再全量罗列

那台实例有几千个库,v1 把所有库和表都打了出来,输出没法复制也没法看。改为:

- 第 1 块只给库计数(DB_ 测试库 / 其他库各多少、本次探查了哪几个);
- 第 2 块只找两类候选表——表名像方案,或同时含工步列与名称列的表;
- 第 3 块只对"有名称列且行数 > 0"的候选打印列名与几行样例,最多 25 张;
- 结果同时写入脚本目录下的 j22-introspect-report.txt,便于慢慢复制。

另外:候选表按真实 schema 拼三段名(表不一定在 dbo 下);新增 -MaxDatabases / -MaxHits / -Database
参数控制长度;报告在出错时也会落盘。

交付脚本自身函数的演练(假连接)通过:行形状含单行不被拆层、DBNull -> null、CommandTimeout 被设上、
名称校验、Say 同时写控制台与报告集合。
1个文件已修改
203 ■■■■■ 已修改文件
scripts/j22-introspect.ps1 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/j22-introspect.ps1
@@ -1,26 +1,43 @@
<#
  22 所上位机 SQL Server 只读探查:找出「工步方案」在哪里(工步名称可能只存在于方案里)。
  与取数程序同一条纪律:只执行 SELECT,不写、不建、不改、不调用存储过程,每条查询都显式设超时。
  甲方可自行审计本文件。
  v2:那台实例有几千个库,全量罗列没法看,所以本版**只报结论**——不列库、不列表,
  只找"像方案的表"与"同时含工步列和名称列的表",命中项才打印细节;其余只给计数。
  为什么需要它:`Ws<N>_IV_0` 采样表里只有工步**号**,没有工步**名称**。
  取数程序只遍历 `DB_` 开头的测试库,所以这台实例上**其他库我们从来没看过**——
  方案的名称很可能就在那些库里。
  只读纪律与取数程序一致:只执行 SELECT,不写、不建、不改、不调用存储过程,每条查询都显式设超时。
  甲方可自行审计本文件。
  用法(在那台机器上,Windows 认证免密码):
      powershell -ExecutionPolicy Bypass -File .\j22-introspect.ps1
      powershell -ExecutionPolicy Bypass -File .\j22-introspect.ps1 -SqlInstance '.\SQLEXPRESS'
      powershell -ExecutionPolicy Bypass -File .\j22-introspect.ps1 -Database 'SomeDb'   # 只看指定库
#>
param(
    [string]$SqlInstance = '.\SQLEXPRESS',
    [int]$ConnectTimeoutSec = 10,
    [int]$CommandTimeoutSec = 15,
    [int]$SampleRows = 3
    # 非 DB_ 库最多探查多少个(按创建时间倒序,最早的先跳过)。几千个库时防止跑太久。
    [int]$MaxDatabases = 100,
    [int]$SampleRows = 3,
    # 最多打印多少张命中表:命中太多就没法复制了,其余只计数。
    [int]$MaxHits = 25,
    [string]$Database = '',
    [string]$ReportFile = ''
)
$ErrorActionPreference = 'Stop'
# 只允许常规库名:库名会被拼进查询(标识符不能用参数),异常字符一律跳过,不改写厂商数据。
# 报告同时写文件:控制台一次刷不完,文件可以慢慢复制。
if (-not $ReportFile) {
    $base = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
    $ReportFile = Join-Path $base 'j22-introspect-report.txt'
}
$report = New-Object System.Collections.ArrayList
function Say([string]$text) {
    Write-Host $text
    [void]$report.Add($text)
}
# 只允许常规库名/表名:库名与表名会被拼进查询(标识符不能用参数),异常字符一律跳过。
function Test-SafeName([string]$name) { return $name -match '^[A-Za-z0-9_$]+$' }
# 所有查询都经过这里:显式设 CommandTimeout,读满即断,不发加锁读、不开启事务。
@@ -44,99 +61,159 @@
    finally { $reader.Close(); $command.Dispose() }
}
function Write-Row($values) {
    $text = ($values | ForEach-Object { if ($null -eq $_) { '<null>' } else { [string]$_ } }) -join ' | '
    Write-Host ('    ' + $text)
}
# 表名像"方案/配方/任务"的,以及同时含工步列与名称列的,都算候选。
$planishTable = "(name like '%Step%' or name like '%Recipe%' or name like '%Task%' or name like '%Plan%'" +
    " or name like '%Program%' or name like '%Scheme%' or name like '%Procedure%' or name like '%Formula%')"
$stepishColumn = "(c.name like '%Step%' or c.name like '%工步%')"
$nameishColumn = "(c.name like '%Name%' or c.name like '%Desc%' or c.name like '%Title%'" +
    " or c.name like '%Remark%' or c.name like '%说明%' or c.name like '%名称%' or c.name like '%备注%')"
$nameishLike = "(?i)(name|desc|title|remark|说明|名称|备注)"
$connectionString = "Server=$SqlInstance;Integrated Security=True;Connect Timeout=$ConnectTimeoutSec;" +
    "Application Name=j22-introspect"
$connection = New-Object System.Data.SqlClient.SqlConnection $connectionString
$connection.Open()
Write-Host "已连接:$SqlInstance(只读探查,不写任何数据)"
try {
    # ---------------------------------------------------------------- 1. 库清单
    Write-Host ''
    Write-Host '=== 1. 用户库清单(database_id > 4) ==='
    $databases = Invoke-ReadOnlyQuery $connection @'
    Say "已连接:$SqlInstance(只读探查,不写任何数据)"
    Say ''
    # ---------------------------------------------------------------- 1. 要探查哪些库
    $all = Invoke-ReadOnlyQuery $connection @'
select name, create_date
from sys.databases
where database_id > 4
order by name
order by create_date desc
'@
    $testDatabases = New-Object System.Collections.ArrayList
    $otherDatabases = New-Object System.Collections.ArrayList
    foreach ($row in $databases) {
        $name = [string]$row[0]
        if ($name -like 'DB[_]%') { [void]$testDatabases.Add($name) } else { [void]$otherDatabases.Add($row) }
    $tests = New-Object System.Collections.ArrayList
    $others = New-Object System.Collections.ArrayList
    foreach ($row in $all) {
        if ([string]$row[0] -like 'DB[_]%') { [void]$tests.Add($row) } else { [void]$others.Add($row) }
    }
    Write-Host ("  测试库(DB_ 开头,取数程序已在读):" + $testDatabases.Count + " 个")
    Write-Host ("  其他库(取数程序从未遍历):" + $otherDatabases.Count + " 个")
    foreach ($row in $otherDatabases) { Write-Row $row }
    # ---------------------------------------------------------------- 2. 其他库里的表
    Write-Host ''
    Write-Host '=== 2. 其他库里的表 ==='
    $candidates = New-Object System.Collections.ArrayList
    foreach ($row in $otherDatabases) {
        $database = [string]$row[0]
        if (-not (Test-SafeName $database)) { Write-Host "  跳过库名异常的库:$database"; continue }
        Write-Host "  [$database]"
        try {
            $tables = Invoke-ReadOnlyQuery $connection ("select name from [$database].sys.tables order by name")
    if ($Database) {
        $targets = New-Object System.Collections.ArrayList
        [void]$targets.Add($Database)
        Say "=== 1. 指定库探查:[$Database] ==="
    }
    else {
        Say "=== 1. 探查范围 ==="
        Say ("  用户库共 " + $all.Count + " 个;其中 DB_ 开头的测试库(取数程序已在读)" + $tests.Count +
            " 个,本次不看")
        Say ("  其他库 " + $others.Count + " 个,按创建时间倒序探查前 " + $MaxDatabases + " 个")
        $targets = New-Object System.Collections.ArrayList
        $index = 0
        foreach ($row in $others) {
            if ($index -ge $MaxDatabases) { break }
            [void]$targets.Add([string]$row[0])
            $index++
        }
        catch { Write-Host ('    读取失败:' + $_.Exception.Message); continue }
        if ($tables.Count -eq 0) { Write-Host '    (没有表)'; continue }
        foreach ($table in $tables) {
            $tableName = [string]$table[0]
            Write-Host "    $tableName"
            # 方案/配方/任务这类表最可能带工步名称,登记下来看列与样例行。
            if ($tableName -match '(?i)(step|recipe|task|plan|config|program|scheme|procedure|formula)') {
                [void]$candidates.Add([pscustomobject]@{ Database = $database; Table = $tableName })
            }
        if ($others.Count -gt 0 -and $others.Count -le 20) {
            Say ("  其他库清单:" + (($others | ForEach-Object { [string]$_[0] }) -join '、'))
        }
    }
    if ($targets.Count -eq 0) {
        Say ''
        Say '=== 结论:库里没有其他库,工步方案不在数据库中 ==='
        Say '  下一步走文件侧:软件目录里的工步方案文件(.sto 方案、TaskModel.xml 等)。'
    }
    # ---------------------------------------------------------------- 2. 找候选表
    $candidates = New-Object System.Collections.ArrayList
    $tableCount = 0
    foreach ($database in $targets) {
        if (-not (Test-SafeName $database)) { Say "  跳过库名异常的库:$database"; continue }
        try {
            $byName = Invoke-ReadOnlyQuery $connection (
                "select name from [$database].sys.tables where $planishTable")
            $byColumn = Invoke-ReadOnlyQuery $connection @"
select tb.name
from [$database].sys.tables tb
where exists (select 1 from [$database].sys.columns c
              where c.object_id = tb.object_id and $stepishColumn)
  and exists (select 1 from [$database].sys.columns c
              where c.object_id = tb.object_id and $nameishColumn)
"@
            $tableCount += (Invoke-ReadOnlyQuery $connection "select count(*) from [$database].sys.tables")[0][0]
        }
        catch { Say ("  读取 [$database] 失败:" + $_.Exception.Message); continue }
        $seen = @{}
        foreach ($row in $byName) {
            $table = [string]$row[0]
            if ($seen.ContainsKey($table)) { continue }
            $seen[$table] = $true
            [void]$candidates.Add([pscustomobject]@{ Database = $database; Table = $table; Why = '表名像方案' })
        }
        foreach ($row in $byColumn) {
            $table = [string]$row[0]
            if ($seen.ContainsKey($table)) { continue }
            $seen[$table] = $true
            [void]$candidates.Add([pscustomobject]@{ Database = $database; Table = $table; Why = '含工步列+名称列' })
        }
    }
    Say ''
    Say '=== 2. 候选表统计 ==='
    Say ("  探查 " + $targets.Count + " 个库、共 " + $tableCount + " 张表;命中候选 " + $candidates.Count + " 张")
    # ---------------------------------------------------------------- 3. 候选表的列与样例行
    Write-Host ''
    Write-Host '=== 3. 候选表(表名含 step/recipe/task/plan/config 等)的列与样例行 ==='
    if ($candidates.Count -eq 0) {
        Write-Host '  没有匹配到候选表。'
    }
    $hits = 0
    foreach ($candidate in $candidates) {
        $database = $candidate.Database
        $table = $candidate.Table
        if (-not (Test-SafeName $table)) { continue }
        Write-Host "  [$database].[$table]"
        try {
            # 带上 schema:表不一定在 dbo 下,用真实 schema 拼三段名,否则计数与取样会失败。
            $columns = Invoke-ReadOnlyQuery $connection @"
select c.name, ty.name as type_name
select c.name, schema_name(tb.schema_id) as schema_name
from [$database].sys.columns c
join [$database].sys.tables tb on tb.object_id = c.object_id
join [$database].sys.types ty on ty.user_type_id = c.user_type_id
where tb.name = '$table'
order by c.column_id
"@
            $schema = [string]$columns[0][1]
            if (-not (Test-SafeName $schema)) { continue }
            $rows = (Invoke-ReadOnlyQuery $connection "select count(*) from [$database].[$schema].[$table]")[0][0]
        }
        catch { Write-Host ('    读取列失败:' + $_.Exception.Message); continue }
        catch { continue }
        $names = @()
        $allColumns = @()
        foreach ($column in $columns) {
            $columnName = [string]$column[0]
            Write-Host ('    ' + $columnName + ' : ' + [string]$column[1])
            if ($columnName -match '(?i)(name|desc|type|no|id|index|idx)') { $names += $columnName }
            $allColumns += $columnName
            if ($columnName -match $nameishLike) { $names += $columnName }
        }
        # 只报"有名称类列"的表:没有名称列的表存不下工步名称,不是我们要找的。
        if ($names.Count -eq 0) { continue }
        # 只挑"像是名称/编号"的列看几行:这是为了判断这张表里到底有没有工步名称,不导出业务数据。
        $projection = ($names | Select-Object -First 8 | ForEach-Object { "[$_]" }) -join ', '
        if ($rows -le 0) { continue }
        $hits++
        # 命中太多就没法复制了:只打印前 -MaxHits 条,其余只计数(报告文件里也只有这些)。
        if ($hits -gt $MaxHits) { continue }
        Say ''
        Say ("  [$database].[$schema].[$table]  行数 $rows  命中原因:" + $candidate.Why)
        Say ("    列:" + ($allColumns -join ', '))
        $projection = ($names | Select-Object -First 6 | ForEach-Object { "[$_]" }) -join ', '
        try {
            $sample = Invoke-ReadOnlyQuery $connection "select top $SampleRows $projection from [$database].dbo.[$table]"
            foreach ($line in $sample) { Write-Row $line }
            $sample = Invoke-ReadOnlyQuery $connection "select top $SampleRows $projection from [$database].[$schema].[$table]"
            foreach ($line in $sample) {
                Say ('    样例:' + (($line | ForEach-Object { if ($null -eq $_) { '<null>' } else { [string]$_ } }) -join ' | '))
            }
        }
        catch { Write-Host ('    取样失败:' + $_.Exception.Message) }
        catch { Say ('    取样失败:' + $_.Exception.Message) }
    }
    Write-Host ''
    Write-Host '探查结束:本条只读,未写入任何数据。'
    Say ''
    if ($hits -eq 0) {
        Say '=== 结论:没有找到"带工步名称的表" ==='
        Say '  下一步:① 提高 -MaxDatabases 再看一遍;② 转文件侧,查软件目录里的工步方案文件。'
    }
    else {
        Say ("=== 结论:命中 " + $hits + " 张带名称列的候选表(最多打印 " + $MaxHits + " 张,见上),把上面这段发回即可 ===")
    }
    Say ('报告同时写在:' + $ReportFile)
}
finally { $connection.Close(); $connection.Dispose() }
finally {
    $connection.Close(); $connection.Dispose()
    # 中途出错也把已有内容落盘:控制台刷不完,文件可以慢慢复制。
    $report | Set-Content -LiteralPath $ReportFile -Encoding UTF8
}