lib/functions/Private/ConvertTo-SqlSchemaEditorModel.ps1
|
function ConvertTo-SqlSchemaEditorModel { <# .SYNOPSIS Turns a raw GetSqlSchema response into the two-level model the Monaco editor's completion provider consumes. .DESCRIPTION Extracted from Complete-SqlSchemaRetrieval by issue #158, which needs this shape for a second caller: a database other than the active one is pushed to the editor through setSchemaForDatabase(name, schema) and must be normalised exactly like the active one. A second copy of the splitting rules would be a second chance for a non-active database to complete differently from the active one. It is also the only part of that function that is pure, so extracting it is what makes the normalisation unit-testable without a WebView or a WPF tree. The rules are carried over unchanged from the code this replaces, and deliberately match Get-SqlSchemaModel (the index the validation pass resolves against): * The response is one object whose property names are "schema.table". Split on the FIRST dot only - a table name may legitimately contain one, the schema name is always in front. * Each column entry is "ColumnName DataType". Split on the first run of whitespace and keep the remainder intact, because the type itself may contain spaces ("nvarchar(50) NOT NULL"). .PARAMETER SchemaResponse The response object as cached in $Script:SqlSchemaCache: the payload is on its .d property. Null, an ErrorRecord, or a response with no .d yields an empty model, which serialises to "{}" and is what tells the editor this database has no completions rather than leaving it with a previous database's. .OUTPUTS [hashtable] schema -> table -> @( [PSCustomObject]@{ n = column name; t = data type } ). The column arrays are wrapped in @() so a single-column table still serialises as a JSON array rather than as a lone object - the editor's normalizeColumn only maps arrays. #> [CmdletBinding()] param( [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline = $true)] [AllowNull()] $SchemaResponse ) # No tracer preamble: the parameter is the tenant's schema, which names every table and column in # the customer's database (issue #61 section 5). process { $SchemaObject = @{} if ($null -eq $SchemaResponse -or $SchemaResponse -is [System.Management.Automation.ErrorRecord]) { return $SchemaObject } $Payload = $SchemaResponse.d if ($null -eq $Payload) { return $SchemaObject } # ONE pass over the tables, grouping them by schema as it goes. This used to collect the schema # names first and then filter every table again for each schema, with a pipeline per column - # 1.5 s for a 565-table database on the UI thread, against 0.4 s for this, for the same model. # PSObject.Properties rather than Get-Member: same NoteProperties, without the cmdlet. One # compiled regex for every column, rather than the -split operator per entry. $ColumnSplitter = [regex]::new("\s+") foreach ($Property in $Payload.PSObject.Properties) { # NoteProperties only, which is exactly what Get-Member -MemberType NoteProperty returned: # a payload that is not an object (a string, say) has CLR properties such as Length too. if ($Property.MemberType -ne [System.Management.Automation.PSMemberTypes]::NoteProperty) { continue } $Part = $Property.Name.Split(".", 2) $SchemaName = $Part[0] if (-not $SchemaObject.ContainsKey($SchemaName)) { $SchemaObject[$SchemaName] = @{} } # A name without a dot has a schema and no table, which is what the two-pass version made # of it too: the schema, empty. if ($Part.Count -lt 2) { continue } $ColumnList = [System.Collections.Generic.List[object]]::new() foreach ($Entry in @($Property.Value)) { $ColumnPart = $ColumnSplitter.Split(([string]$Entry).Trim(), 2) $ColumnList.Add([PSCustomObject][Ordered]@{ n = $ColumnPart[0] t = if ($ColumnPart.Count -gt 1) { $ColumnPart[1].Trim() } else { "" } }) } $SchemaObject[$SchemaName][$Part[1]] = $ColumnList.ToArray() } return $SchemaObject } } function Get-SqlSchemaEditorJson { <# .SYNOPSIS The editor model of a cached schema as compact JSON, built once per response. .DESCRIPTION Every completion of a schema request pushes the schema to the editor - also when it is served from the cache, because the CALLER (a tab switch, the schema window opening, a database node being expanded) still has to be served, and each tab has its own editor. Converting the response again for every one of those cost about a second per large database on the UI thread, for a string that cannot have changed. So the string is kept beside the response, under the same cache key. Complete-SqlSchemaRetrieval drops the entry whenever it stores a DIFFERENT response object for the key (a fresh fetch, a refresh), and Reset-SqlSchemaCache drops the pool's entries, so a string never outlives the response it was built from. -Compress: the editor parses the payload, and nobody reads it. The indented form took 3.5 times as long and 35,000 lines for a large database; Complete-SqlSchemaRetrieval still logs it at VERBOSE2 for whoever needs to read it. .PARAMETER SchemaCacheKey The "<SessionKey>|<DoId>" key the response is cached under. Empty means "do not memoise". .PARAMETER SchemaResponse The response the JSON is built from when it is not memoised yet. .OUTPUTS [string] the JSON the editor's setSchema / setSchemaForDatabase receive. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $false)] [AllowNull()] [AllowEmptyString()] [string]$SchemaCacheKey, [Parameter(Mandatory = $false)] [AllowNull()] $SchemaResponse ) # No tracer preamble: called once per schema push, and its result is the tenant's schema. if ($null -eq $Script:SqlSchemaEditorJsonCache) { $Script:SqlSchemaEditorJsonCache = @{} } if (![string]::IsNullOrWhiteSpace($SchemaCacheKey) -and $Script:SqlSchemaEditorJsonCache.ContainsKey($SchemaCacheKey)) { return $Script:SqlSchemaEditorJsonCache[$SchemaCacheKey] } $Private:Json = ConvertTo-SqlSchemaEditorModel -SchemaResponse $SchemaResponse | ConvertTo-Json -Depth 5 -Compress if (![string]::IsNullOrWhiteSpace($SchemaCacheKey)) { $Script:SqlSchemaEditorJsonCache[$SchemaCacheKey] = $Private:Json } return $Private:Json } |