Repository navigation
Expand file tree
/
Copy pathmssql.ts
More file actions
3807 lines (3538 loc) · 182 KB
/
Copy pathmssql.ts
File metadata and controls
3807 lines (3538 loc) · 182 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Microsoft SQL Server Database Provider
* Full MSSQL support with connection pooling (SQL Authentication)
*/
import mssql from "mssql";
import { SQLBaseProvider } from "./sql-base";
import { MSSQL_CREATE_TABLE } from "./mssql-create-table";
import { type MssqlColumnMetadata, mssqlColumnTypes } from "./column-types";
import {
type DatabaseConnection,
type QueryResult,
type HealthInfo,
type MaintenanceType,
type MaintenanceResult,
type ProviderOptions,
type ProviderExecutionContext,
type ReadOnlyStatementBudget,
type ReadOnlyStatementMode,
type ProviderCapabilities,
type ProviderLabels,
type SlowQuery,
type ActiveSession,
type DatabaseOverview,
type PerformanceMetrics,
type SlowQueryStats,
type ActiveSessionDetails,
type TableStats,
type IndexStats,
type StorageStats,
type PreparedQuery,
type QueryPrepareOptions,
type Container,
type DatabaseObject,
type KindCount,
type ObjectDetail,
type ObjectDetailBatch,
type ObjectKindSpec,
type ColumnSchema,
type IndexSchema,
type ForeignKeySchema,
type ContainerLevelSpec,
type ObjectSourceDocument,
type ObjectSourcePart,
} from "../../types";
import {
applySourceBound,
assertContainerPathShape,
callerBoundTruncationReason,
containerDepth,
type ContainerPathShapeEngine,
declaredKinds,
findKind,
requireSourceKind,
} from "../../object-kinds";
import { comparePaths } from "../../object-path";
import {
DatabaseConfigError,
ConnectionError,
ExecutionProfileError,
QueryError,
mapDatabaseError,
NO_ACTIVE_TRANSACTION,
} from "../../errors";
import { assertReadOnlyBudget, measureResultBytes } from "./read-only-budget";
import { formatBytes } from "../../utils/pool-manager";
import { analyzeQuery, assertRowBounds, DEFAULT_QUERY_LIMIT, MAX_UNLIMITED_ROWS } from "../../utils/query-limiter";
import { measuredNullableAggregate } from "../../utils/measured-aggregate";
import { uniqueFieldNames } from "../../utils/result-fields";
import { readLeadingKeyword } from "@/lib/sql/leading-keyword";
import { resolveSqlGrammar, type SqlGrammar } from "@/lib/sql/grammar";
import { hasLoneCarriageReturnInLineComment } from "@/lib/sql/spans";
import { readStatementEnd } from "@/lib/sql/statement-end";
import { CACHE_HIT_RATIO_UNAVAILABLE, formatCacheHitRatio, measuredNumber } from "@/lib/monitoring-cache-ratio";
/**
* SQL Server's identity for the shared container-path renderer.
*
* Which paths this engine accepts is not a field here: it is `containerPathShapes` in
* `getCapabilities()`, which the object routes read too (#1147).
*/
const MSSQL_CONTAINER_PATH_ENGINE: ContainerPathShapeEngine = {
code: "mssql",
label: "A SQL Server",
shapeNames: "label",
};
/**
* `SELECT ... ` with `TOP n` spliced in where T-SQL wants it, or `null` when this
* statement has no leading `SELECT` to splice after.
*
* The insertion point comes from `readLeadingKeyword` rather than from a
* `^\s*SELECT\s+` rewrite, because that pattern silently matched nothing behind a
* leading comment while the caller had already committed to `wasLimited: true`
* (#275). `DISTINCT` is found the same way, so `SELECT /* c *\/ DISTINCT a` places
* `TOP` after the `DISTINCT` and not between the two - `SELECT TOP n DISTINCT ...`
* is a syntax error in T-SQL.
*
* Every one of those three reads takes T-SQL's grammar, because this is a HEAD
* rewrite and the index comes from the reading: T-SQL nests block comments, so a
* flat reading of `SELECT /* a /* b *\/ DISTINCT *\/ name FROM t` found a
* `DISTINCT` that is inside the comment and spliced the `TOP` in after it - inside
* the comment too. SQL Server then ran the statement unbounded while this provider
* reported a limit, which is #280's shape rather than a missed bound (#300).
*
* The two SUFFIX slices are safe under this dialect's grammar specifically: only
* the alternate-quote tag reads the character before its index (see `readSqlSpan`),
* and T-SQL does not have that form.
*/
function injectTop(sql: string, limit: number, grammar: SqlGrammar): string | null {
const select = readLeadingKeyword(sql, grammar);
if (select === null || select.keyword !== "SELECT") return null;
const next = readLeadingKeyword(sql.slice(select.end), grammar);
const insertAt = next?.keyword === "DISTINCT" ? select.end + next.end : select.end;
// A `TOP` already sitting where this one would go means the statement carries its
// own bound and `analyzeQuery`'s probe missed it - that probe wants literal
// whitespace between the two words, so a comment between them defeats it, as does
// a `DISTINCT`. Splicing anyway yields `SELECT TOP 50 TOP 10` and a syntax error,
// so decline and let the caller report that nothing was limited.
if (readLeadingKeyword(sql.slice(insertAt), grammar)?.keyword === "TOP") return null;
return `${sql.slice(0, insertAt)} TOP ${limit}${sql.slice(insertAt)}`;
}
/**
* A T-SQL page written as `OFFSET n ROW[S]`, at the end of the statement.
*
* That is a complete page here - "skip n rows and return the rest" - and it is
* the one bound form the shared probes in `query-limiter.ts` cannot see: they
* want a `FETCH … ROWS ONLY` tail or a bare `OFFSET n`, and `OFFSET 10 ROWS` is
* neither. Unseen, the statement looked unbounded and collected a clause beside
* its own page, which SQL Server rejects outright (Msg 10741) - so the statement
* FAILED rather than returning too many rows, and this method reported a limit
* for it. The form belongs to this dialect, so it is read here rather than in the
* shared limiter, where it would move every other dialect's probes (#293).
*
* Anchored at the end of the statement for the same reason the shared probes
* are: an `OFFSET` inside a subquery (`… FROM (SELECT … OFFSET 10 ROWS) x`) is a
* different query expression, which a `TOP` on the outer one may legally join,
* and one written in a trailing comment is not a page at all. A digit count only,
* as the shared probes read: `OFFSET @skip ROWS` is not recognised, which is the
* limitation `docs/providers/mssql.md` records.
*/
const TSQL_PAGE_TAIL = /\bOFFSET\s+\d+\s+ROWS?\s*$/i;
/**
* Whether text mentions a clause a row-count clause may not sit beside.
*
* Consulted ONLY where the statement's end may not be cut. Every already-bounded
* probe - the shared ones and `TSQL_PAGE_TAIL` above - is anchored at the end of
* the statement's own text, and where the cut is refused that text still carries
* the trailing trivia: a real page written before a trailing comment then sits
* away from the anchor and reads as absent. An anchor that may be reading trivia
* is not an answer a decision that ADDS a clause may rest on, so this asks the
* weaker question the situation allows - is there anything here that could be a
* page? - and the branch declines when there is.
*
* Unanchored and deliberately blunt: it also fires on a column named `offset`
* and on a subquery's own page, so such a statement loses its bound. That is the
* trade the whole of `src/lib/sql/` makes for text it cannot resolve - an
* over-large read reported honestly as unbounded, never a statement the server
* refuses - and both halves are pinned in this provider's suite.
*/
const TSQL_ROW_BOUND_MENTION = /\b(?:OFFSET|FETCH)\b/i;
// ============================================================================
// SQL Statements
// ============================================================================
// Multi-line SQL is hoisted to module scope so per-line coverage attribution
// stays stable (repo pattern, see the SCHEMA_*_SQL consts in postgres.ts).
const DATABASE_SIZE_MB_SQL = `
SELECT
CAST(SUM(size) * 8.0 / 1024 AS DECIMAL(10,2)) AS size_mb
FROM sys.database_files
`;
// Shared by getHealth() and getPerformanceMetrics().
const BUFFER_CACHE_HIT_RATIO_SQL = `
SELECT
CAST(
(a.cntr_value * 1.0 / NULLIF(b.cntr_value, 0)) * 100
AS DECIMAL(5,2)
) AS hit_ratio
FROM sys.dm_os_performance_counters a
CROSS JOIN sys.dm_os_performance_counters b
WHERE a.counter_name = 'Buffer cache hit ratio'
AND a.object_name LIKE '%Buffer Manager%'
AND b.counter_name = 'Buffer cache hit ratio base'
AND b.object_name LIKE '%Buffer Manager%'
`;
const HEALTH_SLOW_QUERIES_SQL = `
SELECT TOP 5
SUBSTRING(qt.text, 1, 100) AS query,
qs.execution_count AS calls,
CAST(qs.total_elapsed_time / NULLIF(qs.execution_count, 0) / 1000.0 AS DECIMAL(10,2)) AS avg_time_ms
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
WHERE qs.execution_count > 0
ORDER BY qs.total_elapsed_time DESC
`;
const HEALTH_ACTIVE_SESSIONS_SQL = `
SELECT TOP 10
s.session_id AS pid,
s.login_name AS [user],
DB_NAME(s.database_id) AS [database],
s.status AS state,
ISNULL(SUBSTRING(t.text, 1, 100), '') AS query,
ISNULL(CAST(DATEDIFF(SECOND, s.last_request_start_time, GETDATE()) AS VARCHAR) + 's', 'N/A') AS duration
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE s.is_user_process = 1
ORDER BY s.last_request_start_time DESC
`;
// Rebuild all indexes on all tables.
const REBUILD_ALL_INDEXES_SQL = `
DECLARE @sql NVARCHAR(MAX) = '';
SELECT @sql = @sql + 'ALTER INDEX ALL ON [' + s.name + '].[' + t.name + '] REBUILD;'
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE t.type = 'U';
EXEC sp_executesql @sql;
`;
const OVERVIEW_UPTIME_SQL = `
SELECT sqlserver_start_time,
DATEDIFF(SECOND, sqlserver_start_time, GETDATE()) AS uptime_seconds
FROM sys.dm_os_sys_info
`;
const OVERVIEW_CONNECTIONS_SQL = `
SELECT
COUNT(*) AS active_connections,
(SELECT CAST(value_in_use AS INT) FROM sys.configurations WHERE name = 'user connections') AS max_connections
FROM sys.dm_exec_sessions
WHERE is_user_process = 1
`;
const OVERVIEW_DATABASE_SIZE_SQL = `
SELECT SUM(CAST(size AS BIGINT)) * 8 * 1024 AS size_bytes FROM sys.database_files
`;
const OVERVIEW_OBJECT_COUNTS_SQL = `
SELECT
(SELECT COUNT(*) FROM sys.tables WHERE type = 'U') AS table_count,
(SELECT COUNT(*) FROM sys.indexes WHERE object_id IN (SELECT object_id FROM sys.tables WHERE type = 'U') AND name IS NOT NULL) AS index_count
`;
// Interpolated after "SELECT TOP <limit>" in getSlowQueries().
const SLOW_QUERIES_BODY_SQL = `
CAST(qs.query_hash AS VARCHAR(50)) AS query_id,
SUBSTRING(qt.text, 1, 500) AS query,
qs.execution_count AS calls,
CAST(qs.total_elapsed_time / 1000.0 AS DECIMAL(18,2)) AS total_time,
CAST(qs.total_elapsed_time / NULLIF(qs.execution_count, 0) / 1000.0 AS DECIMAL(18,2)) AS avg_time,
CAST(qs.min_elapsed_time / 1000.0 AS DECIMAL(18,2)) AS min_time,
CAST(qs.max_elapsed_time / 1000.0 AS DECIMAL(18,2)) AS max_time,
qs.total_rows AS row_cnt,
qs.total_logical_reads AS logical_reads,
qs.total_physical_reads AS physical_reads
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
WHERE qs.execution_count > 0
ORDER BY qs.total_elapsed_time DESC
`;
// Interpolated after "SELECT TOP <limit>" in getActiveSessions().
const ACTIVE_SESSIONS_BODY_SQL = `
s.session_id AS pid,
s.login_name AS [user],
DB_NAME(s.database_id) AS [database],
s.program_name AS application_name,
s.host_name AS client_addr,
s.status AS state,
ISNULL(SUBSTRING(t.text, 1, 500), '') AS query,
s.last_request_start_time AS query_start,
ISNULL(CAST(DATEDIFF(SECOND, s.last_request_start_time, GETDATE()) AS VARCHAR) + 's', 'N/A') AS duration,
ISNULL(DATEDIFF(MILLISECOND, s.last_request_start_time, GETDATE()), 0) AS duration_ms,
r.wait_type,
r.last_wait_type,
CASE WHEN r.blocking_session_id > 0 THEN 1 ELSE 0 END AS is_blocked
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE s.is_user_process = 1
ORDER BY
CASE s.status WHEN 'running' THEN 0 WHEN 'sleeping' THEN 1 ELSE 2 END,
s.last_request_start_time DESC
`;
const TABLE_STATS_SQL = `
SELECT
s.name AS schema_name,
t.name AS table_name,
ISNULL(p.row_count, 0) AS row_count,
SUM(a.total_pages) * 8 * 1024 AS total_size_bytes,
SUM(a.used_pages) * 8 * 1024 AS used_size_bytes,
SUM(CASE WHEN i.type IN (0, 1) THEN a.total_pages ELSE 0 END) * 8 * 1024 AS table_size_bytes,
SUM(CASE WHEN i.type > 1 THEN a.total_pages ELSE 0 END) * 8 * 1024 AS index_size_bytes,
STATS_DATE(t.object_id, 1) AS last_stats_update
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
LEFT JOIN (
SELECT object_id, SUM(rows) AS row_count
FROM sys.partitions
WHERE index_id IN (0, 1)
GROUP BY object_id
) p ON t.object_id = p.object_id
JOIN sys.indexes i ON t.object_id = i.object_id
JOIN sys.partitions p_all ON i.object_id = p_all.object_id AND i.index_id = p_all.index_id
JOIN sys.allocation_units a ON p_all.partition_id = a.container_id
WHERE t.type = 'U'
GROUP BY s.name, t.name, t.object_id, p.row_count
ORDER BY SUM(a.total_pages) DESC
`;
const INDEX_STATS_SQL = `
SELECT
s.name AS schema_name,
t.name AS table_name,
i.name AS index_name,
i.type_desc AS index_type,
i.is_unique,
i.is_primary_key,
SUM(a.total_pages) * 8 * 1024 AS index_size_bytes,
ISNULL(u.user_seeks + u.user_scans + u.user_lookups, 0) AS scans
FROM sys.indexes i
JOIN sys.tables t ON i.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id
LEFT JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id
LEFT JOIN sys.allocation_units a ON p.partition_id = a.container_id
LEFT JOIN sys.dm_db_index_usage_stats u ON i.object_id = u.object_id AND i.index_id = u.index_id AND u.database_id = DB_ID()
WHERE i.name IS NOT NULL AND t.type = 'U'
GROUP BY s.name, t.name, i.name, i.type_desc, i.is_unique, i.is_primary_key,
i.object_id, i.index_id, u.user_seeks, u.user_scans, u.user_lookups
ORDER BY SUM(a.total_pages) DESC
`;
const INDEX_COLUMNS_SQL = `
SELECT
s.name AS schema_name,
t.name AS table_name,
i.name AS index_name,
c.name AS column_name,
ic.key_ordinal
FROM sys.index_columns ic
JOIN sys.indexes i ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
JOIN sys.tables t ON i.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE i.name IS NOT NULL AND t.type = 'U'
ORDER BY s.name, t.name, i.name, ic.key_ordinal
`;
const STORAGE_STATS_SQL = `
SELECT
name,
physical_name AS location,
CAST(size AS BIGINT) * 8 * 1024 AS size_bytes,
type_desc
FROM sys.database_files
ORDER BY size DESC
`;
// ============================================================================
// Object surface SQL (#789)
// ----------------------------------------------------------------------------
// These are FUNCTIONS rather than the consts above, and the reason is the engine: SQL
// Server reaches another database only through a three-part name, and a database name
// cannot be bound as a parameter. So the catalog segment is interpolated - escaped
// through `escapeIdentifier`, the same `]` doubling `runMaintenance` uses - while the
// schema and the object name, which can be bound, always are.
//
// The `@schema` filter is absent from a statement rather than bound as NULL. Binding one
// would mean `request.input("schema", null)`, which leaves `mssql` inferring a type for a
// value that has none, and it would hide an unfiltered read behind a parameter nobody can
// see in the statement text.
// ============================================================================
/**
* `SERVERPROPERTY('EngineEdition')` for Azure SQL Database.
*
* 5 is Azure SQL Database, 3 is Enterprise (and Developer, which is what the container
* fixture reports), 8 is Azure SQL Managed Instance. Only 5 cannot run a cross-database
* query, and Managed Instance can, so this is one edition and not "anything Azure".
*/
const AZURE_SQL_DATABASE_EDITION = 5;
/**
* The `sys.objects.type` spellings behind each declared kind.
*
* One entry per kind, and both the counting statement's CASE and every listing's IN list
* are built from it, so a kind added to `objectKinds` without an entry here fails loudly
* instead of drawing a folder nothing can fill.
*
* **Derived from the ENGINE's documented type set, not from a fixture** (standing ruling
* 5a, #789). A `SELECT DISTINCT type FROM sys.objects` over this repo's fixture server
* answers eighteen spellings and none of the CLR ones, because no assembly is registered
* there: taking the vocabulary from what happened to be present would have dropped a CLR
* stored procedure out of the count AND the listing, invisible in the tree while ruling 5f
* still held. Every documented spelling is decided below, and the ones this table does not
* carry are listed with the reason.
*
* | Spelling | Decision |
* |---|---|
* | `U` | `table`. Covers a graph node or edge table, a memory-optimized table, an external table and both halves of a system-versioned temporal pair: all of those are `U` with a flag beside it, so none can fall out. |
* | `V` | `view`. An indexed view is a `V` with a clustered index, so it is here and its index is in the detail row. |
* | `P`, `PC`, `X` | `procedure`. SQL, CLR (assembly) and extended: all three are things a person EXECs. |
* | `FN`, `IF`, `TF`, `FS`, `FT`, `AF` | `function`. SQL scalar, inline table-valued, multi-statement table-valued, then the CLR scalar, CLR table-valued and CLR aggregate. A person wrote a function in every case. |
* | `SN` | `synonym`. |
* | `SO` | `sequence`. |
* | `TR`, `TA` | NOT here: a trigger is counted and listed from `sys.triggers`, which holds the CLR ones too. See below. |
* | `C`, `D`, `F`, `PK`, `UQ`, `EC` | not an object kind: a constraint belongs to the table it constrains and reaches the tree through `describeObject`. |
* | `S`, `IT`, `SQ` | Microsoft's own: a system base table, an internal table and a Service Broker queue. All carry `is_ms_shipped = 1`, so the predicate already drops them. |
* | `TT` | a table TYPE, not a table. Measured: `CREATE TYPE ... AS TABLE` writes a `sys.objects` row named `TT_<type>_<hex>` with `is_ms_shipped = 1`, so the predicate already drops it, and the object a person wrote lives in `sys.types`. A Types folder is out of Phase 1's scope and is recorded in `docs/providers/mssql.md` as a known gap. |
* | `R`, `PG`, `RF` | a rule, a plan guide and a replication filter procedure. Each is a real user object with no declared kind, and each is recorded as a known gap rather than folded into a kind it is not. |
*
* `trigger` is deliberately ABSENT from this table, and that absence is the whole reason it
* exists rather than a `CASE` written inline. Measured on SQL Server 2022 CU26 against
* `docker/mssql-init/01-object-fixture.sql`: `sys.objects` holds 2 triggers there and
* `sys.triggers` holds 4, because a DATABASE-scoped DDL trigger is absent from
* `sys.objects` entirely. A trigger count taken from `sys.objects` is short by exactly the
* DDL triggers, and no assertion on `sys.objects` can see it.
*/
const MSSQL_OBJECT_TYPES: Record<string, readonly string[]> = {
table: ["U"],
view: ["V"],
procedure: ["P", "PC", "X"],
function: ["FN", "IF", "TF", "FS", "FT", "AF"],
synonym: ["SN"],
sequence: ["SO"],
};
/** The one kind `sys.objects` cannot answer for. */
const TRIGGER_KIND = "trigger";
/**
* The source declaration every module-bearing kind on this engine carries (#789 Phase 2).
*
* ONE value spread into four kinds rather than four pairs of literals, so a kind cannot
* gain `hasSource` and miss its language: an absent or unregistered Monaco id degrades to
* plain text with no throw and nothing observable, which is a Source tab that silently
* stopped highlighting.
*
* `sql` and NOT `tsql`. MEASURED in #789: `tsql` is not among the 89 language ids the
* installed monaco-editor 0.57.0 bundle registers, and neither are `plsql` and `cql`, so
* the one id in the bundle that highlights this dialect is the generic one. T-SQL keywords
* the generic grammar does not know (`OUTER APPLY`, `MERGE ... OUTPUT`) render as plain
* identifiers, which is a compromise `docs/providers/mssql.md` records rather than hides.
*/
const MSSQL_SOURCE_DECLARATION = { hasSource: true, sourceLanguage: "sql" } as const;
/** `'U','V'` and so on: one kind's spellings as a SQL literal list. */
function typeList(types: readonly string[]): string {
return types.map((type) => `'${type}'`).join(",");
}
/** Every counted spelling, derived from the table so it cannot drift from the CASE. */
const COUNTED_TYPE_LIST = typeList(Object.values(MSSQL_OBJECT_TYPES).flat());
/**
* The CASE that names each counted spelling's kind, derived from the same table.
*
* Nothing can answer a kind the CASE has no arm for, because the `WHERE` restricts the read
* to the spellings the table lists - and both come from the table. A spelling counted but
* unnamed would answer NULL and land in the result under no kind at all.
*/
const KIND_CASE = Object.entries(MSSQL_OBJECT_TYPES)
.flatMap(([kind, types]) => types.map((type) => `WHEN '${type}' THEN '${kind}'`))
.join(" ");
/**
* The databases this login can open, which on SQL Server is the outer container level.
*
* `HAS_DBACCESS(d.name) = 1` is the engine's own answer to "can this login use this
* database", and it covers more than permissions: measured on SQL Server 2022 CU26, a
* database taken OFFLINE keeps its `sys.databases` row and answers 0 here. Listing it
* would draw a container whose schemas can never be read.
*
* The Azure arm is in the STATEMENT rather than in TypeScript, so one read serves both
* editions and neither can be forgotten. Azure SQL Database cannot run a cross-database
* query at all, so every catalog other than the connected one would draw a container that
* opens onto an error - and answering an empty list there would be a lie about the
* database the caller is connected to, which is why the level lists exactly one row
* instead. UNVERIFIED against a live Azure SQL Database: `docs/providers/mssql.md` records
* that, and records that the arm was probed here by inverting the edition it tests.
*
* `DB_ID()` rather than the configured database name: it is the server's own answer for
* which database this session is in, so it stays right for a connection string that named
* none and for a name whose case differs from the catalog's.
*/
const CONTAINERS_SQL = `
SELECT d.name AS name,
CASE WHEN d.database_id = DB_ID() THEN 1 ELSE 0 END AS is_session_default
FROM sys.databases d
WHERE HAS_DBACCESS(d.name) = 1
AND (SERVERPROPERTY('EngineEdition') <> ${AZURE_SQL_DATABASE_EDITION} OR d.database_id = DB_ID())
ORDER BY d.name
`;
/**
* One database's schemas, which is the inner container level.
*
* Two exclusions, and both are measured rather than tidied. `sys` and `INFORMATION_SCHEMA`
* can hold NOTHING a person wrote: `CREATE TABLE sys.probe` and
* `CREATE TABLE INFORMATION_SCHEMA.probe` both answer Msg 2760 on SQL Server 2022 CU26,
* and across every accessible database on the fixture server not one object in either
* schema has `is_ms_shipped = 0`. The nine fixed-role schemas (`db_owner`,
* `db_datareader`, ...) exist to own permissions, and `is_fixed_role` on the owning
* principal is the engine's own answer for which those are - no name list and no
* `schema_id >= 16384` magic number.
*
* The `EXISTS` arm is what keeps that second exclusion from hiding anything: a fixed-role
* schema CAN hold a user object (`CREATE TABLE db_owner.t` succeeds, measured), and one
* that does is listed. So the count for a whole database always equals the sum over the
* schemas this statement lists, because a schema it drops holds nothing to count.
*
* `isSessionDefault` is answered for the CONNECTED database only, and that restriction is
* the whole of it: SQL Server publishes the session's default schema as `SCHEMA_NAME()`,
* which is evaluated in the database the session is in, so marking a row under any other
* catalog would name a schema the session has nothing to do with. `DB_NAME()` travels back
* with the rows so the caller can apply that restriction against the catalog it asked for,
* rather than this statement interpolating a database NAME as a literal to compare.
*
* The tree needs it at this level: first paint walks the container chain down to the
* session default at the DEEPEST declared level and reads the counts there, so an engine
* that marks only its outer level opens a catalog and stops, with no folder and no count
* (#789). `SCHEMA_NAME()` is the connection's own default schema, which is `dbo` for a
* login that has not been given another.
*/
function schemasSql(database: string): string {
return `
SELECT s.name AS name,
CASE WHEN s.name = SCHEMA_NAME() THEN 1 ELSE 0 END AS is_session_schema,
DB_NAME() AS connected_database
FROM ${database}.sys.schemas s
JOIN ${database}.sys.database_principals p ON p.principal_id = s.principal_id
WHERE s.name NOT IN ('sys', 'INFORMATION_SCHEMA')
AND (p.is_fixed_role = 0
OR EXISTS (SELECT 1 FROM ${database}.sys.objects o
WHERE o.schema_id = s.schema_id AND o.is_ms_shipped = 0))
ORDER BY s.name
`;
}
/**
* How many objects of each kind one container holds, in one statement and one round trip.
*
* `is_ms_shipped = 0` is load-bearing and not hygiene. Measured on SQL Server 2022 CU26,
* `msdb` holds 476 stored procedures, 145 tables, 78 views, 38 triggers, 58 functions and
* 10 synonyms, every one of them shipped by Microsoft: without the predicate, a database
* nobody has written a line in reports hundreds of objects, and a fresh user database
* reports 72 system tables and 36 internal ones as tables.
*
* The trigger arm reads `sys.triggers` because `sys.objects` has no DDL trigger at all,
* and it joins the base object's schema so the same arm can be filtered by schema: a DML
* trigger belongs to its base object's schema, and a DATABASE-scoped DDL trigger belongs
* to no schema, so a schema-filtered count drops it. That is the honest answer at that
* depth, and the database-level count is where it appears.
*/
function countsSql(database: string, bySchema: boolean): string {
return `
SELECT kind, COUNT(*) AS n FROM (
SELECT CASE o.type ${KIND_CASE} END AS kind
FROM ${database}.sys.objects o
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
WHERE o.is_ms_shipped = 0 AND o.type IN (${COUNTED_TYPE_LIST})${bySchema ? " AND s.name = @schema" : ""}
UNION ALL
SELECT '${TRIGGER_KIND}'
FROM ${database}.sys.triggers t
LEFT JOIN ${database}.sys.objects po ON po.object_id = t.parent_id
LEFT JOIN ${database}.sys.schemas ps ON ps.schema_id = po.schema_id
WHERE t.is_ms_shipped = 0${bySchema ? " AND ps.name = @schema" : ""}
) counted
GROUP BY kind
`;
}
/**
* One kind's objects, names only, with the row count SQL Server maintains for a table.
*
* `sys.partitions` with `index_id IN (0, 1)` is the heap or the clustered index, which is
* the same expression `SCHEMA_TABLES_SQL` uses for the flat schema tree - so a table's
* count reads the same in both surfaces while both are live. It is an approximation the
* engine maintains rather than a `COUNT(*)`, which is what `DatabaseObject.rowCount`
* promises and all this tree needs.
*
* `withRowCount` is false for every other kind rather than answering 0, because a view, a
* routine, a synonym and a sequence have no rows of their own: the column is not selected,
* so the object carries no `rowCount` key at all.
*/
function listObjectsSql(database: string, types: readonly string[], bySchema: boolean, withRowCount: boolean): string {
const schemaFilter = bySchema ? " AND s.name = @schema" : "";
if (!withRowCount) {
return `
SELECT s.name AS schema_name, o.name AS name
FROM ${database}.sys.objects o
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
WHERE o.is_ms_shipped = 0 AND o.type IN (${typeList(types)})${schemaFilter}
`;
}
return `
SELECT s.name AS schema_name, o.name AS name, SUM(p.rows) AS row_count
FROM ${database}.sys.objects o
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
LEFT JOIN ${database}.sys.partitions p ON p.object_id = o.object_id AND p.index_id IN (0, 1)
WHERE o.is_ms_shipped = 0 AND o.type IN (${typeList(types)})${schemaFilter}
GROUP BY s.name, o.name
`;
}
/**
* One container's triggers, each with the object it fires on.
*
* `sys.triggers` is the spine and `sys.objects` is OUTER joined, which is the shape of this
* statement rather than a style choice. `countObjects` counts triggers from `sys.triggers`,
* and standing ruling 5f (#789) requires this listing to hold exactly what that count
* counted: a DATABASE-scoped DDL trigger has `parent_id = 0` and no row to join, so an
* inner join would drop the two the badge already counted.
*
* Both parent columns arrive NULL together for that reason, and the object then hangs off
* the database itself, which ruling 5f allows. `is_disabled` is the one state SQL Server
* publishes about an object of any declared kind - there is no VALID / INVALID here, so
* there is no second vocabulary for this field to collide with, which is why Oracle keeps
* ENABLED / DISABLED out of the same field and this provider carries it.
*
* The join cannot multiply rows: a trigger is one `sys.triggers` row and `object_id` is
* unique in `sys.objects`.
*/
function listTriggersSql(database: string, bySchema: boolean): string {
return `
SELECT t.name AS name, ps.name AS parent_schema, po.name AS parent_name, t.is_disabled
FROM ${database}.sys.triggers t
LEFT JOIN ${database}.sys.objects po ON po.object_id = t.parent_id
LEFT JOIN ${database}.sys.schemas ps ON ps.schema_id = po.schema_id
WHERE t.is_ms_shipped = 0${bySchema ? " AND ps.name = @schema" : ""}
`;
}
/**
* One object's definition text, and the three separable reasons there may be none (#789).
*
* THE LEFT JOIN IS THE SHAPE, not a style. `sys.objects` is the spine and `sys.sql_modules`
* hangs off it, so an object with no SQL module at all answers a ROW with `has_module = 0`
* rather than no row. An inner join would answer nothing for it, which is the same shape as
* the object not existing, and those two are a refusal and a raise respectively.
*
* `sys.syscomments` AND NOT `OBJECTPROPERTY`, and this REFUTES the row the design wrote for
* this engine (#789 recipe rule 7). MEASURED on SQL Server 2022 RTM-CU26 (16.0.4265.3):
* `OBJECTPROPERTY(id, 'IsEncrypted')` resolves its object id in the CONNECTED database
* whatever database a three-part name addresses. Reading `libredb_objects_two` from a session
* in `libredb_objects`, it answered NULL or 0, and never the right answer, for a view that
* really is encrypted: the id names something else in the connected database, or nothing, and
* which of the two depends on what that database holds at that id. Both values route to the
* "no VIEW DEFINITION" refusal, so an encrypted module is never reported as encrypted.
* `OBJECT_ID('db.schema.name')` does not
* rescue it: the id resolves and `OBJECTPROPERTY` still answers 0. On a two-level engine whose
* catalog level is part of every path that is not a limitation, it is a wrong answer about
* somebody else's object, so the flag comes from the one place that IS three-part nameable.
*
* `sys.syscomments` is a compatibility view and carries a deprecation notice; it is used for
* the `encrypted` BIT alone and never for its chunked `text`, which `sys.sql_modules` answers
* whole. MEASURED: a 5045-character module is one `sys.sql_modules.definition` and TWO
* `sys.syscomments` rows, which is why the flag is aggregated rather than joined.
*
* AND THE AGGREGATE IS WHAT MAKES THE THREE CAUSES SEPARABLE FOR THE DENIED CALLER ITSELF.
* MEASURED, with `VIEW DEFINITION` granted on exactly two of four objects to one login: that
* login's own read answered a text and `is_encrypted = 0` for the granted plain module,
* `is_encrypted = 1` for the granted encrypted one, and NULL for the two it still lacked. So
* the flag is visible exactly where `VIEW DEFINITION` is, per OBJECT, which is what separates
* "this is encrypted" from "you cannot see this".
*
* `o.type IN (...)` because the KIND the caller asked under is part of the address. Measured:
* `app.orders` is a table and the view statement answers zero rows for it, which is the
* absence of a VIEW at that name rather than a table's definition handed to a view reader.
*/
function sourceObjectSql(database: string, types: readonly string[]): string {
return `
SELECT sm.definition AS definition,
CASE WHEN sm.object_id IS NULL THEN 0 ELSE 1 END AS has_module,
(SELECT MAX(CONVERT(INT, c.encrypted)) FROM ${database}.sys.syscomments c WHERE c.id = o.object_id) AS is_encrypted
FROM ${database}.sys.objects o
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
LEFT JOIN ${database}.sys.sql_modules sm ON sm.object_id = o.object_id
WHERE o.is_ms_shipped = 0 AND o.type IN (${typeList(types)}) AND s.name = @schema AND o.name = @name
`;
}
/**
* The same read for a trigger, whose spine is `sys.triggers` for the reason the LISTING's is.
*
* `sys.objects` holds no DATABASE-scoped DDL trigger at all, so this statement outer-joins it
* only to reach the trigger's parent SCHEMA, and a DDL trigger is addressed by the arm that
* asks for `ps.name IS NULL`. The schema is the filter and the parent TABLE is not: measured,
* a trigger name is unique per SCHEMA on SQL Server, which is why `app` and `reporting` may
* each hold a `stamp_order` and one schema may not hold two.
*
* CONSEQUENCE OF THE SAME ABSENCE, disclosed rather than left to be found: a DDL trigger has no
* `sys.syscomments` row either, readable or encrypted, so `is_encrypted` is NULL for one. Its
* definition is readable and the text arm answers first, so the only degraded case is an
* ENCRYPTED DDL trigger, which reports the "cannot be read here" refusal rather than the
* encryption one. `docs/providers/mssql.md` records it.
*/
function sourceTriggerSql(database: string, bySchema: boolean): string {
return `
SELECT sm.definition AS definition,
CASE WHEN sm.object_id IS NULL THEN 0 ELSE 1 END AS has_module,
(SELECT MAX(CONVERT(INT, c.encrypted)) FROM ${database}.sys.syscomments c WHERE c.id = t.object_id) AS is_encrypted
FROM ${database}.sys.triggers t
LEFT JOIN ${database}.sys.objects po ON po.object_id = t.parent_id
LEFT JOIN ${database}.sys.schemas ps ON ps.schema_id = po.schema_id
LEFT JOIN ${database}.sys.sql_modules sm ON sm.object_id = t.object_id
WHERE t.is_ms_shipped = 0 AND t.name = @name AND ${bySchema ? "ps.name = @schema" : "ps.name IS NULL"}
`;
}
// ----------------------------------------------------------------------------
// One object's detail. Four narrow reads, each bound to ONE schema and ONE object.
//
// Four statements rather than one wide one, on Oracle's precedent in this epic: each
// failure names its own statement through `mapDatabaseError`, and a single connection
// serialises them anyway. Every one of them keys the object explicitly rather than through
// `OBJECT_NAME()` or `COL_NAME()`, which the flat schema query above uses: those resolve
// in the CURRENT database and would answer for the connected one while this read is
// three-part named at another.
// ----------------------------------------------------------------------------
/**
* Columns, from `sys.columns` rather than `INFORMATION_SCHEMA.COLUMNS`.
*
* `sys.types.name` is the same spelling `INFORMATION_SCHEMA.COLUMNS.DATA_TYPE` gives, so
* this surface and the flat schema tree name a column's type identically while both are
* live: verified column by column on the fixture's `app.orders` - int, int, decimal,
* nvarchar from both. `sys.default_constraints.definition` likewise matches
* `COLUMN_DEFAULT`, parentheses included (`((0))`).
*/
function objectColumnsSql(database: string): string {
return `
SELECT c.name AS name, ty.name AS data_type, TYPE_NAME(c.system_type_id) AS base_type, c.is_nullable, dc.definition AS default_definition
FROM ${database}.sys.columns c
JOIN ${database}.sys.objects o ON o.object_id = c.object_id
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
JOIN ${database}.sys.types ty ON ty.user_type_id = c.user_type_id
LEFT JOIN ${database}.sys.default_constraints dc ON dc.object_id = c.default_object_id
WHERE s.name = @schema AND o.name = @name
ORDER BY c.column_id
`;
}
function objectPrimaryKeySql(database: string): string {
return `
SELECT c.name AS name
FROM ${database}.sys.indexes i
JOIN ${database}.sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN ${database}.sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
JOIN ${database}.sys.objects o ON o.object_id = i.object_id
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
WHERE i.is_primary_key = 1 AND s.name = @schema AND o.name = @name
`;
}
/**
* Foreign keys, paired column by column.
*
* `sys.foreign_key_columns` already carries both sides of each pair in one row, so there
* is no position join to get wrong here. The referenced schema comes back as its own
* column because a foreign key may cross schemas, and `referencedTable` below qualifies
* the name when it does.
*/
function objectForeignKeysSql(database: string): string {
return `
SELECT pc.name AS column_name, rs.name AS ref_schema, ro.name AS ref_table, rc.name AS ref_column
FROM ${database}.sys.foreign_keys fk
JOIN ${database}.sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
JOIN ${database}.sys.objects po ON po.object_id = fk.parent_object_id
JOIN ${database}.sys.schemas ps ON ps.schema_id = po.schema_id
JOIN ${database}.sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
JOIN ${database}.sys.objects ro ON ro.object_id = fk.referenced_object_id
JOIN ${database}.sys.schemas rs ON rs.schema_id = ro.schema_id
JOIN ${database}.sys.columns rc ON rc.object_id = fkc.referenced_object_id
AND rc.column_id = fkc.referenced_column_id
WHERE ps.name = @schema AND po.name = @name
ORDER BY fk.name, fkc.constraint_column_id
`;
}
/**
* Indexes, filtered the same way `SCHEMA_INDEXES_SQL` filters them.
*
* `i.name IS NOT NULL` drops the heap, and `is_primary_key = 0` drops the index behind the
* primary key, which the columns' own `isPrimary` already carries. Keeping the two
* surfaces' rule identical is what stops one screen showing an index the other hides.
*/
function objectIndexesSql(database: string): string {
return `
SELECT i.name AS index_name, i.is_unique, c.name AS column_name
FROM ${database}.sys.indexes i
JOIN ${database}.sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN ${database}.sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
JOIN ${database}.sys.objects o ON o.object_id = i.object_id
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
WHERE s.name = @schema AND o.name = @name AND i.name IS NOT NULL AND i.is_primary_key = 0
ORDER BY i.name, ic.key_ordinal
`;
}
// ----------------------------------------------------------------------------
// Every object of one kind, described together (#789)
//
// FIVE statements for a whole folder rather than four per object. They share one
// `described` CTE, which is the target set, and each detail read joins it on `object_id`
// rather than on a name: an object id is the engine's own key for the object, so nothing
// here has to compare two strings under a collation to decide which rows belong together.
//
// The five are separate statements rather than one batch of result sets, on the precedent
// the four single-object reads set in this file: each failure names its own statement
// through `mapDatabaseError`, and one connection serialises them anyway. The COUNT is what
// matters and it is constant - five, whether the folder holds one object or five thousand.
// ----------------------------------------------------------------------------
/**
* The objects one bulk read describes, ordered and cut only when the caller bounded it.
*
* `TOP (@limit)` is SQL Server's spelling of a row bound and the value is BOUND rather than
* interpolated. It comes with `ORDER BY s.name, o.name`, and the two travel together in
* both directions: SQL Server refuses an `ORDER BY` inside a CTE that has no row bound
* (Msg 1033), and a bound with no order would cut an arbitrary set. So the unbounded
* statement carries neither, which is correct rather than a compromise - an unbounded read
* cuts nothing, and the answer is re-sorted by path in code either way.
*
* The order runs under the DATABASE's collation, which is SQL_Latin1_General_CP1_CI_AS on
* the fixture server (measured) and is case-insensitive there. `(schema, name)` is unique
* within a database, so the order is TOTAL and all five statements cut the same set.
*/
function describedSql(database: string, types: readonly string[], bySchema: boolean, bounded: boolean): string {
return `
WITH described AS (
SELECT ${bounded ? "TOP (@limit) " : ""}o.object_id, s.name AS schema_name, o.name AS name
FROM ${database}.sys.objects o
JOIN ${database}.sys.schemas s ON s.schema_id = o.schema_id
WHERE o.is_ms_shipped = 0 AND o.type IN (${typeList(types)})${bySchema ? " AND s.name = @schema" : ""}${
bounded ? "\n ORDER BY s.name, o.name" : ""
}
)`;
}
/** The target set itself, which is what says WHICH objects the answer is about. */
function bulkTargetSql(database: string, types: readonly string[], bySchema: boolean, bounded: boolean): string {
return `${describedSql(database, types, bySchema, bounded)}
SELECT d.object_id, d.schema_name, d.name FROM described d`;
}
/**
* The four detail reads, each re-pointed from ONE object to the whole target set.
*
* They are the `object*Sql()` bodies above with `WHERE s.name = @schema AND o.name = @name`
* replaced by a join to `described`, so every measured decision those statements carry
* still applies: columns come from `sys.columns` and `sys.types` rather than from
* `INFORMATION_SCHEMA.COLUMNS`, `i.name IS NOT NULL` drops the heap, `is_primary_key = 0`
* drops the index behind the primary key, and the foreign-key pairs come from
* `sys.foreign_key_columns`, which carries both sides of each pair in one row.
*
* Nothing here caps a column list. An unreported bound is the defect
* `ObjectDetailBatch.truncated` exists to prevent; what is bounded here is the number of
* OBJECTS, by the caller, and it is reported.
*/
function bulkDetailSql(
database: string,
types: readonly string[],
bySchema: boolean,
bounded: boolean,
): { columns: string; primaryKey: string; foreignKeys: string; indexes: string } {
const described = describedSql(database, types, bySchema, bounded);
return {
columns: `${described}
SELECT d.object_id, c.name AS name, ty.name AS data_type, TYPE_NAME(c.system_type_id) AS base_type, c.is_nullable, dc.definition AS default_definition
FROM described d
JOIN ${database}.sys.columns c ON c.object_id = d.object_id
JOIN ${database}.sys.types ty ON ty.user_type_id = c.user_type_id
LEFT JOIN ${database}.sys.default_constraints dc ON dc.object_id = c.default_object_id
ORDER BY d.object_id, c.column_id`,
primaryKey: `${described}
SELECT d.object_id, c.name AS name
FROM described d
JOIN ${database}.sys.indexes i ON i.object_id = d.object_id AND i.is_primary_key = 1
JOIN ${database}.sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN ${database}.sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id`,
foreignKeys: `${described}
SELECT d.object_id, pc.name AS column_name, rs.name AS ref_schema, ro.name AS ref_table, rc.name AS ref_column
FROM described d
JOIN ${database}.sys.foreign_keys fk ON fk.parent_object_id = d.object_id
JOIN ${database}.sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
JOIN ${database}.sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
JOIN ${database}.sys.objects ro ON ro.object_id = fk.referenced_object_id
JOIN ${database}.sys.schemas rs ON rs.schema_id = ro.schema_id
JOIN ${database}.sys.columns rc ON rc.object_id = fkc.referenced_object_id
AND rc.column_id = fkc.referenced_column_id
ORDER BY d.object_id, fk.name, fkc.constraint_column_id`,
indexes: `${described}
SELECT d.object_id, i.name AS index_name, i.is_unique, c.name AS column_name
FROM described d
JOIN ${database}.sys.indexes i ON i.object_id = d.object_id AND i.name IS NOT NULL AND i.is_primary_key = 0
JOIN ${database}.sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN ${database}.sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
ORDER BY d.object_id, i.name, ic.key_ordinal`,
};
}
// ============================================================================
// Object surface shapes and derivations (#789)
// ============================================================================
/** One row of `CONTAINERS_SQL`. */
interface ContainerRow {
name: string;
is_session_default: number;
}
/** One row of `schemasSql`. */
interface SchemaNameRow {
name: string;
/** `SCHEMA_NAME()`, which is the session's default schema IN THE CONNECTED DATABASE. */
is_session_schema: number;
/** `DB_NAME()`, so the caller can tell whether the column above is about this catalog. */
connected_database: string;
}
/** One row of `countsSql`: a kind id and how many of it the container holds. */
interface KindCountRow {
kind: string;
n: number;
}
/**
* One row of a `sys.objects` listing: the object's own schema, and a row count where the
* statement selected one.
*/
interface SchemaObjectRow {
name: string;
schema_name: string;
row_count?: number | string | null;
}
/**
* One row of the trigger listing: its BASE OBJECT rather than its own schema, both columns
* NULL for a DATABASE-scoped DDL trigger, and the state SQL Server publishes for it.
*/
interface TriggerRow {
name: string;
parent_schema: string | null;
parent_name: string | null;
is_disabled: boolean;
}
interface ColumnRow {
name: string;
data_type: string;
/**
* `TYPE_NAME(system_type_id)`: the type the declared one is BUILT ON, which for an alias
* type is not the declared name. NULL for the system CLR types, which all share
* `system_type_id` 240 - measured, `Person.Address.SpatialLocation` answers NULL for the
* base and `geography` for the declared name - so the reader falls back rather than
* reporting an absence it would have to invent.
*/
base_type: string | null;
is_nullable: boolean;
default_definition: string | null;
}
interface ForeignKeyRow {
column_name: string;
ref_schema: string;
ref_table: string;
ref_column: string;
}
interface IndexRow {
index_name: string;
is_unique: boolean;
column_name: string;
}
/**
* The container levels this engine declares, cut to the depth `containerDepth()` answers.
*
* Every derivation below starts here rather than from a length or an index. Standing
* ruling 5g (#789) is about the three spellings of one defect, and all three are wrong on
* this engine specifically: a hardcoded `container.length !== 1`, a positional
* `path[1]` for the object name, and a positional `path[0]` for the schema. SQL Server is
* the first engine in the epic where the schema is not at index 0 and the name is not at
* index 1, so the derivations are written once here and every caller reads them.
*/
function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerLevelSpec[] {
return (capabilities.containerLevels ?? []).slice(0, containerDepth(capabilities));