Repository navigation
Expand file tree
/
Copy pathsqlserver_to_exasol.sql
More file actions
2266 lines (2198 loc) · 140 KB
/
Copy pathsqlserver_to_exasol.sql
File metadata and controls
2266 lines (2198 loc) · 140 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
create schema if not exists database_migration;
/*
sqlserver_to_exasol.sql - generate the statements to migrate Microsoft SQL Server or Azure SQL databases to Exasol v8.
Source: SQL Server 2017, 2019, 2022 and 2025 (tested), SQL Server 2016 best effort (out of extended support since
2026-07-14), Azure SQL Managed Instance and Azure SQL Database (one database per connection). Use the latest
Microsoft JDBC driver (mssql-jdbc 13.6.0 or newer), not jTDS.
This script runs on the TARGET Exasol database, reads the SOURCE metadata through a JDBC connection and RETURNS the
statements to recreate and load the source. It changes nothing itself - review the output and run it in the order
returned, in ONE session (see TIME ZONE below), preferably with stop-on-error (EXAplus option -x; EXAplus continues
after errors by default). In EXAplus run SET DEFINE OFF; first when the output contains the character & (a note says
so), otherwise EXAplus treats &name as a substitution variable.
OUTPUT (in this order): header and notes, CREATE SCHEMA, DROP TABLE IF EXISTS ... CASCADE CONSTRAINTS + CREATE TABLE,
PRIMARY KEYs and FOREIGN KEYs (created DISABLED), PARTITION BY, COMMENTs, the TIME ZONE block, the IMPORTs, the
CONSTRAINT STATE section (SET_AS_SOURCE / FORCE_ENABLE), the optional DATA VALIDATION (CHECK_MIGRATION), the restore of
the session time zone, and the optional commented VIEW review section. Re-running the output replaces the target
tables (DROP ... CASCADE CONSTRAINTS also drops foreign keys that OTHER Exasol tables hold on them - a FOREIGN KEYS
DROPPED note lists them). TARGET_SCHEMA in double quotes is used verbatim (no upper-casing); at most 128 characters.
TIME ZONE (important): datetimeoffset is migrated as TIMESTAMP(n) WITH LOCAL TIME ZONE: the instant is kept exactly,
the original offset (+02:00 ...) is not. The values are transferred as UTC. Exasol interprets incoming values in the
SESSION time zone, so the output switches the session to TIME_ZONE = 'UTC' before the IMPORTs and restores the
original zone at the end. Run the ALTER SESSION and the IMPORTs in the SAME session. Afterwards every session sees the
values in its own time zone. If the run stops at an error before the end, the session stays in UTC - the banner in
the output names the ALTER SESSION statement that restores the original zone.
The usable datetimeoffset range is 0001-01-02 .. 9999-12-30 UTC (values on the outermost day cannot be displayed in
every session time zone); TEMPORAL_OUT_OF_RANGE applies to values outside it: 'FAIL' (the IMPORT fails and names the
column and the value), 'NULL' (load NULL) or 'CLAMP' (load the nearest value of the range, e.g. for a 9999-12-31
"open end"). date, datetime, datetime2 and smalldatetime have no time zone; their full range (up to 9999-12-31) is
transferred unchanged (except the 1582 gap, see KNOWN LIMITS).
DATA TYPE MAPPING (by the base system type - alias types resolve to their base type):
bit -> DECIMAL(1,0); tinyint/smallint/int/bigint -> DECIMAL(3/5/10/19,0); decimal/numeric(p <= 36, s) ->
DECIMAL(p,s); money/smallmoney -> DECIMAL(19,4)/(10,4); float/real -> DOUBLE; date -> DATE; datetime ->
TIMESTAMP(3); smalldatetime -> TIMESTAMP(0); datetime2(n) -> TIMESTAMP(n); datetimeoffset(n) -> TIMESTAMP(n) WITH
LOCAL TIME ZONE; char/nchar(n <= 2000) -> CHAR(n) UTF8; varchar/nvarchar/text/ntext/char(n > 2000) -> VARCHAR UTF8
(nvarchar(n) -> VARCHAR(n): the length is in characters); sysname -> VARCHAR(128); uniqueidentifier -> CHAR(36).
Non-UTF-8 code-page columns (char/varchar/text) are converted by SQL Server itself (CAST AS NVARCHAR), so every
byte arrives as SQL Server shows it.
Small documented differences: time(n) -> VARCHAR(16) ('HH:MI:SS[.fffffff]', Exasol has no TIME type);
binary(n <= 1024) and rowversion -> HASHTYPE, other binary/varbinary/image -> hex text (BINARY_HANDLING; a binary value
holds at most 1,000,000 bytes); xml/json/vector -> VARCHAR text (vector elements keep 8 significant digits);
sql_variant -> VARCHAR text of its value (numbers with full precision, date/time in ISO form incl. the offset; the base
type is not kept); hierarchyid -> VARCHAR ('/1/2/'); geometry/geography -> GEOMETRY (2-D WKT: Z/M values and SRIDs are
not kept, curves become line approximations - invalid curved instances are made valid first -, a geography FULLGLOBE
becomes NULL); float: normal values arrive exactly, -0.0 becomes 0, subnormal values (|x| < 2.2250738585072014e-308) may
become 0, |x| > 1.7976e308 makes the IMPORT fail.
Exasol stores an empty string as NULL: empty character, binary and text values become NULL. NOT NULL is therefore
only kept on numeric, date, timestamp, hashtype and uniqueidentifier targets - and not on datetimeoffset under
TEMPORAL_OUT_OF_RANGE='NULL'.
Identity columns are migrated as plain columns carrying their values (no IDENTITY in Exasol - inserts must supply
the value). Computed columns are migrated as plain columns with their current values. Columns named LEVEL, ROWNUM,
ROWID, CONNECT_BY_ISLEAF or CONNECT_BY_ISCYCLE (not allowed as Exasol column names) get a trailing underscore.
DEFAULTs are migrated when Exasol can represent them with the same meaning (literals, getdate/sysdatetime ->
CURRENT_TIMESTAMP, getutcdate/sysutcdatetime -> the UTC time); others are listed as notes.
Hard limits (the IMPORT fails loudly rather than corrupting data): a value > 2,000,000 characters (unless
TRUNCATE_LONG_STRINGS=true), a binary value > 1,000,000 bytes, a decimal value with more integer digits than its
DECIMAL_OVERFLOW='CAP' column holds (decimal(p > 36, s) -> DECIMAL(36, s) with 36 - s integer digits, e.g.
decimal(38,2) -> DECIMAL(36,2); a scale above 35 is rounded to 35 so that one integer digit remains, so a value that
rounds up to the next integer digit, e.g. 9.99...9 in decimal(38,37), fails too; a DECIMAL CAP note names such columns),
a datetimeoffset value outside 0001-01-02 .. 9999-12-30 UTC under TEMPORAL_OUT_OF_RANGE='FAIL'.
PARALLEL IMPORT: a table with at least PARALLEL_MIN_ROWS rows is read by up to PARALLEL_STATEMENTS parallel
STATEMENT clauses, each reading a disjoint part (exact 1:1): partitions of a partitioned table, key ranges of the
clustered index (boundaries from the statistics histogram) or physical row locations of a heap / columnstore table -
also of a clustered table whose histogram gives very uneven key ranges (e.g. a sequential uniqueidentifier key). A note
names tables that get fewer or uneven STATEMENTs (few partitions or distinct key values, key values very close together).
AUTO = per table ceil(sqrt(rows / 25000)) STATEMENTs (the same row estimate as PARALLEL_MIN_ROWS), at most half
the vCPUs of the Exasol cluster (VCPU/2, 4..64; 8 without rounding to the nodes when
EXA_STATISTICS.EXA_SYSTEM_EVENTS is not readable; the rule of postgresql_to_exasol.sql), at most 2 x the SQL
Server processor count for key ranges / partitions and 1 x for physical row locations (every such STATEMENT scans
the table; no processor cap when the processor count is not readable, e.g. on Azure SQL Database - a PARALLEL note
says so), and at most one STATEMENT per about 1,000 rows; a result of at least the Exasol node count is then
rounded to the nearest multiple of it (ties up) that stays within these limits; at least 2. E.g. 4 nodes, 64
vCPUs: 250,000 rows -> 4, 1,000,000 -> 8, 7,200,000 -> 16, 72,000,000 -> 32 (key ranges with a SQL Server of at
least 16 processors; a heap gets at most the processor count, e.g. 16); 5 nodes, 160 vCPUs, SQL Server with 16
processors: 72,000,000 rows -> 30 (key ranges, at most 32). A fixed number uses that many STATEMENTs for every
table with at least PARALLEL_MIN_ROWS rows (fewer where a table has few partitions or distinct key values, or is
very wide; a PARALLEL note names it). No primary key is needed: a heap or clustered columnstore table is split by
physical row locations, a clustered rowstore index (unique or not) by key ranges. Each STATEMENT is a separate SQL
Server transaction - the source must not be written during the load; for a consistent copy migrate from a database
snapshot (CREATE DATABASE ... AS SNAPSHOT OF ..., then DB_FILTER = the snapshot name and TARGET_SCHEMA set; not on
Azure SQL Database). AUTO sizes the streams for the Exasol cluster and the SQL Server processor count and assumes
that the source server may be used fully during the load: for a production, weak or shared SQL Server set
PARALLEL_STATEMENTS explicitly lower and try it on a medium-sized table first. Each stream is one SQL Server
session; with cached data it used about 0.05-0.12 CPU core of the source with key ranges (about 0.3 core for a
heap, where every STATEMENT scans the table), but about 1.5-2 CPU cores and up to about 2 GB of memory (large
tables) on its Exasol node (every STATEMENT runs in its own Java process): on Exasol nodes with little memory, use
fewer streams for very large tables.
CONSTRAINTS: PK/FK are always created DISABLED (the primary key is named <TABLE>_PK, foreign keys keep their SQL Server
names); a final CONSTRAINT STATE section enables them according to
CONSTRAINT_STATE (SET_AS_SOURCE keeps keys that are disabled or not trusted - WITH NOCHECK - disabled). A foreign key
is migrated when its parent table is migrated and it references the parent primary key. Exasol compares strings
binary: keys that hold only under a case-insensitive collation or trailing-blank padding may fail to enable.
SPECIAL TABLES (listed as notes): row-level security and dynamic data masking filter or mask the migrated data for a
connection user without exemption / UNMASK (CHECK_MIGRATION then reports DEVIATIONs for masked columns, because SQL
Server masks the source aggregates too); Always Encrypted columns are not migrated; graph tables (internal columns skipped), memory-optimized tables (read
WITH (SNAPSHOT)), temporal history and ledger tables (migrated as plain tables), tables with a disabled clustered
index (created empty), indexed views (only with MIGRATE_INDEXED_VIEWS). System databases are migrated only when
DB_FILTER names them exactly; Microsoft-shipped objects, external tables and temporary tables are never migrated.
Not migrated: indexes, UNIQUE/CHECK constraints, sequences, synonyms, rules, functions/procedures/triggers, users,
roles and permissions.
PRIVILEGES: the metadata and the data are read through the connection user: grant VIEW DEFINITION (otherwise DEFAULTs,
view definitions and security policies are invisible, and tables the user may not SELECT - or in a schema hidden by a
schema-level DENY VIEW DEFINITION, even with SELECT granted - are missing from the output
- a note says so) and SELECT on the tables (a table without SELECT or with a column-level DENY is skipped with a note;
a database whose catalog the user may not read is skipped with a note). On the Exasol side, the user running the
generator and the output needs the system privilege IMPORT and the connection granted with GRANT CONNECTION (ACCESS ON
CONNECTION is not enough), CREATE SCHEMA / CREATE TABLE for the target, and for CHECK_MIGRATION the right to create
tables in the script schema (owner of that schema, or CREATE ANY TABLE and DROP ANY TABLE).
KNOWN LIMITS: dates between 1582-10-05 and 1582-10-14 arrive shifted by 10 days (Julian/Gregorian gap of the transfer;
a date key may then collide with real 1582-10-15..24 values); a STATEMENT of 131,072 bytes or more is rejected by
Exasol (a SIZE LIMIT note names such very wide tables); a table with more than 4,096 columns is not migrated. A primary key with an empty string value cannot be enabled ('' is NULL
in Exasol). Filters: comma separated names or LIKE patterns, as in postgresql_to_exasol.sql (SCHEMA_FILTER and
TABLE_FILTER: '', NULL and '%' select all; DB_FILTER must not be empty; a list of only commas is an error); _ and %
are always wildcards (e.g. 'sales_2024' also matches salesX2024), [ and ] are literal characters; case sensitivity follows the source collation (e.g. under a Turkish collation 'KISI' does not
match Kisi). A filter that names a view, synonym, sequence or table type matches no table.
CHECK_MIGRATION reads every source table again - once per group of up to 250 metrics, and the distinct count of every
column adds a scan of its own - so on wide tables the check can take much longer than the IMPORT. It compares the
transported values: under TEMPORAL_OUT_OF_RANGE = 'NULL' or 'CLAMP' the changed datetimeoffset values are not counted. The per-table
<table>_MIG_CHK tables of the previous release in the target schemas are not removed.
Comments and DEFAULT texts are copied from the source as they are: when they contain a question mark, a colon before a
quote or a backslash before a quote, a client such as DbVisualizer may ask for parameter values when running the
output - disable its parameter / variable substitution for that run.
*/
--/
create or replace script database_migration.SQLSERVER_TO_EXASOL(
CONNECTION_NAME -- name of the JDBC connection inside Exasol, e.g. SQLSERVER_JDBC
,DB2SCHEMA -- false (recommended) => "schema"."table"; true => "database"."schema_table" (several databases at once)
,DB_FILTER -- SQL Server database(s): a name or LIKE pattern, or a comma list of them, e.g. 'sales', 'db1, db2', 'dwh%'
,SCHEMA_FILTER -- schema(s): names or LIKE patterns, comma separated, '%' = all
,TARGET_SCHEMA -- target schema on Exasol; '' = use the source schema (or, with DB2SCHEMA, the database) name
,TABLE_FILTER -- table(s): names or LIKE patterns, comma separated, '%' = all
,IDENTIFIER_CASE_INSENSITIVE -- true (recommended) => fold all identifiers to UPPER case; false => keep them as in SQL Server
,PARALLEL_STATEMENTS -- 'AUTO' (recommended: per table ceil(sqrt(rows / 25000)), 2..VCPU/2 (4..64), at most 2 x / 1 x the SQL Server processor count, then, from the node count on, the nearest multiple of the Exasol nodes within these limits), a number >= 1 (no per-table sizing), or 1 = one STATEMENT per table
,PARALLEL_MIN_ROWS -- tables with fewer rows are read with one STATEMENT (default 250000); 0 = split every table that can be split
,CONSTRAINT_STATE -- 'FORCE_DISABLE' (recommended), 'SET_AS_SOURCE' or 'FORCE_ENABLE'
,GENERATE_COMMENTS -- true/false: migrate MS_Description comments of schemas, tables and columns
,GENERATE_VIEWS -- true/false: list the source views as a commented manual-review section
,MIGRATE_INDEXED_VIEWS -- false (default) => indexed views are only listed for review; true => migrate their stored rows as tables
,GENERATE_PARTITION_BY -- true/false: PARTITION BY from the SQL Server partitioning column (when its Exasol type allows it)
,BINARY_HANDLING -- 'HASHTYPE' (recommended; binary(n <= 1024) and rowversion -> HASHTYPE, other binary -> hex text), 'HEX' (all binary as hex text) or 'SKIP' (binary columns are not migrated)
,DECIMAL_OVERFLOW -- 'CAP' (recommended), 'DOUBLE' or 'VARCHAR' for decimal/numeric with precision > 36
,TRUNCATE_LONG_STRINGS -- false (recommended): a value > 2,000,000 characters makes the IMPORT fail; true: cut it to 2,000,000 characters
,TEMPORAL_OUT_OF_RANGE -- 'FAIL' (recommended), 'NULL' or 'CLAMP' for datetimeoffset values outside the usable range 0001-01-02 .. 9999-12-30 UTC
,CHECK_MIGRATION -- true/false: also generate a source-vs-target data validation (run after the IMPORTs)
) RETURNS TABLE
AS
-------------------------------------------------------------------------------------------------------------
-- helpers
-------------------------------------------------------------------------------------------------------------
local NL = string.char(10)
local CR = string.char(13)
local SQ = string.char(39) -- single quote
local DQ = string.char(34) -- double quote
local BSL = string.char(92) -- backslash
local function fail(msg) error('SQLSERVER_TO_EXASOL: ' .. msg) end
local function isnull(v) return v == nil or v == null end
local function str(v) if isnull(v) then return nil end return tostring(v) end
local function int(v) if isnull(v) then return nil end return math.floor(tonumber(v)) end
local function istr(n) return string.format('%d', math.floor(n)) end
local function trim(s) return (s:gsub('^%s+', ''):gsub('%s+$', '')) end
local function sl(s) return SQ .. (s:gsub(SQ, SQ .. SQ)) .. SQ end -- SQL string literal (Exasol and T-SQL)
local function nl_(s) return 'N' .. sl(s) end -- T-SQL nvarchar literal
local function qi(s) return DQ .. (s:gsub(DQ, DQ .. DQ)) .. DQ end -- Exasol quoted identifier
local function qb(s) return '[' .. (s:gsub('%]', ']]')) .. ']' end -- T-SQL bracketed identifier
local function oneline(s) return (s:gsub('%c', ' ')) end -- names inside comment rows
local UTF8_CONT = '[' .. string.char(128) .. '-' .. string.char(191) .. ']'
local function ulen(s) return #s - select(2, s:gsub(UTF8_CONT, '')) end -- length in characters (UTF-8)
local function usub(s, n) -- first n characters (UTF-8)
local count = 0
for i = 1, #s do
local b = s:byte(i)
if b < 128 or b >= 192 then
count = count + 1
if count > n then return s:sub(1, i - 1) end
end
end
return s
end
local C0 = '[' .. string.char(0) .. '-' .. string.char(8) .. string.char(11, 12) .. string.char(14) .. '-' .. string.char(31) .. string.char(127) .. ']'
local function cmt(s) -- multi-line text as comment lines
s = s:gsub(C0, ' ') -- other control characters break EXAplus file mode
s = s:gsub(CR .. NL, NL):gsub(CR, NL)
return (s:gsub(NL, NL .. '-- '))
end
-------------------------------------------------------------------------------------------------------------
-- parameter validation (invalid values fail loudly)
-------------------------------------------------------------------------------------------------------------
local function p_bool(v, name, default)
if isnull(v) then return default end
if type(v) == 'boolean' then return v end
if type(v) == 'string' then
local u = trim(v):upper()
if u == '' then return default end
if u == 'TRUE' then return true end
if u == 'FALSE' then return false end
end
fail('invalid value ' .. tostring(v) .. ' for ' .. name .. ' - valid: true, false')
end
local function p_opt(v, name, valid)
if isnull(v) then return valid[1] end
local u = trim(tostring(v)):upper()
if u == '' then return valid[1] end
for _, x in ipairs(valid) do if u == x then return u end end
fail('invalid value ' .. tostring(v) .. ' for ' .. name .. ' - valid: ' .. table.concat(valid, ', '))
end
-- whole numbers only (no '0x10', '1e1'), at most 2,147,483,647
local function p_int(v)
local n
if type(v) == 'string' then
local t = trim(v)
if not (t:match('^%d+$') or t:match('^%d+%.0*$')) then return nil end
n = tonumber(t)
else
n = tonumber(v)
end
if n == nil or n ~= math.floor(n) or n > 2147483647 then return nil end
return n
end
local CONN = str(CONNECTION_NAME)
if CONN == nil or trim(CONN) == '' then fail('invalid value (empty) for CONNECTION_NAME - valid: the name of an existing JDBC connection') end
CONN = trim(CONN)
if not (CONN:match('^[%a_][%w_]*$') or (CONN:match('^' .. DQ .. '[^' .. DQ .. ']+' .. DQ .. '$'))) then
fail('invalid value ' .. CONN .. ' for CONNECTION_NAME - valid: the name of an existing CONNECTION object (an identifier, not a JDBC URL)')
end
if type(CONNECTION_NAME) ~= 'string' then fail('invalid value ' .. CONN .. ' for CONNECTION_NAME - valid: the name of an existing CONNECTION object (an identifier, not a JDBC URL)') end
if not CONN:match('^' .. DQ) and query('select count(*) from EXA_SQL_KEYWORDS where RESERVED and KEYWORD = upper(' .. sl(CONN) .. ')')[1][1] > 0 then
fail('invalid value ' .. CONN .. ' for CONNECTION_NAME - a reserved word must be given in the quoted form ' .. DQ .. CONN:upper() .. DQ)
end
local D2S = p_bool(DB2SCHEMA, 'DB2SCHEMA', false)
local DBF = str(DB_FILTER)
if DBF == nil or trim(DBF) == '' then fail('invalid value (empty) for DB_FILTER - valid: a database name or LIKE pattern, or a comma list of them') end
local SF = str(SCHEMA_FILTER); if SF == nil or trim(SF) == '' then SF = '%' end
local TF = str(TABLE_FILTER); if TF == nil or trim(TF) == '' then TF = '%' end
local TGT = str(TARGET_SCHEMA); if TGT ~= nil then TGT = trim(TGT); if TGT == '' then TGT = nil end end
local TGT_Q = false
if TGT and #TGT >= 2 and TGT:sub(1, 1) == DQ and TGT:sub(-1) == DQ then
local inner = TGT:sub(2, -2)
if inner == '' or inner:gsub(DQ .. DQ, ''):find(DQ, 1, true) then fail('invalid value ' .. TGT .. ' for TARGET_SCHEMA - valid: a name, or a quoted name with doubled inner double quotes') end
TGT = inner:gsub(DQ .. DQ, DQ); TGT_Q = true
end
if TGT and ulen(TGT) > 128 then fail('invalid value ' .. TGT .. ' for TARGET_SCHEMA - valid: at most 128 characters') end
local ICI = p_bool(IDENTIFIER_CASE_INSENSITIVE, 'IDENTIFIER_CASE_INSENSITIVE', true)
local TGT_UP = TGT and (TGT_Q and TGT or query('select upper(' .. sl(TGT) .. ') from sys.dual')[1][1]) or nil
local CSTATE = p_opt(CONSTRAINT_STATE, 'CONSTRAINT_STATE', {'FORCE_DISABLE', 'SET_AS_SOURCE', 'FORCE_ENABLE'})
local G_COMM = p_bool(GENERATE_COMMENTS, 'GENERATE_COMMENTS', true)
local G_VIEWS = p_bool(GENERATE_VIEWS, 'GENERATE_VIEWS', true)
local G_IV = p_bool(MIGRATE_INDEXED_VIEWS, 'MIGRATE_INDEXED_VIEWS', false)
local G_PART = p_bool(GENERATE_PARTITION_BY, 'GENERATE_PARTITION_BY', true)
local BINMODE = p_opt(BINARY_HANDLING, 'BINARY_HANDLING', {'HASHTYPE', 'HEX', 'SKIP'})
local DECOF = p_opt(DECIMAL_OVERFLOW, 'DECIMAL_OVERFLOW', {'CAP', 'DOUBLE', 'VARCHAR'})
local TRUNC = p_bool(TRUNCATE_LONG_STRINGS, 'TRUNCATE_LONG_STRINGS', false)
local OORMODE = p_opt(TEMPORAL_OUT_OF_RANGE, 'TEMPORAL_OUT_OF_RANGE', {'FAIL', 'NULL', 'CLAMP'})
local G_CHECK = p_bool(CHECK_MIGRATION, 'CHECK_MIGRATION', false)
local PS_AUTO, PS_FIX = false, 1
do
local v = PARALLEL_STATEMENTS
if isnull(v) or (type(v) == 'string' and (trim(v) == '' or trim(v):upper() == 'AUTO')) then
PS_AUTO = true
else
local n = p_int(v)
if n == nil or n < 1 then fail('invalid value ' .. tostring(v) .. ' for PARALLEL_STATEMENTS - valid: AUTO or an integer from 1 to 2147483647') end
PS_FIX = math.floor(n)
end
end
local PMIN = 250000
do
local v = PARALLEL_MIN_ROWS
if not (isnull(v) or (type(v) == 'string' and trim(v) == '')) then
local n = p_int(v)
if n == nil or n < 0 then fail('invalid value ' .. tostring(v) .. ' for PARALLEL_MIN_ROWS - valid: an integer from 0 to 2147483647') end
PMIN = math.floor(n)
end
end
-- filter lists: comma separated; an element with % or _ is a LIKE pattern (SQL Server LIKE, [ escaped), else an exact name
local function filter_elems(f)
local out = {}
for e in (f .. ','):gmatch('([^,]*),') do
e = trim(e)
if e ~= '' then out[#out + 1] = e end
end
return out
end
local function is_pattern(e) return e:find('[%%_]') ~= nil end
local function filt(f, col)
local parts = {}
for _, e in ipairs(filter_elems(f)) do
if is_pattern(e) then
local esc = e:gsub(BSL, BSL .. BSL):gsub('%[', BSL .. '[')
parts[#parts + 1] = col .. ' like ' .. nl_(esc) .. ' escape ' .. sl(BSL)
else
parts[#parts + 1] = col .. ' = ' .. nl_(e)
end
end
if #parts == 0 then return '1 = 1' end
return '(' .. table.concat(parts, ' or ') .. ')'
end
local SCH_W = filt(SF, 's.name')
local TAB_W = filt(TF, 'o.name')
if #SCH_W + #TAB_W > 40000 then fail('SCHEMA_FILTER / TABLE_FILTER too long (' .. istr(#SCH_W + #TAB_W) .. ' bytes; at most 40,000 because the filters are repeated inside the metadata queries) - use LIKE patterns or several runs') end
if #filter_elems(SF) == 0 then fail('invalid value ' .. SF .. ' for SCHEMA_FILTER - valid: names or LIKE patterns, comma separated (% = all)') end
if #filter_elems(TF) == 0 then fail('invalid value ' .. TF .. ' for TABLE_FILTER - valid: names or LIKE patterns, comma separated (% = all)') end
if #filter_elems(DBF) == 0 then fail('invalid value ' .. DBF .. ' for DB_FILTER - valid: a database name or LIKE pattern, or a comma list of them') end
-------------------------------------------------------------------------------------------------------------
-- remote (SQL Server) access
-------------------------------------------------------------------------------------------------------------
local function remote(tsql, exa_select)
return query('select ' .. (exa_select or 't.*') .. ' from (import from jdbc at ' .. CONN .. ' statement ' .. sl(tsql) .. ') t')
end
local function premote(tsql, exa_select)
return pquery('select ' .. (exa_select or 't.*') .. ' from (import from jdbc at ' .. CONN .. ' statement ' .. sl(tsql) .. ') t')
end
-- source environment
local env = remote([[select cast(serverproperty('ProductVersion') as nvarchar(128)) as PV, cast(serverproperty('ProductMajorVersion') as int) as PMAJ,
cast(serverproperty('EngineEdition') as int) as EED, cast(serverproperty('Edition') as nvarchar(128)) as EDI, db_name() as CDB]])
local VER, VMAJ, EED, EDITION, CURDB = str(env[1][1]), int(env[1][2]) or 0, int(env[1][3]) or 0, str(env[1][4]), str(env[1][5])
local AZURE_DB = (EED == 5)
local AZURE = (EED == 5 or EED == 8) -- Azure SQL Database / Managed Instance (version line 12, no ProductMajorVersion)
if VMAJ < 13 and not AZURE then fail('SQL Server 2016 or newer required (found ' .. tostring(VER) .. ')') end
local SRC_CPU = nil
do
local ok, r = premote([[exec master.dbo.xp_msver 'ProcessorCount']])
if ok and #r >= 1 and not isnull(r[1][3]) then SRC_CPU = int(r[1][3]) end
end
-- Exasol environment
local SCRIPT_SCHEMA = exa.meta.script_schema
local SESSION_TZ = query('select sessiontimezone from sys.dual')[1][1]
local NODES, VCPU = nil, nil
do
local ok, r = pquery([[select NODES, VCPU from EXA_STATISTICS.EXA_SYSTEM_EVENTS where EVENT_TYPE = 'STARTUP' order by MEASURE_TIME desc limit 1]])
if ok and #r == 1 and not isnull(r[1][1]) and not isnull(r[1][2]) then NODES = int(r[1][1]); VCPU = int(r[1][2]) end
end
local RESERVED = {}
do
local r = query([[select upper(KEYWORD) from EXA_SQL_KEYWORDS where RESERVED]])
for i = 1, #r do RESERVED[r[i][1]] = true end
end
local FORBIDDEN_COL = {LEVEL = true, ROWNUM = true, ROWID = true, CONNECT_BY_ISLEAF = true, CONNECT_BY_ISCYCLE = true}
-- resolve PARALLEL_STATEMENTS
local PS, PS_NOTE
if PS_AUTO then
-- same rule as postgresql_to_exasol.sql: PS = upper bound per call, half the vCPUs of the Exasol cluster, 4..64;
-- the STATEMENTs per table follow from its row count (auto_n) and the SQL Server processor count (src_cap)
local why
if VCPU and NODES and NODES > 0 then
PS = math.max(4, math.min(64, math.floor(VCPU / 2)))
why = 'Exasol VCPU ' .. istr(VCPU) .. ' / 2, 4..64'
else
PS = 8
why = 'fallback; EXA_STATISTICS.EXA_SYSTEM_EVENTS not readable'
end
if SRC_CPU and SRC_CPU >= 1 then why = why .. '; at most ' .. istr(2 * SRC_CPU) .. ' for key ranges / partitions and ' .. istr(SRC_CPU) .. ' for physical row locations (2 x / 1 x the SQL Server processor count ' .. istr(SRC_CPU) .. ')'
elseif not SRC_CPU then why = why .. '; SQL Server processor count not readable (xp_msver), no cap' end
PS_NOTE = 'AUTO -> per table ceil(sqrt(rows / 25000)), at least 2, at most ' .. istr(PS) .. ' (' .. why .. ')' ..
((NODES and NODES > 1) and ('; from ' .. istr(NODES) .. ' STATEMENTs on, rounded to the nearest multiple of the ' .. istr(NODES) .. ' nodes that stays within the limits of the table') or '')
else
PS = PS_FIX
PS_NOTE = 'fixed -> ' .. istr(PS)
end
-------------------------------------------------------------------------------------------------------------
-- output collection
-------------------------------------------------------------------------------------------------------------
local OUT = {}
local function add(s) OUT[#OUT + 1] = {s} end
local NOTES = {}
local function note(topic, text, warn)
NOTES[#NOTES + 1] = (warn and '-- !!! ' or '-- NOTE ') .. topic .. ': ' .. cmt(text)
end
local function exa_id(raw, up) if ICI then return up else return raw end end
-------------------------------------------------------------------------------------------------------------
-- databases
-------------------------------------------------------------------------------------------------------------
local DBS = {}
do
local named_in = {}
for _, e in ipairs(filter_elems(DBF)) do named_in[#named_in + 1] = nl_(e) end -- named exactly = a filter element equal to the name (case-insensitive)
if AZURE_DB then
local r = remote('select name as N, cast(has_perms_by_name(name, ' .. sl('DATABASE') .. ', ' .. sl('VIEW DEFINITION') .. ') as int) as VD from sys.databases where name = db_name() and ' .. filt(DBF, 'name'))
if #r == 0 then
fail('Azure SQL Database - one connection per database: set databaseName=<database> in the connection and DB_FILTER = ' .. SQ .. tostring(CURDB) .. SQ .. ' (connected database ' .. tostring(CURDB) .. ')')
end
DBS[1] = {name = CURDB, vd = (int(r[1][2]) == 1)}
else
local r = remote('select name as N, database_id as DID, cast(state as int) as ST, state_desc as SD, cast(has_dbaccess(name) as int) as ACC, ' ..
'cast(is_distributor as int) as DIS, case when source_database_id is null then 0 else 1 end as SNAP, ' ..
'cast(has_perms_by_name(name, ' .. sl('DATABASE') .. ', ' .. sl('VIEW DEFINITION') .. ') as int) as VD, ' ..
'case when name collate Latin1_General_100_CI_AS in (' .. table.concat(named_in, ', ') .. ') then 1 else 0 end as NAMED, user_access_desc as UAD ' ..
'from sys.databases where ' .. filt(DBF, 'name') .. ' order by name')
local sysdb = {}
for i = 1, #r do
local name, did, st, sd, acc, dis, snap, vd = r[i][1], int(r[i][2]), int(r[i][3]), str(r[i][4]), int(r[i][5]), int(r[i][6]), int(r[i][7]), int(r[i][8])
local named = int(r[i][9]) == 1
if (did <= 4 or dis == 1) and not named then
sysdb[#sysdb + 1] = oneline(name)
elseif snap == 1 and not named then
note('SKIPPED DATABASE', 'database ' .. oneline(name) .. ' is a database snapshot - it is only migrated when DB_FILTER names it exactly', true)
elseif st ~= 0 then
note('SKIPPED DATABASE', 'database ' .. oneline(name) .. ' is not online (' .. tostring(sd) .. ') and is not migrated', true)
elseif acc ~= 1 then
local uad = str(r[i][10])
note('SKIPPED DATABASE', 'database ' .. oneline(name) .. ' is not accessible for the connection user' ..
((uad == 'SINGLE_USER') and ' (SINGLE_USER mode - another session holds it)' or (uad == 'RESTRICTED_USER') and ' (RESTRICTED_USER mode - only members of db_owner, dbcreator or sysadmin may connect)' or '') .. ' and is not migrated', true)
else
DBS[#DBS + 1] = {name = name, vd = (vd == 1)}
end
end
table.sort(sysdb)
if #sysdb > 0 then note('SKIPPED DATABASE', 'system databases matched by a pattern are not migrated (name them exactly in DB_FILTER to include them): ' .. table.concat(sysdb, ', '), true) end
end
table.sort(DBS, function(a, b) return a.name < b.name end)
end
local function vd_notes()
for _, d in ipairs(DBS) do
if not d.vd then
note('NO VIEW DEFINITION', 'the connection user has no VIEW DEFINITION permission in database ' .. oneline(d.name) ..
' - DEFAULTs, view definitions and row-level security policies are not visible and therefore not migrated or reported, and tables the user may not SELECT (or whose schema is hidden by DENY VIEW DEFINITION) are missing from this output; grant VIEW DEFINITION and SELECT for a complete migration', true)
end
end
end
-- one T-SQL query per database, chunked into UNION ALL statements below 100,000 bytes
local function remote_all(fn, exa_select)
local rows, cur, len = {}, {}, 0
local function flush()
if #cur == 0 then return end
local r = remote(table.concat(cur, ' union all '), exa_select)
for i = 1, #r do rows[#rows + 1] = r[i] end
cur, len = {}, 0
end
for _, d in ipairs(DBS) do
local qs = fn(d.name, qb(d.name))
if type(qs) ~= 'table' then qs = {qs} end
for _, q in ipairs(qs) do
if #cur > 0 and len + #q > 100000 then flush() end
cur[#cur + 1] = q; len = len + #q + 11
end
end
flush()
return rows
end
local GRAPH = (VMAJ >= 14) or AZURE
local LEDGER = (VMAJ >= 16) or AZURE
local SYS_SCHEMAS = "('sys', 'INFORMATION_SCHEMA', 'guest', 'db_owner', 'db_accessadmin', 'db_securityadmin', 'db_ddladmin', 'db_backupoperator', 'db_datareader', 'db_datawriter', 'db_denydatareader', 'db_denydatawriter')"
local OBJW = ' o.is_ms_shipped = 0 and left(o.name, 1) <> ' .. sl('#') .. ' and s.name not in ' .. SYS_SCHEMAS .. ' and ' .. SCH_W .. ' and ' .. TAB_W .. ' '
local function objfrom(B) return B .. '.sys.objects o join ' .. B .. '.sys.schemas s on s.schema_id = o.schema_id' end
-------------------------------------------------------------------------------------------------------------
-- relations (tables, and indexed views with MIGRATE_INDEXED_VIEWS)
-------------------------------------------------------------------------------------------------------------
local RELS, REL_BY = {}, {}
local function rkey(db, oid) return db .. NL .. istr(oid) end
local UPPER4 = 't.*, upper(t."DBN"), upper(t."SCH"), upper(t."TN"), upper(t."SCH" || ' .. sl('_') .. ' || t."TN")'
local function new_rel(x, kind)
local r = {db = x[1], sch = x[2], tn = x[3], oid = int(x[4]), kind = kind, cols = {}, colmap = {}}
local n = #x
r.db_up, r.sch_up, r.tn_up, r.st_up = x[n - 3], x[n - 2], x[n - 1], x[n]
if TGT then r.exa_schema = ICI and TGT_UP or TGT
elseif D2S then r.exa_schema = exa_id(r.db, r.db_up)
else r.exa_schema = exa_id(r.sch, r.sch_up) end
r.exa_table = D2S and exa_id(r.sch .. '_' .. r.tn, r.st_up) or exa_id(r.tn, r.tn_up)
r.label = oneline(r.db) .. '.' .. oneline(r.sch) .. '.' .. oneline(r.tn)
r.src = qb(r.db) .. '.' .. qb(r.sch) .. '.' .. qb(r.tn)
r.migrate = true
RELS[#RELS + 1] = r
REL_BY[rkey(r.db, r.oid)] = r
return r
end
do
local function rel_query(db, B)
return 'select ' .. nl_(db) .. ' as DBN, s.name collate database_default as SCH, o.name collate database_default as TN, o.object_id as OID, ' ..
'cast(t.is_memory_optimized as int) as MO, cast(t.durability as int) as DUR, cast(t.temporal_type as int) as TT, ' ..
'(select hs.name + ' .. sl('.') .. ' + ht.name from ' .. B .. '.sys.tables ht join ' .. B .. '.sys.schemas hs on hs.schema_id = ht.schema_id where ht.history_table_id = t.object_id) collate database_default as HPARENT, ' ..
(LEDGER and 'cast(t.ledger_type as int)' or 'cast(0 as int)') .. ' as LT, ' ..
(GRAPH and '(cast(t.is_node as int) + 2 * cast(t.is_edge as int))' or 'cast(0 as int)') .. ' as GR, ' ..
'(select sum(p.rows) from ' .. B .. '.sys.partitions p where p.object_id = o.object_id and p.index_id in (0, 1)) as RWS, ' ..
'cast(i.type as int) as ITYPE, cast(i.is_disabled as int) as IDIS, ' ..
'(select count(*) from ' .. B .. '.sys.security_predicates sp join ' .. B .. '.sys.security_policies pol on pol.object_id = sp.object_id where pol.is_enabled = 1 and sp.predicate_type = 0 and sp.target_object_id = o.object_id) as RLS, ' ..
'(select count(*) from ' .. B .. '.sys.masked_columns mc where mc.object_id = o.object_id and mc.is_masked = 1) as MSK, ' ..
'pf.name collate database_default as PF, pc.name collate database_default as PCOL, ' ..
'kc.name collate database_default as KCOL, type_name(kc.system_type_id) as KTYPE, cast(kc.max_length as int) as KLEN, cast(kc.precision as int) as KPREC, cast(kc.scale as int) as KSCALE, ' ..
'cast(kc.is_nullable as int) as KNULL, kc.collation_name collate database_default as KCOLL, cast(case when kc.system_type_id = kc.user_type_id then 0 else 1 end as int) as KALIAS ' ..
'from ' .. B .. '.sys.tables t join ' .. objfrom(B) .. ' on o.object_id = t.object_id ' ..
'join ' .. B .. '.sys.indexes i on i.object_id = o.object_id and i.index_id <= 1 ' ..
'left join ' .. B .. '.sys.partition_schemes ps on ps.data_space_id = i.data_space_id ' ..
'left join ' .. B .. '.sys.partition_functions pf on pf.function_id = ps.function_id ' ..
'left join ' .. B .. '.sys.index_columns pic on pic.object_id = o.object_id and pic.index_id = i.index_id and pic.partition_ordinal = 1 ' ..
'left join ' .. B .. '.sys.columns pc on pc.object_id = o.object_id and pc.column_id = pic.column_id ' ..
'left join ' .. B .. '.sys.index_columns ic on ic.object_id = o.object_id and ic.index_id = 1 and ic.key_ordinal = 1 and i.type = 1 ' ..
'left join ' .. B .. '.sys.columns kc on kc.object_id = o.object_id and kc.column_id = ic.column_id ' ..
'where t.is_external = 0 and ' .. (LEDGER and ('t.is_dropped_ledger_table = 0 and not exists (select 1 from ' .. B .. '.sys.tables dl where dl.history_table_id = t.object_id and dl.is_dropped_ledger_table = 1) and ') or '') .. OBJW
end
local ok_tr, tr = pcall(remote_all, rel_query, UPPER4)
if not ok_tr then
local err, keep = tr, {}
for _, d in ipairs(DBS) do
if premote('select top 0 1 as X from ' .. qb(d.name) .. '.sys.objects') then keep[#keep + 1] = d
else note('SKIPPED DATABASE', 'database ' .. oneline(d.name) .. ' is not migrated: the connection user may not read its catalog (SELECT denied, e.g. db_denydatareader)', true) end
end
if #keep == #DBS then error(err, 0) end -- no database is unreadable: the original error stands
DBS = keep
tr = remote_all(rel_query, UPPER4)
end
do
-- SQL Server checks the catalog SELECT permission in the context of the first database of a statement only, so a
-- database whose catalog is not readable can also come back silently empty: probe every database without a match
local seen, keep = {}, {}
for i = 1, #tr do seen[tr[i][1]] = true end
for _, d in ipairs(DBS) do
if seen[d.name] or premote('select top 0 1 as X from ' .. qb(d.name) .. '.sys.objects') then keep[#keep + 1] = d
else note('SKIPPED DATABASE', 'database ' .. oneline(d.name) .. ' is not migrated: the connection user may not read its catalog (SELECT denied, e.g. db_denydatareader)', true) end
end
DBS = keep
end
vd_notes()
for i = 1, #tr do
local x = tr[i]
local r = new_rel(x, 'U')
r.mo, r.dur, r.tt, r.hparent, r.lt, r.gr = int(x[5]) == 1, int(x[6]), int(x[7]), str(x[8]), int(x[9]), int(x[10])
r.rows = tonumber(x[11]) or 0
r.itype, r.idis, r.rls, r.msk = int(x[12]), int(x[13]) == 1, int(x[14]), int(x[15])
r.pf, r.pcol = str(x[16]), str(x[17])
r.key = (not isnull(x[18])) and {col = x[18], typ = str(x[19]), len = int(x[20]), prec = int(x[21]), scale = int(x[22]),
null_ = int(x[23]) == 1, coll = str(x[24]), alias = int(x[25]) == 1} or nil
end
end
-- views (review section) and indexed views
local VIEWS = {}
if G_VIEWS or G_IV then
local vr = remote_all(function(db, B)
return 'select ' .. nl_(db) .. ' as DBN, s.name collate database_default as SCH, o.name collate database_default as TN, o.object_id as OID, ' ..
'cast(case when exists (select 1 from ' .. B .. '.sys.indexes i where i.object_id = o.object_id and i.index_id = 1) then 1 else 0 end as int) as IDX, ' ..
'(select sum(p.rows) from ' .. B .. '.sys.partitions p where p.object_id = o.object_id and p.index_id = 1) as RWS, ' ..
'cast(case when exists (select 1 from ' .. B .. '.sys.indexes i where i.object_id = o.object_id and i.index_id = 1 and i.is_disabled = 1) then 1 else 0 end as int) as IDIS, ' ..
'cast(left(m.definition, 1990000) as nvarchar(max)) collate database_default as DEF ' ..
'from ' .. B .. '.sys.views v join ' .. objfrom(B) .. ' on o.object_id = v.object_id ' ..
'left join ' .. B .. '.sys.sql_modules m on m.object_id = o.object_id ' ..
'where ' .. (LEDGER and ('o.object_id not in (select lt.ledger_view_id from ' .. B .. '.sys.tables lt where lt.ledger_view_id is not null) and ') or '') .. OBJW
end, UPPER4)
for i = 1, #vr do
local x = vr[i]
local v = {db = x[1], sch = x[2], tn = x[3], oid = int(x[4]), idx = int(x[5]) == 1, rows = tonumber(x[6]) or 0, idis = int(x[7]) == 1, def = str(x[8])}
v.label = oneline(v.db) .. '.' .. oneline(v.sch) .. '.' .. oneline(v.tn)
if v.idx and G_IV then
local r = new_rel(x, 'V')
r.mo, r.tt, r.lt, r.gr, r.rows, r.itype, r.idis, r.rls, r.msk = false, 0, 0, 0, v.rows, 1, v.idis, 0, 0
v.migrated = r
end
VIEWS[#VIEWS + 1] = v
end
table.sort(VIEWS, function(a, b) return a.label < b.label end)
end
do
local has, empty = {}, {}
for _, r in ipairs(RELS) do has[r.db] = true end
for _, d in ipairs(DBS) do if not has[d.name] then empty[#empty + 1] = oneline(d.name) end end
if #empty > 0 and #empty < #DBS then
note('NO TABLE', 'no table matched SCHEMA_FILTER / TABLE_FILTER in database(s) ' .. table.concat(empty, ', ') ..
' - if tables are expected there, check that the connection user may SELECT them and see their schema (VIEW DEFINITION)', false)
end
end
table.sort(RELS, function(a, b) if a.exa_schema ~= b.exa_schema then return a.exa_schema < b.exa_schema end
if a.exa_table ~= b.exa_table then return a.exa_table < b.exa_table end return a.label < b.label end)
-- target names: system schemas, length, collisions
for _, r in ipairs(RELS) do
local u = r.exa_schema:upper()
if u == 'SYS' or u == 'EXA_STATISTICS' or u == 'EXA_SYSTEM' then
fail('target schema ' .. r.exa_schema .. ' collides with an Exasol system schema - set TARGET_SCHEMA')
end
if ulen(r.exa_schema) > 128 or ulen(r.exa_table) > 128 then
r.migrate = false
note('NAME TOO LONG', 'table ' .. r.label .. ' maps to an Exasol name longer than 128 characters (' .. oneline(r.exa_schema) .. '.' .. oneline(r.exa_table) .. ') and is not migrated', true)
end
end
do
local seen = {}
for _, r in ipairs(RELS) do
if r.migrate then
local k = r.exa_schema .. NL .. r.exa_table
if seen[k] then
seen[k].migrate = false; r.migrate = false
note('NAME COLLISION', 'source tables ' .. seen[k].label .. ' and ' .. r.label .. ' map to the same Exasol table ' .. oneline(r.exa_schema) .. '.' .. oneline(r.exa_table) .. ' - neither is migrated; use IDENTIFIER_CASE_INSENSITIVE = false, DB2SCHEMA or separate TARGET_SCHEMA runs', true)
else
seen[k] = r
end
end
end
local sdone = {}
for _, r in ipairs(RELS) do
if r.migrate and RESERVED[r.exa_schema:upper()] and not sdone[r.exa_schema] then
sdone[r.exa_schema] = true
note('RESERVED NAME', 'the schema name ' .. oneline(r.exa_schema) .. ' is a reserved word in Exasol and must be quoted in queries', false)
end
end
end
-- notes about table kinds
for _, r in ipairs(RELS) do
if r.migrate then
if r.rls and r.rls > 0 then note('ROW LEVEL SECURITY', 'table ' .. r.label .. ' has an enabled row-level security filter predicate - the IMPORT reads only the rows the connection user may see (this can apply to sysadmin logins too); migrate with a login that the predicate exempts. The CHECK metric ROW_CNT_CATALOG shows a difference', true) end
if r.msk and r.msk > 0 then note('MASKED COLUMNS', 'table ' .. r.label .. ' has ' .. istr(r.msk) .. ' dynamically masked column(s) - a connection user without UNMASK permission migrates the masked values, and CHECK_MIGRATION then reports DEVIATIONs for them (SQL Server masks the source aggregates as well)', true) end
if r.gr == 1 or r.gr == 2 then note('GRAPH TABLE', 'graph ' .. (r.gr == 1 and 'node' or 'edge') .. ' table ' .. r.label .. ' is migrated as a plain table: internal graph columns are skipped, the pseudo columns become NODE_ID / EDGE_ID / FROM_ID / TO_ID (JSON text with SQL Server object ids); MATCH queries are not migrated', false) end
if r.mo then note('MEMORY-OPTIMIZED', 'table ' .. r.label .. ' is memory-optimized and is read with WITH (SNAPSHOT)' .. (r.dur == 1 and ' - it is SCHEMA_ONLY (non-durable): its rows are lost at every SQL Server restart' or ''), false) end
if r.tt == 1 then note('HISTORY TABLE', 'table ' .. r.label .. ' is the system-versioning history table of ' .. oneline(r.hparent or 'unknown') .. '; it is migrated as a plain table (period columns are UTC)' .. ((r.tn:find('^MSSQL_TemporalHistoryFor_') or r.tn:find('^MSSQL_LedgerHistoryFor_')) and ' - its name contains the SQL Server object id: after the source table is re-created a re-run creates a NEW history table, drop the old one manually' or ''), false) end
if r.tt == 2 then note('HISTORY TABLE', 'table ' .. r.label .. ' is system-versioned; its period columns (UTC) are migrated as plain columns, the history table only when it matches the filters', false) end
if r.lt == 1 then note('LEDGER TABLE', 'table ' .. r.label .. ' is a ledger history table; it is migrated as a plain table' .. (r.tn:find('^MSSQL_LedgerHistoryFor_') and ' - its name contains the SQL Server object id: after the source table is re-created a re-run creates a NEW history table, drop the old one manually' or ''), false)
elseif r.lt == 2 or r.lt == 3 then note('LEDGER TABLE', 'ledger table ' .. r.label .. ' is migrated as a plain table including its hidden ledger columns; ledger verification is not migrated', false) end
if r.idis then
r.noimport = true
note('DISABLED INDEX', 'table ' .. r.label .. ' has a disabled clustered index (or columnstore index) and cannot be read in SQL Server - it is created empty, without IMPORT; rebuild the index and generate again', true)
end
if r.kind == 'V' then note('INDEXED VIEW', 'indexed view ' .. r.label .. ' is migrated as a table with its stored rows (read WITH (NOEXPAND)); Exasol does not maintain it', false) end
end
end
for _, v in ipairs(VIEWS) do
if v.def == nil then note('ENCRYPTED VIEW', 'the definition of view ' .. v.label .. ' is not available (WITH ENCRYPTION, or no VIEW DEFINITION permission) - it is listed without its T-SQL', false) end
if v.idx and not G_IV then note('INDEXED VIEW', 'indexed view ' .. v.label .. ' is not migrated (set MIGRATE_INDEXED_VIEWS = true to migrate its stored rows as a table)', false) end
end
-------------------------------------------------------------------------------------------------------------
-- columns
-------------------------------------------------------------------------------------------------------------
do
local types_w = G_IV and "('U', 'V')" or "('U')"
local cr = remote_all(function(db, B)
return 'select ' .. nl_(db) .. ' as DBN, c.object_id as OID, c.column_id as CID, c.name collate database_default as CN, ' ..
'case when ty.is_user_defined = 0 then ty.name else type_name(ty.system_type_id) end collate database_default as BT, ty.name collate database_default as TYN, ' ..
'cast(ty.is_assembly_type as int) as CLR, cast(ty.is_user_defined as int) as UD, cast(c.max_length as int) as ML, ' ..
'cast(c.precision as int) as PR, cast(c.scale as int) as SC, cast(c.is_nullable as int) as NUL, cast(c.is_identity as int) as IDN, ' ..
'cast(case when c.is_computed = 1 and not exists (select 1 from ' .. B .. '.sys.computed_columns cc where cc.object_id = c.object_id and cc.column_id = c.column_id and cc.is_persisted = 1) then 2 else c.is_computed end as int) as CMP, cast(c.is_column_set as int) as CSET, ' .. (GRAPH and 'cast(c.graph_type as int)' or 'cast(null as int)') .. ' as GT, ' ..
'cast(collationproperty(c.collation_name, ' .. sl('CodePage') .. ') as int) as CP, dc.definition collate database_default as DDEF, ' ..
'cast(case when c.default_object_id <> 0 and dc.object_id is null then 1 else 0 end as int) as BOUND, ' ..
'cast(has_perms_by_name(quotename(' .. nl_(db) .. ') + ' .. sl('.') .. ' + quotename(s.name) + ' .. sl('.') .. ' + quotename(o.name), ' .. sl('OBJECT') .. ', ' .. sl('SELECT') .. ', c.name, ' .. sl('COLUMN') .. ') as int) as SELP, ' ..
'cast(case when c.encryption_type is null then 0 else 1 end as int) as ENC ' ..
'from ' .. B .. '.sys.columns c join ' .. objfrom(B) .. ' on o.object_id = c.object_id ' ..
'join ' .. B .. '.sys.types ty on ty.user_type_id = c.user_type_id ' ..
'left join ' .. B .. '.sys.default_constraints dc on dc.object_id = c.default_object_id ' ..
'where o.type in ' .. types_w .. ' and ' .. OBJW
end, 't.*, upper(t."CN")')
for i = 1, #cr do
local x = cr[i]
local r = REL_BY[rkey(x[1], int(x[2]))]
if r then
r.cols[#r.cols + 1] = {num = int(x[3]), name = x[4], bt = str(x[5]), tyn = str(x[6]), clr = int(x[7]) == 1, ud = int(x[8]) == 1,
ml = int(x[9]), pr = int(x[10]), sc = int(x[11]), nullable = int(x[12]) == 1, identity = int(x[13]) == 1,
computed = int(x[14]) >= 1, volatile = int(x[14]) == 2, colset = int(x[15]) == 1, gt = int(x[16]), cp = int(x[17]),
def = str(x[18]), bound = int(x[19]) == 1, selp = int(x[20]), enc = int(x[21]) == 1, name_up = x[22]}
end
end
end
-------------------------------------------------------------------------------------------------------------
-- type mapping: Exasol type + T-SQL source expression per column
-------------------------------------------------------------------------------------------------------------
local KNOWN = {}
for _, t in ipairs({'bit', 'tinyint', 'smallint', 'int', 'bigint', 'decimal', 'numeric', 'money', 'smallmoney', 'float', 'real',
'char', 'varchar', 'text', 'nchar', 'nvarchar', 'ntext', 'sysname', 'uniqueidentifier', 'xml', 'json', 'vector',
'date', 'datetime', 'smalldatetime', 'datetime2', 'time', 'datetimeoffset', 'binary', 'varbinary', 'image',
'timestamp', 'rowversion', 'sql_variant', 'hierarchyid', 'geometry', 'geography'}) do KNOWN[t] = true end
local INTS = {tinyint = 3, smallint = 5, int = 10, bigint = 19}
local SC_COLL = ' collate Latin1_General_100_CI_AS_SC'
local function long_text(c)
-- (n)varchar(max)/text/ntext/xml/json/vector: optional cut at 2,000,000 characters (supplementary characters count once)
if TRUNC then return 'left(cast(' .. c .. ' as nvarchar(max))' .. SC_COLL .. ', 2000000)' end
return 'cast(' .. c .. ' as nvarchar(max))'
end
local function hexsrc(c) return 'convert(varchar(max), cast(' .. c .. ' as varbinary(max)), 2)' end
-- an invalid geometry that contains curves: Exasol GEOMETRY has no curves, so it is made valid and then linearised
local function geo_curved(c)
local w = 'cast(' .. c .. '.STAsText() as nvarchar(max)) collate Latin1_General_BIN2'
return '(' .. w .. ' like N' .. sl('%CURVE%') .. ' or ' .. w .. ' like N' .. sl('%CIRCULARSTRING%') .. ')'
end
local function oor_dto(c, n, name)
-- c: datetimeoffset(n) column; returns its UTC value as datetime2(n) (NULL passes) and applies TEMPORAL_OUT_OF_RANGE to
-- values outside 0001-01-02 .. 9999-12-30 UTC (values on the outermost day cannot be displayed in every session time
-- zone). The range test compares the datetimeoffset itself (it compares by its UTC instant); CONVERT style 1 converts
-- to UTC once and costs a fraction of AT TIME ZONE.
local ty, dt = 'datetime2(' .. istr(n) .. ')', 'datetimeoffset(' .. istr(n) .. ')'
local frac = (n > 0) and ('.' .. string.rep('9', n)) or ''
local e = 'convert(' .. ty .. ', ' .. c .. ', 1)'
local lo = 'cast(' .. sl('0001-01-02 00:00:00 +00:00') .. ' as ' .. dt .. ')'
local hi = 'cast(' .. sl('9999-12-30 23:59:59' .. frac .. ' +00:00') .. ' as ' .. dt .. ')'
if OORMODE == 'NULL' then
return 'case when ' .. c .. ' >= ' .. lo .. ' and ' .. c .. ' <= ' .. hi .. ' then ' .. e .. ' end'
elseif OORMODE == 'CLAMP' then
return 'case when ' .. c .. ' < ' .. lo .. ' then cast(' .. sl('0001-01-02 00:00:00') .. ' as ' .. ty .. ') when ' .. c .. ' > ' .. hi ..
' then cast(' .. sl('9999-12-30 23:59:59' .. frac) .. ' as ' .. ty .. ') else ' .. e .. ' end'
end
-- FAIL: the source query fails with a message that names the column and the value (int conversion of a text with both)
return 'case when ' .. c .. ' is null or (' .. c .. ' >= ' .. lo .. ' and ' .. c .. ' <= ' .. hi .. ') then ' .. e ..
' else dateadd(day, cast(N' .. sl('TEMPORAL_OUT_OF_RANGE column ') .. ' + ' .. nl_(usub(oneline(name), 128)) .. ' + N' .. sl(' value ') ..
' + convert(nvarchar(34), ' .. c .. ', 121) as int), cast(null as ' .. ty .. ')) end'
end
local function map_col(col)
-- returns {typ, src, cls, ...}; cls: dec, dbl, date, ts, tsz, char, text, time, uuid, hash, hex, geo, numtext
local c = qb(col.name)
local t = col.bt
local m = {cls = 'text'}
if (col.ud and col.clr) or t == nil or not KNOWN[t] then m.unsupported = true return m end
local utf8 = (col.cp == 65001)
if t == 'bit' then
m.typ = 'DECIMAL(1,0)'; m.cls = 'dec'; m.p = 1; m.s = 0; m.exact = true; m.distinct = true; m.src = c
m.chk = 'cast(' .. c .. ' as tinyint)'; m.bit = true
elseif INTS[t] then
m.typ = 'DECIMAL(' .. istr(INTS[t]) .. ',0)'; m.cls = 'dec'; m.p = INTS[t]; m.s = 0; m.exact = true; m.distinct = true; m.src = c
elseif t == 'decimal' or t == 'numeric' then
local p, s = col.pr, col.sc
if p <= 36 then
m.typ = 'DECIMAL(' .. istr(p) .. ',' .. istr(s) .. ')'; m.cls = 'dec'; m.p = p; m.s = s; m.exact = true; m.distinct = true; m.src = c
elseif DECOF == 'DOUBLE' then
m.typ = 'DOUBLE'; m.cls = 'dbl'; m.src = 'cast(' .. c .. ' as float)'
elseif DECOF == 'VARCHAR' then
m.typ = 'VARCHAR(41) ASCII'; m.cls = 'numtext'; m.src = 'convert(varchar(41), ' .. c .. ')'
else
-- keep the scale (decimal(38,2) -> DECIMAL(36,2), as common in SQL Server); a scale above 35 is rounded to 35 so that
-- at least one integer digit remains; values with more integer digits than 36 - scale make the IMPORT fail loudly
local cs = math.min(s, 35)
m.typ = 'DECIMAL(36,' .. istr(cs) .. ')'; m.cls = 'dec'; m.p = 36; m.s = cs; m.exact = true; m.narrowed = p - s > 36 - cs
if cs < s then m.capped = true; m.src = 'cast(' .. c .. ' as decimal(38,' .. istr(cs) .. '))' else m.src = c; m.distinct = true end
end
elseif t == 'money' or t == 'smallmoney' then
m.typ = (t == 'money') and 'DECIMAL(19,4)' or 'DECIMAL(10,4)'; m.cls = 'dec'; m.p = (t == 'money') and 19 or 10; m.s = 4
m.exact = true; m.distinct = true; m.src = c
elseif t == 'float' or t == 'real' then
m.typ = 'DOUBLE'; m.cls = 'dbl'; m.src = c
elseif t == 'char' then
local n = col.ml
m.typ = (n <= 2000) and ('CHAR(' .. istr(n) .. ') UTF8') or ('VARCHAR(' .. istr(n) .. ') UTF8'); m.cls = (n <= 2000) and 'char' or 'text'
m.src = utf8 and c or ((n <= 4000) and ('cast(' .. c .. ' as nchar(' .. istr(n) .. '))') or ('cast(' .. c .. ' as nvarchar(max))'))
m.plain_text = (n > 2000)
elseif t == 'varchar' then
if col.ml < 0 then
m.typ = 'VARCHAR(2000000) UTF8'; m.src = (utf8 and not TRUNC) and c or long_text(c)
else
m.typ = 'VARCHAR(' .. istr(col.ml) .. ') UTF8'
m.src = utf8 and c or ((col.ml <= 4000) and ('cast(' .. c .. ' as nvarchar(' .. istr(col.ml) .. '))') or ('cast(' .. c .. ' as nvarchar(max))'))
end
m.plain_text = true
elseif t == 'text' or t == 'ntext' then
m.typ = 'VARCHAR(2000000) UTF8'; m.src = long_text(c); m.plain_text = true
elseif t == 'nchar' then
local n = math.floor(col.ml / 2)
m.typ = (n <= 2000) and ('CHAR(' .. istr(n) .. ') UTF8') or ('VARCHAR(' .. istr(n) .. ') UTF8'); m.cls = (n <= 2000) and 'char' or 'text'
m.src = c; m.plain_text = (n > 2000)
elseif t == 'nvarchar' then
if col.ml < 0 then m.typ = 'VARCHAR(2000000) UTF8'; m.src = TRUNC and long_text(c) or c
else m.typ = 'VARCHAR(' .. istr(math.floor(col.ml / 2)) .. ') UTF8'; m.src = c end
m.plain_text = true
elseif t == 'sysname' then
m.typ = 'VARCHAR(128) UTF8'; m.src = c; m.plain_text = true
elseif t == 'uniqueidentifier' then
m.typ = 'CHAR(36) ASCII'; m.cls = 'uuid'; m.distinct = true; m.src = c
elseif t == 'xml' or t == 'json' or t == 'vector' then
m.typ = 'VARCHAR(2000000) UTF8'; m.src = long_text(c); m.structured = true
elseif t == 'sql_variant' then
local bt = 'cast(sql_variant_property(' .. c .. ', ' .. sl('BaseType') .. ') as varchar(30)) collate Latin1_General_BIN2'
m.typ = 'VARCHAR(16100) UTF8'; m.plain_text = true
m.src = 'case ' .. bt ..
' when ' .. sl('float') .. ' then convert(nvarchar(30), cast(' .. c .. ' as float), 3)' ..
' when ' .. sl('real') .. ' then convert(nvarchar(30), cast(' .. c .. ' as real), 3)' ..
' when ' .. sl('datetime') .. ' then convert(nvarchar(23), cast(' .. c .. ' as datetime), 121)' ..
' when ' .. sl('smalldatetime') .. ' then convert(nvarchar(19), cast(' .. c .. ' as smalldatetime), 120)' ..
' when ' .. sl('datetime2') .. ' then convert(nvarchar(27), cast(' .. c .. ' as datetime2(7)), 121)' ..
' when ' .. sl('date') .. ' then convert(nvarchar(10), cast(' .. c .. ' as date), 23)' ..
' when ' .. sl('time') .. ' then cast(cast(' .. c .. ' as time(7)) as nvarchar(16))' ..
' when ' .. sl('datetimeoffset') .. ' then convert(nvarchar(34), cast(' .. c .. ' as datetimeoffset(7)), 121)' ..
' when ' .. sl('money') .. ' then convert(nvarchar(30), cast(' .. c .. ' as money), 2)' ..
' when ' .. sl('smallmoney') .. ' then convert(nvarchar(30), cast(' .. c .. ' as smallmoney), 2)' ..
' when ' .. sl('binary') .. ' then convert(varchar(max), cast(' .. c .. ' as varbinary(8000)), 2)' ..
' when ' .. sl('varbinary') .. ' then convert(varchar(max), cast(' .. c .. ' as varbinary(8000)), 2)' ..
' when ' .. sl('varchar') .. ' then cast(cast(' .. c .. ' as varchar(8000)) as nvarchar(max))' ..
' when ' .. sl('char') .. ' then cast(cast(' .. c .. ' as varchar(8000)) as nvarchar(max))' ..
' else cast(' .. c .. ' as nvarchar(4000)) end'
elseif t == 'date' then
m.typ = 'DATE'; m.cls = 'date'; m.distinct = true; m.src = c
elseif t == 'datetime' then
m.typ = 'TIMESTAMP(3)'; m.cls = 'ts'; m.tsp = 3; m.distinct = true; m.src = c
elseif t == 'smalldatetime' then
m.typ = 'TIMESTAMP(0)'; m.cls = 'ts'; m.tsp = 0; m.distinct = true; m.src = c; m.small = true
elseif t == 'datetime2' then
m.typ = 'TIMESTAMP(' .. istr(col.sc) .. ')'; m.cls = 'ts'; m.tsp = col.sc; m.distinct = true; m.src = c
elseif t == 'datetimeoffset' then
m.typ = 'TIMESTAMP(' .. istr(col.sc) .. ') WITH LOCAL TIME ZONE'; m.cls = 'tsz'; m.tsp = col.sc
m.src = oor_dto(c, col.sc, col.name)
elseif t == 'time' then
m.typ = 'VARCHAR(16) ASCII'; m.cls = 'time'; m.tsp = col.sc; m.src = 'cast(' .. c .. ' as varchar(16))'
elseif t == 'binary' or t == 'varbinary' or t == 'image' or t == 'timestamp' or t == 'rowversion' then
if BINMODE == 'SKIP' then m.skipped = true; m.typ = '-'; m.src = 'null'; return m end
local n = col.ml
if t == 'timestamp' or t == 'rowversion' then
m.src = 'convert(varchar(16), cast(' .. c .. ' as varbinary(8)), 2)'
if BINMODE == 'HASHTYPE' then m.typ = 'HASHTYPE(8 BYTE)'; m.cls = 'hash' else m.typ = 'VARCHAR(16) ASCII'; m.cls = 'hex' end
elseif t == 'binary' and BINMODE == 'HASHTYPE' and n <= 1024 then
m.typ = 'HASHTYPE(' .. istr(n) .. ' BYTE)'; m.cls = 'hash'; m.src = hexsrc(c)
else
local len = (t == 'image' or n < 0 or 2 * n > 2000000) and 2000000 or 2 * n
m.typ = 'VARCHAR(' .. istr(len) .. ') ASCII'; m.cls = 'hex'; m.src = hexsrc(c)
end
elseif t == 'hierarchyid' then
m.typ = 'VARCHAR(4000) UTF8'; m.src = c .. '.ToString()'
elseif t == 'geometry' then
-- curves become line approximations; STCurveToLine fails on invalid geometries (error 24144) and turns POINT EMPTY
-- into GEOMETRYCOLLECTION EMPTY - those keep their plain WKT
m.typ = 'GEOMETRY'; m.cls = 'geo'
m.src = 'case when ' .. c .. '.STIsValid() = 1 and ' .. c .. '.STIsEmpty() = 0 then ' .. c .. '.STCurveToLine().STAsText() when ' .. geo_curved(c) ..
' then ' .. c .. '.MakeValid().STCurveToLine().STAsText() else ' .. c .. '.STAsText() end'
elseif t == 'geography' then
m.typ = 'GEOMETRY'; m.cls = 'geo'
m.src = 'case when cast(' .. c .. '.STGeometryType() as nvarchar(30)) collate Latin1_General_BIN2 = ' .. sl('FullGlobe') .. ' then null when ' ..
c .. '.STIsValid() = 1 and ' .. c .. '.STIsEmpty() = 0 then ' .. c .. '.STCurveToLine().STAsText() when ' .. geo_curved(c) ..
' then ' .. c .. '.MakeValid().STCurveToLine().STAsText() else ' .. c .. '.STAsText() end'
else
m.unsupported = true
end
return m
end
-------------------------------------------------------------------------------------------------------------
-- DEFAULT mapping (validated per mapped type; returns ' DEFAULT ...', or nil + reason)
-------------------------------------------------------------------------------------------------------------
local QM = string.char(63) -- question mark (kept out of the script text: DbVisualizer parameter marker)
local COLON = string.char(58) -- colon (a colon before a quote is a DbVisualizer parameter marker too)
local function unwrap(d)
d = trim(d)
while d:sub(1, 1) == '(' and d:sub(-1) == ')' do
local depth, ok, inq = 0, true, false
for i = 1, #d do
local ch = d:sub(i, i)
if ch == SQ then inq = not inq
elseif not inq then
if ch == '(' then depth = depth + 1
elseif ch == ')' then depth = depth - 1; if depth == 0 and i < #d then ok = false break end end
end
end
if not ok then break end
d = trim(d:sub(2, -2))
end
local inner = d:match('^%-%((.*)%)$')
if inner then return '-' .. unwrap(inner) end
return d
end
local function civil(days) -- days since 1970-01-01 -> year, month, day (proleptic Gregorian)
local z = days + 719468
local era = (z >= 0 and z or z - 146096) // 146097
local doe = z - era * 146097
local yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
local y = yoe + era * 400
local doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
local mp = (5 * doy + 2) // 153
local d = doy - (153 * mp + 2) // 5 + 1
local mo = (mp < 10) and (mp + 3) or (mp - 9)
if mo <= 2 then y = y + 1 end
return y, mo, d
end
local function strlit(v)
local s = v:match('^N' .. SQ .. '(.*)' .. SQ .. '$') or v:match('^' .. SQ .. '(.*)' .. SQ .. '$')
if s == nil or (s:gsub(SQ .. SQ, '')):find(SQ) then return nil end
return (s:gsub(SQ .. SQ, SQ))
end
local function numparts(v)
-- decimal literal -> sign, integer digits, fraction digits (nil if not a plain decimal literal)
v = v:gsub('^%$', '')
local sg, ip, fp = v:match('^([%+%-]' .. QM .. ')(%d*)%.' .. QM .. '(%d*)$')
if sg == nil or (ip == '' and fp == '') then return nil end
if not v:find('^[%+%-]' .. QM .. '%d*%.' .. QM .. '%d*$') then return nil end
return (sg == '-') and '-' or '', ip, fp
end
local function round_dec(sg, ip, fp, s)
-- round half away from zero to s fractional digits (string arithmetic); returns sign, int digits, frac digits
ip = (ip == '') and '0' or ip
if #fp <= s then return sg, ip, fp .. string.rep('0', s - #fp) end
local keep, nxt = fp:sub(1, s), tonumber(fp:sub(s + 1, s + 1))
local digits = ip .. keep
if nxt >= 5 then
local t, carry = {}, 1
for i = #digits, 1, -1 do
local dd = tonumber(digits:sub(i, i)) + carry
carry = (dd >= 10) and 1 or 0
t[i] = istr(dd % 10)
end
digits = (carry == 1 and '1' or '') .. table.concat(t)
end
return sg, digits:sub(1, #digits - s), digits:sub(#digits - s + 1)
end
local NOW = {['GETDATE()'] = 'L', ['SYSDATETIME()'] = 'L', ['CURRENT_TIMESTAMP'] = 'L', ['GETUTCDATE()'] = 'U', ['SYSUTCDATETIME()'] = 'U', ['SYSDATETIMEOFFSET()'] = 'O'}
local function parse_ts(s)
local y, mo, d, rest = s:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)(.*)$')
if not y then y, mo, d, rest = s:match('^(%d%d%d%d)(%d%d)(%d%d)(.*)$') end
if not y or tonumber(mo) < 1 or tonumber(mo) > 12 or tonumber(d) < 1 then return nil end
local yy, mm = tonumber(y), tonumber(mo)
local mdays = ({31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31})[mm]
if mm == 2 and ((yy % 4 == 0 and yy % 100 ~= 0) or yy % 400 == 0) then mdays = 29 end
if tonumber(d) > mdays then return nil end
local hh, mi, ss, fr = '00', '00', '00', ''
if rest ~= '' then
local h2, m2, r2 = rest:match('^[T ](%d%d)' .. COLON .. '(%d%d)(.*)$')
if not h2 then return nil end
hh, mi = h2, m2
if r2 ~= '' then
local s2, f2 = r2:match('^' .. COLON .. '(%d%d)%.' .. QM .. '(%d*)$')
if not s2 then return nil end
ss, fr = s2, f2
end
end
return y .. '-' .. mo .. '-' .. d, hh .. COLON .. mi .. COLON .. ss, fr
end
local function map_default(col, m)
if col.identity or col.computed then return '' end
if col.bound then return nil, '(bound default object, sp_bindefault)' end
local d = col.def
if d == nil or d == '' then return '' end
if d:find(string.char(0), 1, true) then return nil, d end
local v = unwrap(d)
if v:upper() == 'NULL' then return '' end
local fn = NOW[v:upper():gsub('%s', '')]
local s = strlit(v)
local cls = m.cls
if cls == 'dec' then
local lit = s or v
local sg, ip, fp = numparts(lit)
if not sg and m.bit and s and (s:upper() == 'TRUE' or s:upper() == 'FALSE') then return (s:upper() == 'TRUE') and ' DEFAULT 1' or ' DEFAULT 0' end
if not sg then
local e = tonumber(lit)
if e and lit:find('[eE]') and math.abs(e) < 2^53 then
e = (e < 0) and -math.floor(-e) or math.floor(e)
if m.s == 0 then sg, ip, fp = (e < 0) and '-' or '', istr(math.abs(e)), '' end
end
end
if not sg then return nil, d end
if m.bit then
local nz = (ip .. fp):find('[1-9]') ~= nil
return nz and ' DEFAULT 1' or ' DEFAULT 0'
end
if INTS[col.bt] then fp = '' end -- SQL Server truncates a numeric literal for integer columns (decimal rounds)
local rs, ri, rf = round_dec(sg, ip, fp, m.s)
ri = ri:gsub('^0+(%d)', '%1')
if #ri:gsub('^0+', '') > m.p - m.s then return nil, d end
local IRANGE = {tinyint = {0, 255}, smallint = {-32768, 32767}, int = {-2147483648, 2147483647}, bigint = {-9.2233720368547758e18, 9.2233720368547758e18}}
if IRANGE[col.bt] then
local nv = tonumber(((rs == '-') and '-' or '') .. ri)
if nv == nil or nv < IRANGE[col.bt][1] or nv > IRANGE[col.bt][2] then return nil, d end
end
return ' DEFAULT ' .. ((ri .. rf):find('[1-9]') and rs or '') .. ri .. ((m.s > 0) and ('.' .. rf) or '')
elseif cls == 'dbl' then
local lit = s or v
if tonumber(lit) and lit:find('^[%+%-]' .. QM .. '[%d%.]+[eE]' .. QM .. '[%+%-]' .. QM .. '%d*$') then return ' DEFAULT ' .. lit end
return nil, d
elseif cls == 'date' then
if fn == 'L' then return ' DEFAULT CURRENT_DATE' end
if s then local dd = parse_ts(s); if dd then return ' DEFAULT DATE ' .. sl(dd) end end
return nil, d
elseif cls == 'ts' then
if fn == 'L' or fn == 'O' then return ' DEFAULT CURRENT_TIMESTAMP' end
if fn == 'U' then return ' DEFAULT CONVERT_TZ(CURRENT_TIMESTAMP, SESSIONTIMEZONE, ' .. sl('UTC') .. ')' end
if s then
local dd, tt, fr = parse_ts(s)
if dd then
if m.tsp and m.tsp > 0 and fr ~= '' then tt = tt .. '.' .. fr:sub(1, m.tsp) end
return ' DEFAULT TIMESTAMP ' .. sl(dd .. ' ' .. tt)
end
elseif col.bt == 'datetime' or col.bt == 'smalldatetime' then
local n = v:match('^%-' .. QM .. '%d+$') and tonumber(v)
if n and math.abs(n) < 3000000 then
local y, mo, dd = civil(-25567 + n) -- datetime 0 = 1900-01-01
if y >= 1753 and y <= 9999 then return ' DEFAULT TIMESTAMP ' .. sl(string.format('%04d-%02d-%02d 00:00:00', y, mo, dd)) end
end
end
return nil, d
elseif cls == 'tsz' then
if fn then return ' DEFAULT CURRENT_TIMESTAMP' end
return nil, d
elseif cls == 'time' then
if s then
local hh, mi, rest = s:match('^(%d%d' .. QM .. ')' .. COLON .. '(%d%d)(.*)$')
if hh then
local ss, fr = '00', ''
if rest ~= '' then ss, fr = rest:match('^' .. COLON .. '(%d%d)%.' .. QM .. '(%d*)$') end