mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
Compare commits
963
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f54385b97 | ||
|
|
cb9f87031f | ||
|
|
5c738f5bf2 | ||
|
|
038ff4e773 | ||
|
|
9c56de2e9f | ||
|
|
ef87e45cbc | ||
|
|
f83f707fbc | ||
|
|
7a4edb6c19 | ||
|
|
b4787a63af | ||
|
|
fd17037a2a | ||
|
|
41db375ee6 | ||
|
|
1498b27314 | ||
|
|
6d8e667bed | ||
|
|
df88d7baa7 | ||
|
|
659fb7f330 | ||
|
|
c237ecbfca | ||
|
|
bcc64274e8 | ||
|
|
802019daa3 | ||
|
|
60bfb41f13 | ||
|
|
17e2573fbe | ||
|
|
2467d212d5 | ||
|
|
80224ab1d9 | ||
|
|
89086cc22b | ||
|
|
fdfad88590 | ||
|
|
d3a97164aa | ||
|
|
d4245f0d58 | ||
|
|
2c5f926bd8 | ||
|
|
20a2994fa1 | ||
|
|
7ad05b178f | ||
|
|
be307ba2d7 | ||
|
|
10bce698ae | ||
|
|
c0abc26fe8 | ||
|
|
425ca055ea | ||
|
|
99a9961712 | ||
|
|
d8ec9bb8d0 | ||
|
|
4636788f49 | ||
|
|
22305b7d0f | ||
|
|
71d07348ad | ||
|
|
b198dd1327 | ||
|
|
9290391985 | ||
|
|
1016ccc352 | ||
|
|
ec0b6768be | ||
|
|
b09c6f4f21 | ||
|
|
74ffab29ca | ||
|
|
e996f8aefb | ||
|
|
4fed29fba2 | ||
|
|
214bf86a6f | ||
|
|
955558c81f | ||
|
|
7933b7656f | ||
|
|
e5572ead7a | ||
|
|
bd6c780aba | ||
|
|
dfd4d3b7ff | ||
|
|
ab4e747bf9 | ||
|
|
9e4fe8ce56 | ||
|
|
6a6e5fbad9 | ||
|
|
e8d6370b1e | ||
|
|
4eeeafa858 | ||
|
|
e4dc5bace8 | ||
|
|
920ab62cf1 | ||
|
|
36a7b7bd3a | ||
|
|
565feb8745 | ||
|
|
40549c9573 | ||
|
|
eb0994e980 | ||
|
|
cf03de122a | ||
|
|
d9beaaaa1c | ||
|
|
5cca5e0b86 | ||
|
|
6e644c0eea | ||
|
|
f3effbc76b | ||
|
|
81172688b8 | ||
|
|
b5bac9cafc | ||
|
|
704f817b4d | ||
|
|
e6d25b016e | ||
|
|
d70a1231b0 | ||
|
|
185db5e8c6 | ||
|
|
bd05c0350d | ||
|
|
d2326acb81 | ||
|
|
8bc31d3319 | ||
|
|
ba8df70fe6 | ||
|
|
afcfa59d0b | ||
|
|
4a3d63b09a | ||
|
|
caaa8932a1 | ||
|
|
f41b5dbdf6 | ||
|
|
e7191b1c4b | ||
|
|
2138f59da4 | ||
|
|
2711646d48 | ||
|
|
3d77966f52 | ||
|
|
62f2185f44 | ||
|
|
699043ff07 | ||
|
|
6875ca4000 | ||
|
|
168e7bcb54 | ||
|
|
ff88516235 | ||
|
|
aac09d3cb7 | ||
|
|
174f786483 | ||
|
|
4b454b72b9 | ||
|
|
ac2af84e7b | ||
|
|
8333c78320 | ||
|
|
3a0e86956c | ||
|
|
96d2f0dd1c | ||
|
|
bf09181ba5 | ||
|
|
e93b41a4ff | ||
|
|
589fbd9de8 | ||
|
|
cb4cc43685 | ||
|
|
8382d85e19 | ||
|
|
10df1b07d1 | ||
|
|
7231c41618 | ||
|
|
2e739d5902 | ||
|
|
c00003e4d7 | ||
|
|
a33f7d3c56 | ||
|
|
0739048425 | ||
|
|
d06f62f9f4 | ||
|
|
71344c637c | ||
|
|
138e48d347 | ||
|
|
0588a67449 | ||
|
|
25ea7b21eb | ||
|
|
fb907a5866 | ||
|
|
affcb7fa7e | ||
|
|
d15605cee9 | ||
|
|
95537957cc | ||
|
|
c153ca8f57 | ||
|
|
b5124baca9 | ||
|
|
4b4b4ac2dd | ||
|
|
83a6ad005b | ||
|
|
b77328f03e | ||
|
|
d85816c3ed | ||
|
|
f12ac21453 | ||
|
|
bc18bbfe5c | ||
|
|
8c735206e0 | ||
|
|
8475c84d48 | ||
|
|
6686d1628e | ||
|
|
1e9babca45 | ||
|
|
0f9d32e1f5 | ||
|
|
75c2dc5e17 | ||
|
|
eeeb4e5011 | ||
|
|
b37a1f8538 | ||
|
|
4c28492f2c | ||
|
|
ca0d4356c0 | ||
|
|
ef22b84fbe | ||
|
|
a4bf8442d7 | ||
|
|
2b7d353313 | ||
|
|
5b61f17c49 | ||
|
|
c8c9870dba | ||
|
|
43289e2ce1 | ||
|
|
60e3d61934 | ||
|
|
2e8673a7ea | ||
|
|
a6c386a067 | ||
|
|
68eae4ad4d | ||
|
|
8db66db8ca | ||
|
|
f1578a6e9e | ||
|
|
dc10323b7c | ||
|
|
8bcbfd7ae7 | ||
|
|
43c896053b | ||
|
|
084f872a80 | ||
|
|
8e47296e02 | ||
|
|
706334434a | ||
|
|
d362c8efb8 | ||
|
|
771a7f03a0 | ||
|
|
2a2357e2f1 | ||
|
|
1c2e5bb07c | ||
|
|
a9c6c7b5e4 | ||
|
|
1ed20e424e | ||
|
|
0dc1f0c14c | ||
|
|
0658d18922 | ||
|
|
aa91a0aaa9 | ||
|
|
cb581baf20 | ||
|
|
19f8736b67 | ||
|
|
5e307f56cc | ||
|
|
bfd7c7def2 | ||
|
|
46111850db | ||
|
|
86f36e51ad | ||
|
|
c6e9e94c58 | ||
|
|
f9c4b19a60 | ||
|
|
a66590868e | ||
|
|
7494cf8011 | ||
|
|
18ee40bb5d | ||
|
|
ef9d0b8042 | ||
|
|
988fa1aabb | ||
|
|
9b4f94cbb5 | ||
|
|
151172f81a | ||
|
|
51f7b1d8b5 | ||
|
|
c27246ca01 | ||
|
|
8d2e627efe | ||
|
|
5c29af80ec | ||
|
|
013fb011e5 | ||
|
|
79af004b6e | ||
|
|
eae1fa0c75 | ||
|
|
33c0cf2050 | ||
|
|
dfbdcfc04f | ||
|
|
81c3cc15d2 | ||
|
|
88e659833d | ||
|
|
e9fea304ba | ||
|
|
343d006300 | ||
|
|
9b8721e0e8 | ||
|
|
876f850e2d | ||
|
|
249896dddf | ||
|
|
7dfd0fdd5a | ||
|
|
316a1f2a7c | ||
|
|
b488942d82 | ||
|
|
d1d8dc4aec | ||
|
|
cf652f212e | ||
|
|
e977e886c3 | ||
|
|
eccbc5a315 | ||
|
|
4eabeb6a15 | ||
|
|
9c90edabe3 | ||
|
|
e7ef9df85c | ||
|
|
7c8e0a88cf | ||
|
|
08365d73eb | ||
|
|
4be1928d15 | ||
|
|
2cf79b3c9f | ||
|
|
0aa18257e7 | ||
|
|
418951c1e2 | ||
|
|
7686d7dc13 | ||
|
|
a2f8824e6a | ||
|
|
277386ec81 | ||
|
|
44dc2a88f8 | ||
|
|
2f464e0d33 | ||
|
|
a6edbd7fcb | ||
|
|
efba56c8c0 | ||
|
|
b3e8709272 | ||
|
|
e2fa810f2e | ||
|
|
f621e40655 | ||
|
|
cd18b7a3db | ||
|
|
5c75c2391c | ||
|
|
68b0ab0476 | ||
|
|
3232195e1f | ||
|
|
9af98c2db2 | ||
|
|
adb7578998 | ||
|
|
332daba302 | ||
|
|
492c9727ae | ||
|
|
6b5b33540f | ||
|
|
9e135f7609 | ||
|
|
2305f2de22 | ||
|
|
1d3d6c8bb1 | ||
|
|
d9674029f3 | ||
|
|
7904231566 | ||
|
|
992ac00aff | ||
|
|
6baf17f609 | ||
|
|
ba25a871fd | ||
|
|
255faf0e4c | ||
|
|
8d23216d60 | ||
|
|
f45fcdb69d | ||
|
|
566bc87014 | ||
|
|
d8e3d041cc | ||
|
|
0b4159372e | ||
|
|
e0ffbe8992 | ||
|
|
ba60ffb954 | ||
|
|
ff31f44b42 | ||
|
|
697a8efe6e | ||
|
|
ae3b991bd5 | ||
|
|
a62a51b922 | ||
|
|
d6bb83cad7 | ||
|
|
6cf8d8e035 | ||
|
|
d29c2d85cb | ||
|
|
e1a4242729 | ||
|
|
7a098a276a | ||
|
|
57b350eb49 | ||
|
|
707296c71d | ||
|
|
ff4667b93f | ||
|
|
5bafb68919 | ||
|
|
4021216155 | ||
|
|
4a478acf07 | ||
|
|
eaad88cc80 | ||
|
|
c1888a2364 | ||
|
|
bdba8ace7d | ||
|
|
3f960f51ab | ||
|
|
d7c81195d9 | ||
|
|
0aa1fb9d7e | ||
|
|
357fcbbcf7 | ||
|
|
332ce7405d | ||
|
|
781cf01c78 | ||
|
|
c021fe009b | ||
|
|
c8dbeeaf7d | ||
|
|
0efce9e9a9 | ||
|
|
63c19a9230 | ||
|
|
05d8e1333e | ||
|
|
a8369390df | ||
|
|
917bbc1238 | ||
|
|
d7adff54b7 | ||
|
|
8cc924270c | ||
|
|
4d7d4ab74e | ||
|
|
8187ef9baf | ||
|
|
2f47a23c4c | ||
|
|
bd9f10cb5a | ||
|
|
29b527fd3a | ||
|
|
80240d0ba6 | ||
|
|
4c938328b7 | ||
|
|
c0665d69f3 | ||
|
|
2124cccef7 | ||
|
|
c462272d8e | ||
|
|
7e1e9053f8 | ||
|
|
e03cc2d269 | ||
|
|
0047d79974 | ||
|
|
c1e21c7261 | ||
|
|
55c2b63c70 | ||
|
|
497f070254 | ||
|
|
3e9c383732 | ||
|
|
e0be55ddf3 | ||
|
|
e11a0d467d | ||
|
|
3ece54d07e | ||
|
|
72bb880ee7 | ||
|
|
e1eb44f626 | ||
|
|
e1f48bdd60 | ||
|
|
b4b04d30b8 | ||
|
|
e15ade130d | ||
|
|
2927e01591 | ||
|
|
add44cf39e | ||
|
|
351dc81a30 | ||
|
|
6a06a12455 | ||
|
|
d616e6b1a8 | ||
|
|
493e18755d | ||
|
|
6e189723bd | ||
|
|
4dd107484f | ||
|
|
0827478078 | ||
|
|
38664e6aea | ||
|
|
726f86e24c | ||
|
|
d8bc5153d2 | ||
|
|
701f188e45 | ||
|
|
4e09b007c0 | ||
|
|
bc19d25ccd | ||
|
|
3b66e88356 | ||
|
|
1db4148a93 | ||
|
|
e55ee6054d | ||
|
|
93dc455c72 | ||
|
|
9815e5ac81 | ||
|
|
096e0ac409 | ||
|
|
c4630540dd | ||
|
|
0908a944e8 | ||
|
|
acc2137cde | ||
|
|
4a1f3e7141 | ||
|
|
b74d49e982 | ||
|
|
3dbcc2d0dc | ||
|
|
c9f184ac5d | ||
|
|
a1a4415425 | ||
|
|
9f638f9c79 | ||
|
|
3bbcab3bab | ||
|
|
a1e94d8228 | ||
|
|
946677a579 | ||
|
|
bf029cd342 | ||
|
|
3118e0d617 | ||
|
|
904efff6b3 | ||
|
|
36c9500a4e | ||
|
|
2fee435b33 | ||
|
|
906ea8ba26 | ||
|
|
53b7be3c6f | ||
|
|
de99931c04 | ||
|
|
3f1544d45e | ||
|
|
756d4aedc9 | ||
|
|
389faff8ee | ||
|
|
0559f9f76e | ||
|
|
9774a194ec | ||
|
|
6304335fcc | ||
|
|
82e5756325 | ||
|
|
6eb8b43701 | ||
|
|
3442acd52c | ||
|
|
fe269a28db | ||
|
|
b2cf487734 | ||
|
|
33dfaf957f | ||
|
|
a0c9a1c878 | ||
|
|
f274a599ed | ||
|
|
11179676ed | ||
|
|
a6f4cebd18 | ||
|
|
fcb6ea92db | ||
|
|
6aaac7133f | ||
|
|
3858cf1e59 | ||
|
|
edffa6706d | ||
|
|
e8c759e314 | ||
|
|
a2c9317c8b | ||
|
|
355f34f3c7 | ||
|
|
f3d2b6cb01 | ||
|
|
62657446f3 | ||
|
|
d33161bb71 | ||
|
|
5ca99c632b | ||
|
|
621074beca | ||
|
|
509d28c69a | ||
|
|
b015cfeef5 | ||
|
|
da49e03048 | ||
|
|
bb90c5febc | ||
|
|
3bec5091df | ||
|
|
f57cd3960b | ||
|
|
37ca4c31ad | ||
|
|
7d5cae6486 | ||
|
|
8671a2fb2a | ||
|
|
47e02b8da7 | ||
|
|
e50bd26141 | ||
|
|
31a50fb460 | ||
|
|
52c892676c | ||
|
|
4b75334164 | ||
|
|
68acf9f132 | ||
|
|
2bb85dcdc9 | ||
|
|
00b641aaec | ||
|
|
102f24798c | ||
|
|
49296c8930 | ||
|
|
0daa23ed8f | ||
|
|
7d6a7c2321 | ||
|
|
81e174e332 | ||
|
|
655cd12d9d | ||
|
|
dbe653d3d2 | ||
|
|
2d321b3537 | ||
|
|
0ba2021181 | ||
|
|
09b326558f | ||
|
|
cc78d4f528 | ||
|
|
4a22889b17 | ||
|
|
4bf7551b82 | ||
|
|
b09fd35cd8 | ||
|
|
25d5b885ba | ||
|
|
c210efbcc5 | ||
|
|
ec50662793 | ||
|
|
02402de53f | ||
|
|
ab0ff28fe4 | ||
|
|
12da615daa | ||
|
|
f2e13922a2 | ||
|
|
e139f63953 | ||
|
|
5792ce1244 | ||
|
|
68179d16f5 | ||
|
|
4c13e21cca | ||
|
|
7fa94152a8 | ||
|
|
30f75d8388 | ||
|
|
b5d3343f9a | ||
|
|
a7edb8f8db | ||
|
|
10d1509d92 | ||
|
|
90a18a7b23 | ||
|
|
5767a0fd59 | ||
|
|
69e5e81071 | ||
|
|
b4015a4393 | ||
|
|
7ce992b8f8 | ||
|
|
81872165da | ||
|
|
68d2919219 | ||
|
|
b0448da0cc | ||
|
|
c030835acf | ||
|
|
be9f8e72e4 | ||
|
|
8a9b02fa07 | ||
|
|
751894d224 | ||
|
|
6aee39e884 | ||
|
|
e8e980c25c | ||
|
|
8031a9e651 | ||
|
|
1a3f20f6c3 | ||
|
|
e2a4b26379 | ||
|
|
b3acf1e558 | ||
|
|
d2e2270d84 | ||
|
|
5741bc1256 | ||
|
|
ba0f864234 | ||
|
|
439f2c2617 | ||
|
|
db8f262c14 | ||
|
|
3132b17323 | ||
|
|
345cd3f8b3 | ||
|
|
a6a76640e0 | ||
|
|
321a8d03af | ||
|
|
501990b29b | ||
|
|
f494f90c30 | ||
|
|
2cc8d78e64 | ||
|
|
11407b537f | ||
|
|
0e0edcd206 | ||
|
|
e4577abeed | ||
|
|
89e0f1734b | ||
|
|
dd77817c7e | ||
|
|
90af026193 | ||
|
|
50607826d0 | ||
|
|
dcbd034afa | ||
|
|
d53c855b96 | ||
|
|
be1785d4fa | ||
|
|
5a921b3b76 | ||
|
|
12a2dd07f7 | ||
|
|
dc74d2a77f | ||
|
|
8b0fd8d7d8 | ||
|
|
3f939e0458 | ||
|
|
fc67a323c4 | ||
|
|
f3cc8b7a01 | ||
|
|
e90dff7be8 | ||
|
|
5014100554 | ||
|
|
a6d03f3577 | ||
|
|
6f723a3b1c | ||
|
|
8d82dbed08 | ||
|
|
06be463baa | ||
|
|
021966bb62 | ||
|
|
6a008a96fb | ||
|
|
bd36b5f1d0 | ||
|
|
1faf5715f1 | ||
|
|
8213da233b | ||
|
|
c7797b4be1 | ||
|
|
2c75a6535e | ||
|
|
f36d854e68 | ||
|
|
78aab1e5ed | ||
|
|
b537c4b891 | ||
|
|
f88167c3d0 | ||
|
|
dc8b083c63 | ||
|
|
c35f791681 | ||
|
|
c286eec7d2 | ||
|
|
634bb9a7d0 | ||
|
|
34c8afeb58 | ||
|
|
c2b7e9f269 | ||
|
|
15d73f3020 | ||
|
|
82da9cb6dc | ||
|
|
884744df4c | ||
|
|
39ce50b681 | ||
|
|
680358aff8 | ||
|
|
c163adc8e5 | ||
|
|
bbb57b32c8 | ||
|
|
58d9b48808 | ||
|
|
5e9ac2876b | ||
|
|
74be035352 | ||
|
|
2b11af94ba | ||
|
|
5011468a9a | ||
|
|
4e74f77cc8 | ||
|
|
7a707e77d8 | ||
|
|
34a158765e | ||
|
|
11903c6dfd | ||
|
|
4c38b389ce | ||
|
|
e61bd75c72 | ||
|
|
e65c3ab914 | ||
|
|
80a808d53c | ||
|
|
9a1ead8ca4 | ||
|
|
bfbbc2bff2 | ||
|
|
1315b25c3e | ||
|
|
dee9d52761 | ||
|
|
bf6c8d4d2b | ||
|
|
68d93c9427 | ||
|
|
30f5c34729 | ||
|
|
a40dc02825 | ||
|
|
c09dfbc813 | ||
|
|
4bba84bbdd | ||
|
|
c10b4e92ce | ||
|
|
45819df9e3 | ||
|
|
f4f894d979 | ||
|
|
f937ab9c6d | ||
|
|
d085c5b4f4 | ||
|
|
e9ca697c3b | ||
|
|
ca8f6cd967 | ||
|
|
be171e9976 | ||
|
|
ab2444c757 | ||
|
|
f651e3a61f | ||
|
|
2196b743b0 | ||
|
|
81d22b32c7 | ||
|
|
68ad9b2c5d | ||
|
|
8c2b232243 | ||
|
|
21af5cf600 | ||
|
|
533c7a9633 | ||
|
|
d0bc4751c3 | ||
|
|
703c6bc81f | ||
|
|
e5ccb5cb01 | ||
|
|
09b2a1d552 | ||
|
|
6f5ec6c819 | ||
|
|
ca8f566674 | ||
|
|
43c62d85ad | ||
|
|
f3ffdcc27d | ||
|
|
5b1d6efc71 | ||
|
|
fd41268432 | ||
|
|
b7338518d1 | ||
|
|
fa4238c5d3 | ||
|
|
6cbb10439e | ||
|
|
c4105d1e65 | ||
|
|
1ecac25d52 | ||
|
|
58b7d8856a | ||
|
|
e0448ac87e | ||
|
|
95760e320a | ||
|
|
27fd668758 | ||
|
|
aeba7d81ad | ||
|
|
bba6df6242 | ||
|
|
e0bf2ca717 | ||
|
|
9dd00b9e85 | ||
|
|
4b2365b961 | ||
|
|
3b75c8861d | ||
|
|
ea4a258d24 | ||
|
|
7be64f45a7 | ||
|
|
3c1813123d | ||
|
|
871bd9d21f | ||
|
|
86f2a2da57 | ||
|
|
81f780ed01 | ||
|
|
c27f21dd14 | ||
|
|
1e58f9898d | ||
|
|
0b49c5a761 | ||
|
|
94949eb1c7 | ||
|
|
0aafdb0238 | ||
|
|
c712be3e02 | ||
|
|
5196e92b24 | ||
|
|
5acdbfa61e | ||
|
|
b3b6d29099 | ||
|
|
0facea2740 | ||
|
|
f0cc5aefcf | ||
|
|
9051ad35b8 | ||
|
|
168f2a472b | ||
|
|
61103523de | ||
|
|
2eee29c0bf | ||
|
|
1345e3f57b | ||
|
|
f997a8a39b | ||
|
|
3bb57b9c90 | ||
|
|
4d32bc4981 | ||
|
|
9fc7721590 | ||
|
|
cb09a5c75b | ||
|
|
30cbdbce8d | ||
|
|
41ffc969a1 | ||
|
|
8271ba2a71 | ||
|
|
4c260b1a2f | ||
|
|
d93abea495 | ||
|
|
28747e8d29 | ||
|
|
99066319cd | ||
|
|
cf1476a6dc | ||
|
|
3e6e43c57c | ||
|
|
e6fa850a31 | ||
|
|
e2b8ce3408 | ||
|
|
a6945a19f2 | ||
|
|
99ded5c8bb | ||
|
|
d39d3ec134 | ||
|
|
50c706cc95 | ||
|
|
fab5c75fd2 | ||
|
|
665e5920c9 | ||
|
|
b6df771760 | ||
|
|
cddc6a2c40 | ||
|
|
840082d73b | ||
|
|
f92497b3d3 | ||
|
|
37efd29187 | ||
|
|
dd8c645a06 | ||
|
|
3e46c6027e | ||
|
|
9162d312de | ||
|
|
e1b3514084 | ||
|
|
bf1bb80d05 | ||
|
|
9846a4f77e | ||
|
|
4794a66aba | ||
|
|
c724fd46b0 | ||
|
|
aa6d60533c | ||
|
|
ebf412b144 | ||
|
|
e872586b70 | ||
|
|
088cca545c | ||
|
|
dd82e8f9c8 | ||
|
|
ce5b96189f | ||
|
|
23102934ad | ||
|
|
0856e40455 | ||
|
|
50874601c1 | ||
|
|
a290948ad9 | ||
|
|
6a25bd1909 | ||
|
|
9dbb2ef72f | ||
|
|
7d2458a74b | ||
|
|
db7463eb70 | ||
|
|
1cdee024c2 | ||
|
|
736ebbdd69 | ||
|
|
84e834aab9 | ||
|
|
710b264bc4 | ||
|
|
f6b91e93dc | ||
|
|
4ce27f8ae5 | ||
|
|
a8f966dcfd | ||
|
|
2ade896d0a | ||
|
|
520de302dd | ||
|
|
e9c35fe619 | ||
|
|
e97c9ab5d7 | ||
|
|
92ab9bb0dc | ||
|
|
dbd93dae5a | ||
|
|
f4dbfe0cc3 | ||
|
|
19e547fe52 | ||
|
|
50e6476cb0 | ||
|
|
47337ac048 | ||
|
|
f30d0cb680 | ||
|
|
e474138696 | ||
|
|
8931f65b89 | ||
|
|
3b16a0213e | ||
|
|
265d282550 | ||
|
|
21f2f58640 | ||
|
|
f15ecd6d59 | ||
|
|
64e5e88c27 | ||
|
|
d5f18034f9 | ||
|
|
310eaa38a0 | ||
|
|
c2cf8d3960 | ||
|
|
d2e77a45dd | ||
|
|
4660a11f25 | ||
|
|
01ea889569 | ||
|
|
2f3a32955c | ||
|
|
71136377e5 | ||
|
|
e671b3da7d | ||
|
|
8996da7d9a | ||
|
|
c81466d0db | ||
|
|
bdee3a843c | ||
|
|
1d8771a846 | ||
|
|
e18222628d | ||
|
|
34a4f5d78a | ||
|
|
dd83601c3c | ||
|
|
044379da71 | ||
|
|
edb136d960 | ||
|
|
c63c517b4f | ||
|
|
1838a4726e | ||
|
|
175c22d927 | ||
|
|
56c4c08836 | ||
|
|
4c2eb39937 | ||
|
|
2346dbbdd7 | ||
|
|
36ee06bbf8 | ||
|
|
e97a5a5b8f | ||
|
|
7f1a983498 | ||
|
|
ed87267777 | ||
|
|
030627cc10 | ||
|
|
2f2303e1d6 | ||
|
|
6988f9878f | ||
|
|
f9c4f290f5 | ||
|
|
76b8f2f54b | ||
|
|
bd4cfd6b99 | ||
|
|
ad7a66c64f | ||
|
|
5bfb07eef0 | ||
|
|
868502326c | ||
|
|
60e685d6e6 | ||
|
|
6ec3ff0ccc | ||
|
|
b6c83838f3 | ||
|
|
1d7fa00fe3 | ||
|
|
8f3351f720 | ||
|
|
c573c249ed | ||
|
|
bdf5f6a87c | ||
|
|
d782667b22 | ||
|
|
405bdb295c | ||
|
|
49d669c805 | ||
|
|
9bbe8e0b6e | ||
|
|
9dcbf2a356 | ||
|
|
5fe8b347a6 | ||
|
|
9fbbf70d4a | ||
|
|
9e7d80f467 | ||
|
|
8bf4485a75 | ||
|
|
2ea5e8e9b9 | ||
|
|
745483cd6c | ||
|
|
98df975614 | ||
|
|
6a2000deff | ||
|
|
356127f0e3 | ||
|
|
cd6f99fbc5 | ||
|
|
84f1498a4b | ||
|
|
a407c77a16 | ||
|
|
057b24b5e8 | ||
|
|
1aec3ae000 | ||
|
|
2d4b6f85fc | ||
|
|
4768dbb99a | ||
|
|
fbaf556f3a | ||
|
|
dc2d3ea1dc | ||
|
|
a0de029a23 | ||
|
|
f23a9e15d5 | ||
|
|
e5298f2bf3 | ||
|
|
b521d0377c | ||
|
|
38a7815b72 | ||
|
|
8737114d28 | ||
|
|
86bb88ab66 | ||
|
|
3a637de44c | ||
|
|
2d526db91b | ||
|
|
722b6e7d19 | ||
|
|
5695ef6e77 | ||
|
|
a574459f8b | ||
|
|
c5eea21821 | ||
|
|
b9e63dc57e | ||
|
|
26084d12e1 | ||
|
|
2b8a5c8897 | ||
|
|
a99b75a8c5 | ||
|
|
a6d9e6818e | ||
|
|
185ff68d78 | ||
|
|
f5a3652f6a | ||
|
|
9f2af0983a | ||
|
|
6dcfdc6ae0 | ||
|
|
1ff24aaeb4 | ||
|
|
bcec60ebef | ||
|
|
05361eb40d | ||
|
|
1a10db4c18 | ||
|
|
cd51e247cb | ||
|
|
b855923a21 | ||
|
|
ebad74f81e | ||
|
|
0e1022f04c | ||
|
|
60e95b1634 | ||
|
|
a578916fa1 | ||
|
|
02eed8c683 | ||
|
|
518a8b593c | ||
|
|
1cd962d9c7 | ||
|
|
6013e21cb4 | ||
|
|
41b68fd624 | ||
|
|
f645d7d50d | ||
|
|
65792128e4 | ||
|
|
462f8e1fb2 | ||
|
|
c7a1b6df0f | ||
|
|
041dc8b856 | ||
|
|
f29590b1c6 | ||
|
|
9e730fac56 | ||
|
|
df95288aab | ||
|
|
30d13bc7f0 | ||
|
|
325127419f | ||
|
|
b6c4a0be0f | ||
|
|
073139cde9 | ||
|
|
3db119ca4a | ||
|
|
56fde5b20b | ||
|
|
2d47c94992 | ||
|
|
799bdd1a62 | ||
|
|
880d635961 | ||
|
|
d63a407931 | ||
|
|
b406c0a52c | ||
|
|
5b40487753 | ||
|
|
f9fd2d4fa8 | ||
|
|
e3c154ab4c | ||
|
|
348e3a0c70 | ||
|
|
56cacd0441 | ||
|
|
4c6c3142c0 | ||
|
|
278429ab07 | ||
|
|
c892c8d1b0 | ||
|
|
2ecbac4a6f | ||
|
|
3d114c28e2 | ||
|
|
9b983441d7 | ||
|
|
98f5c52f8e | ||
|
|
5df8990802 | ||
|
|
10b1ba4578 | ||
|
|
f9f21cbd88 | ||
|
|
3ecfb99f9a | ||
|
|
42d9ad9fc2 | ||
|
|
b119ca36ae | ||
|
|
e77b547cea | ||
|
|
f6518ff3a0 | ||
|
|
35eab8a8b1 | ||
|
|
9c50cc789b | ||
|
|
b22cd9079a | ||
|
|
4efd815329 | ||
|
|
38bf4ec521 | ||
|
|
0b530145ba | ||
|
|
34f6389503 | ||
|
|
58633bf682 | ||
|
|
64dcd8ab18 | ||
|
|
55d611141b | ||
|
|
fc6c5d4aa6 | ||
|
|
65cd4cfe28 | ||
|
|
d64908c596 | ||
|
|
de72675b67 | ||
|
|
e0a7bc996d | ||
|
|
cce36ae058 | ||
|
|
be3336c1ec | ||
|
|
75df9266cb | ||
|
|
a3eaac3413 | ||
|
|
8b02ba003b | ||
|
|
b38e30cd7f | ||
|
|
d26321056d | ||
|
|
847539397c | ||
|
|
fbf240efbc | ||
|
|
68c22e878b | ||
|
|
84beec5136 | ||
|
|
58a2d78629 | ||
|
|
1e0687a7e2 | ||
|
|
a640809c61 | ||
|
|
44cb278257 | ||
|
|
f8b165fe20 | ||
|
|
5a05c79a45 | ||
|
|
ad2b02d0e4 | ||
|
|
b23c31bfc6 | ||
|
|
37f64dbdf4 | ||
|
|
7a21e7e5f9 | ||
|
|
8548ecacc0 | ||
|
|
a0354726b1 | ||
|
|
7a01a440b8 | ||
|
|
1c89373035 | ||
|
|
2faa755bfe | ||
|
|
c228078e25 | ||
|
|
47fcb9311f | ||
|
|
5f60fbe196 | ||
|
|
1efb3de113 | ||
|
|
e1e334c7cd | ||
|
|
b5a8864220 | ||
|
|
18d581137c | ||
|
|
31a7609604 | ||
|
|
a82ccdecd1 | ||
|
|
7f9a2d06dd | ||
|
|
33a3e0de58 | ||
|
|
e85d3dcc59 | ||
|
|
020654c33c | ||
|
|
38b5440662 | ||
|
|
0f86614679 | ||
|
|
c51abd2178 | ||
|
|
e8156ab411 | ||
|
|
e4b9e55883 | ||
|
|
7eac5dd610 | ||
|
|
6f4f6f373e | ||
|
|
c7e0c1c899 | ||
|
|
3acaf6f16a | ||
|
|
982778128e | ||
|
|
e5a3917f7d | ||
|
|
2e27f7a66a | ||
|
|
cf57df35da | ||
|
|
ee08f356e9 | ||
|
|
01e67ee9a4 | ||
|
|
68733fbf51 | ||
|
|
91e7463ee5 | ||
|
|
a67bcb09db | ||
|
|
d10cdaa0fb | ||
|
|
93e9be46a9 | ||
|
|
23c9028b57 | ||
|
|
16e3bb7d7a | ||
|
|
3acf3a66b9 | ||
|
|
4106a8ce22 | ||
|
|
396ebf8393 | ||
|
|
5cfb457903 | ||
|
|
5375bd1473 | ||
|
|
3dd034bf06 | ||
|
|
bd83ee189b | ||
|
|
a740e2e9ce | ||
|
|
292d0c8b54 | ||
|
|
de5c2cb26b | ||
|
|
d4d6501af6 | ||
|
|
433626f5c9 | ||
|
|
0b5c0bdcc0 | ||
|
|
1d2a47fee0 | ||
|
|
f88e39563d | ||
|
|
6f3895edd8 | ||
|
|
e18d1ddedf | ||
|
|
d0e027bb17 | ||
|
|
7dabb9e4fb | ||
|
|
847afca6fe | ||
|
|
9fb70bcfc9 | ||
|
|
baec487a21 | ||
|
|
5bda37b05d | ||
|
|
11694292da | ||
|
|
513c3011fc | ||
|
|
6a88df5500 | ||
|
|
2517307801 | ||
|
|
86dcc75fa0 | ||
|
|
a9194ba3b5 | ||
|
|
9b24d962bb | ||
|
|
2d00620fac | ||
|
|
58f05d91c3 | ||
|
|
c570517fe0 | ||
|
|
6429334549 | ||
|
|
105920047a | ||
|
|
668c5ddadb | ||
|
|
9a30b95a1a | ||
|
|
023e082ce5 | ||
|
|
a029888725 | ||
|
|
b8d37774fd | ||
|
|
2a26ea5429 | ||
|
|
463c3cd7f0 | ||
|
|
2d217229c6 | ||
|
|
42afd3cad2 | ||
|
|
a9299e947f | ||
|
|
5f8cc0b08b | ||
|
|
1ad609f0df | ||
|
|
ea9a27ce11 | ||
|
|
ab46f3fc72 | ||
|
|
915c0f60a4 | ||
|
|
8c2f7c02ad | ||
|
|
55e3616938 | ||
|
|
fc2db2a17c | ||
|
|
d0b67ae795 | ||
|
|
056e46504e | ||
|
|
747e9900df | ||
|
|
307c382d40 | ||
|
|
26dd569afc | ||
|
|
5737bb497f | ||
|
|
b29e52b0a2 | ||
|
|
143143a88d | ||
|
|
c27a4dd83d | ||
|
|
1259c34db8 | ||
|
|
4e3a8e4110 | ||
|
|
ec4f8582e6 | ||
|
|
9bacb752e4 | ||
|
|
a23f516395 | ||
|
|
eaa8b59a10 | ||
|
|
b86ca22206 | ||
|
|
891e60c2f9 | ||
|
|
e865e4102e | ||
|
|
3b189c23e9 | ||
|
|
2df0530c77 | ||
|
|
5173bbafd1 | ||
|
|
e9538ee1dd | ||
|
|
e9e0d04a2c | ||
|
|
0e42de5f7f | ||
|
|
5433b61d3e | ||
|
|
3a5de1f9d8 | ||
|
|
980c5de3dc | ||
|
|
9cfac53a97 | ||
|
|
81f45ba048 | ||
|
|
275b4d5831 | ||
|
|
e8fae013bd | ||
|
|
e4bd07c535 | ||
|
|
02a56e4f64 | ||
|
|
8788bed921 | ||
|
|
9ae5f495c1 |
@@ -0,0 +1,44 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Path,
|
||||
|
||||
[string]$ExpectedCertificateSha1 = $env:VSIGN_CERT_HASH
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ExpectedCertificateSha1)) {
|
||||
throw "The expected Authenticode certificate SHA1 is not configured"
|
||||
}
|
||||
|
||||
$expectedSha1 = $ExpectedCertificateSha1.Replace(" ", "").ToUpperInvariant()
|
||||
if ($expectedSha1 -notmatch '^[0-9A-F]{40}$') {
|
||||
throw "The expected Authenticode certificate SHA1 is invalid"
|
||||
}
|
||||
|
||||
foreach ($candidate in $Path) {
|
||||
$resolvedPath = (Resolve-Path -LiteralPath $candidate -ErrorAction Stop).Path
|
||||
if (!(Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
|
||||
throw "Authenticode target is not a file: $resolvedPath"
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $resolvedPath
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
throw "No Authenticode signature was found on $resolvedPath"
|
||||
}
|
||||
|
||||
$actualSha1 = $signature.SignerCertificate.Thumbprint.Replace(" ", "").ToUpperInvariant()
|
||||
if ($actualSha1 -ne $expectedSha1) {
|
||||
throw "Signing certificate mismatch on $resolvedPath. Expected $expectedSha1, got $actualSha1"
|
||||
}
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "Authenticode signature status for $resolvedPath is $($signature.Status): $($signature.StatusMessage)"
|
||||
}
|
||||
if ($null -eq $signature.TimeStamperCertificate) {
|
||||
throw "The Authenticode signature on $resolvedPath does not contain a timestamp certificate"
|
||||
}
|
||||
|
||||
Write-Host "Verified Authenticode signature: $resolvedPath"
|
||||
}
|
||||
@@ -58,15 +58,21 @@ const nativeDriverDirectories = {
|
||||
neo4j: "neo4j-go",
|
||||
vastbase: "vastbase-go",
|
||||
rabbitmq: "rabbitmq",
|
||||
rocketmq: "rocketmq",
|
||||
zookeeper: "zookeeper",
|
||||
tdengine: "tdengine",
|
||||
};
|
||||
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
|
||||
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine"]);
|
||||
const nativeDriverSharedPaths = {
|
||||
hive: [
|
||||
"agents/go-common/go-gssapi",
|
||||
"agents/go-common/gohive",
|
||||
"agents/go-common/gosasl",
|
||||
],
|
||||
zookeeper: [
|
||||
"agents/go-common/go-gssapi",
|
||||
"agents/go-common/gosasl",
|
||||
],
|
||||
};
|
||||
|
||||
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
|
||||
|
||||
@@ -59,6 +59,37 @@ test("bumps the native RabbitMQ agent from its Go directory", () => {
|
||||
assert.equal(result.versions.rabbitmq, "0.1.1");
|
||||
});
|
||||
|
||||
test("bumps the native RocketMQ agent from its Go directory", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { rocketmq: "0.1.0" },
|
||||
changedFiles: ["agents/drivers/rocketmq/main.go"],
|
||||
moduleExists: (path) => path === "agents/drivers/rocketmq",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.rocketmq, "0.1.1");
|
||||
assert.deepEqual(result.nativeModules, ["rocketmq"]);
|
||||
});
|
||||
|
||||
test("bumps ZooKeeper from native and shared SASL source directories", () => {
|
||||
for (const changedFile of [
|
||||
"agents/drivers/zookeeper/main.go",
|
||||
"agents/go-common/gosasl/sasl.go",
|
||||
"agents/go-common/go-gssapi/krb5/krb5.go",
|
||||
]) {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { zookeeper: "0.1.0" },
|
||||
changedFiles: [changedFile],
|
||||
moduleExists: (path) => path === "agents/drivers/zookeeper",
|
||||
readModuleFile: () => "",
|
||||
});
|
||||
|
||||
assert.equal(result.versions.zookeeper, "0.1.1");
|
||||
assert.deepEqual(result.javaModules, []);
|
||||
assert.deepEqual(result.nativeModules, ["zookeeper"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("bumps the native Vastbase agent from its independent Go directory", () => {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { vastbase: "0.1.37" },
|
||||
|
||||
@@ -41,7 +41,7 @@ const extraAliases = {
|
||||
doris: ["selectdb"],
|
||||
manticoresearch: ["manticore"],
|
||||
dameng: ["dm8", "dameng", "达梦"],
|
||||
kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓"],
|
||||
kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓", "电科金仓"],
|
||||
highgo: ["瀚高"],
|
||||
yashandb: ["yashan", "崖山"],
|
||||
saphana: ["hana", "sap hana"],
|
||||
@@ -55,6 +55,7 @@ const extraAliases = {
|
||||
db2: ["ibm db2"],
|
||||
informix: ["ibm informix"],
|
||||
bigquery: ["google bigquery"],
|
||||
spanner: ["cloud spanner", "google spanner", "google cloud spanner"],
|
||||
kylin: ["apache kylin"],
|
||||
oscar: ["shentong", "oscar", "神通"],
|
||||
xugu: ["xugudb", "xugu", "虚谷"],
|
||||
@@ -90,7 +91,19 @@ const supplementalDrivers = [
|
||||
|
||||
const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestUrl, "utf8"));
|
||||
const catalogEntries = [...manifest.drivers, ...supplementalDrivers];
|
||||
const manifestDbTypes = new Set(manifest.drivers.map((driver) => driver.dbType));
|
||||
const supplementalByDbType = new Map(supplementalDrivers.map((driver) => [driver.dbType, driver]));
|
||||
const catalogEntries = [
|
||||
...manifest.drivers.map((driver) => {
|
||||
const supplemental = supplementalByDbType.get(driver.dbType);
|
||||
if (!supplemental) return driver;
|
||||
return {
|
||||
...driver,
|
||||
aliases: [...new Set([...(driver.aliases || []), ...(supplemental.aliases || [])])],
|
||||
};
|
||||
}),
|
||||
...supplementalDrivers.filter((driver) => !manifestDbTypes.has(driver.dbType)),
|
||||
];
|
||||
const duplicateDbTypes = catalogEntries
|
||||
.map((driver) => driver.dbType)
|
||||
.filter((dbType, index, values) => values.indexOf(dbType) !== index);
|
||||
|
||||
@@ -6,12 +6,16 @@ import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const LOCALES_DIR = "apps/desktop/src/i18n/locales";
|
||||
const SOURCE_LOCALE = "zh-CN";
|
||||
const TARGET_LOCALES = ["en", "es", "it", "ja", "pt-BR", "zh-TW"];
|
||||
const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-TW"];
|
||||
// `git show <ref>:<path>` needs forward slashes even on Windows, where
|
||||
// `path.join()` yields backslashes. Allow an override (CI keeps the default).
|
||||
const refPath = (path) => path.replaceAll("\\", "/");
|
||||
const TARGET_LABELS = {
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
ja: "Japanese",
|
||||
ko: "Korean",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
"zh-TW": "Traditional Chinese used in Taiwan",
|
||||
};
|
||||
@@ -106,7 +110,7 @@ function localePath(locale) {
|
||||
|
||||
function readBaseFile(path) {
|
||||
try {
|
||||
return execFileSync("git", ["show", `${baseRef}:${path}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
return execFileSync("git", ["show", `${baseRef}:${refPath(path)}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,23 @@ test("database label synchronization includes every catalog entry once", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Kingbase aliases retain historical and current company names", () => {
|
||||
const kingbase = databaseIssueDrivers.find((driver) => driver.dbType === "kingbase");
|
||||
assert.ok(kingbase);
|
||||
assert.equal(kingbase.aliases.includes("人大金仓"), true);
|
||||
assert.equal(kingbase.aliases.includes("金仓"), true);
|
||||
assert.equal(kingbase.aliases.includes("电科金仓"), true);
|
||||
assert.equal(kingbase.aliases.includes("中电科金仓"), false);
|
||||
});
|
||||
|
||||
test("supplemental aliases merge into manifest entries without duplicating labels", () => {
|
||||
const mqttEntries = databaseIssueDrivers.filter((driver) => driver.dbType === "mqtt");
|
||||
assert.equal(mqttEntries.length, 1);
|
||||
assert.equal(mqttEntries[0].aliases.includes("emqx"), true);
|
||||
assert.equal(mqttEntries[0].aliases.includes("hivemq"), true);
|
||||
assert.equal(mqttEntries[0].aliases.includes("mosquitto"), true);
|
||||
});
|
||||
|
||||
test("labels native and compatibility database products with their families", () => {
|
||||
const cases = [
|
||||
["Turso", ["db/turso"]],
|
||||
@@ -60,6 +77,8 @@ test("labels native and compatibility database products with their families", ()
|
||||
["RabbitMQ", ["db/mq", "db/rabbitmq"]],
|
||||
["MQTT 5.0", ["db/mqtt"]],
|
||||
["EMQX 5.8", ["db/mqtt"]],
|
||||
["电科金仓 KingbaseES V9", ["db/kingbase"]],
|
||||
["中电科金仓 KingbaseES V9", ["db/kingbase"]],
|
||||
];
|
||||
|
||||
for (const [database, expected] of cases) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_jdbc_payload.mjs");
|
||||
const ZIP_BUILDER = join(REPO_ROOT, "agents/scripts/build_offline_zip.sh");
|
||||
const RELEASE_VERIFIER = join(REPO_ROOT, "agents/scripts/verify_offline_jdbc_release.mjs");
|
||||
const ASSET_MANIFEST = join(REPO_ROOT, "apps/desktop/src/lib/database/managedJdbcAssets.json");
|
||||
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"];
|
||||
|
||||
function flattenBundles(manifest) {
|
||||
return Object.values(manifest.drivers).flatMap((driver) => Object.values(driver.bundles || {}));
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { encoding: "utf8", ...options });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("builds and verifies all platform ZIPs from the managed JDBC asset manifest", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dbx-offline-jdbc-builder-test-"));
|
||||
try {
|
||||
const releaseDir = join(root, "release");
|
||||
const pluginZip = join(root, "plugin.zip");
|
||||
const jarRoot = join(root, "jar");
|
||||
const artifact = join(root, "driver.jar");
|
||||
const resolver = join(root, "resolver.mjs");
|
||||
const testManifestPath = join(root, "managed-jdbc-assets.json");
|
||||
mkdirSync(releaseDir, { recursive: true });
|
||||
mkdirSync(join(jarRoot, "META-INF"), { recursive: true });
|
||||
writeFileSync(pluginZip, "plugin");
|
||||
writeFileSync(join(jarRoot, "META-INF/LICENSE"), "Apache License 2.0\n");
|
||||
writeFileSync(join(jarRoot, "META-INF/NOTICE"), "Test notice\n");
|
||||
run("zip", ["-qr", artifact, "."], { cwd: jarRoot });
|
||||
|
||||
const digest = createHash("sha256").update(readFileSync(artifact)).digest("hex");
|
||||
const productionManifest = JSON.parse(readFileSync(ASSET_MANIFEST, "utf8"));
|
||||
const testManifest = structuredClone(productionManifest);
|
||||
const resolverBundles = {};
|
||||
for (const bundle of flattenBundles(testManifest)) {
|
||||
bundle.artifact.file_name = "driver.jar";
|
||||
bundle.artifact.size = statSync(artifact).size;
|
||||
bundle.artifact.sha256 = digest;
|
||||
bundle.redistribution.license_entries = ["META-INF/LICENSE"];
|
||||
bundle.redistribution.notice_entries = ["META-INF/NOTICE"];
|
||||
resolverBundles[bundle.coordinate] = {
|
||||
coordinate: bundle.coordinate,
|
||||
scope: "runtime",
|
||||
repositories: [testManifest.repository],
|
||||
artifacts: [
|
||||
{
|
||||
groupId: bundle.artifact.group_id,
|
||||
artifactId: bundle.artifact.artifact_id,
|
||||
version: bundle.artifact.version,
|
||||
classifier: bundle.artifact.classifier,
|
||||
extension: bundle.artifact.extension,
|
||||
file: artifact,
|
||||
size: bundle.artifact.size,
|
||||
sha256: digest,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
writeFileSync(testManifestPath, `${JSON.stringify(testManifest, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
resolver,
|
||||
`#!/usr/bin/env node
|
||||
const bundles = ${JSON.stringify(resolverBundles)};
|
||||
const coordinate = process.argv[process.argv.indexOf("--coordinate") + 1];
|
||||
if (!bundles[coordinate]) process.exit(2);
|
||||
process.stdout.write(JSON.stringify(bundles[coordinate]));
|
||||
`,
|
||||
);
|
||||
chmodSync(resolver, 0o755);
|
||||
|
||||
run(process.execPath, [BUILDER, releaseDir, pluginZip, resolver, testManifestPath], {
|
||||
env: { ...process.env, DBX_OFFLINE_JDBC_MAVEN_CACHE: join(root, "cache") },
|
||||
});
|
||||
|
||||
const payloadDir = join(releaseDir, "offline-jdbc/jdbc");
|
||||
const offlineManifest = JSON.parse(readFileSync(join(payloadDir, "offline-manifest.json"), "utf8"));
|
||||
assert.equal(offlineManifest.format_version, 1);
|
||||
assert.equal(offlineManifest.plugin_entry, "jdbc/plugin.zip");
|
||||
assert.equal(offlineManifest.third_party_entry, "jdbc/third-party-notices.json");
|
||||
assert.deepEqual(
|
||||
offlineManifest.bundles.map((bundle) => bundle.coordinate),
|
||||
flattenBundles(productionManifest).map((bundle) => bundle.coordinate),
|
||||
);
|
||||
for (const bundle of offlineManifest.bundles) {
|
||||
const bundleManifest = JSON.parse(readFileSync(join(payloadDir, "maven", bundle.id, "manifest.json"), "utf8"));
|
||||
assert.equal(bundleManifest.coordinate, bundle.coordinate);
|
||||
assert.equal(bundleManifest.artifacts.length, 1);
|
||||
assert.equal(bundleManifest.artifacts[0].sha256, digest);
|
||||
}
|
||||
|
||||
writeFileSync(join(releaseDir, "agent-registry.json"), "{}\n");
|
||||
for (const platform of PLATFORMS) writeFileSync(join(releaseDir, `dbx-jre-21-${platform}.tar.zst`), platform);
|
||||
run("bash", [ZIP_BUILDER, releaseDir]);
|
||||
run(process.execPath, [RELEASE_VERIFIER, releaseDir, testManifestPath]);
|
||||
for (const platform of PLATFORMS) assert.equal(existsSync(join(releaseDir, `dbx-agents-offline-${platform}.zip`)), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const REGISTRY_ASSET = "agent-registry.json";
|
||||
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
|
||||
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine"]);
|
||||
const PLATFORMS = [
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
|
||||
@@ -114,6 +114,24 @@ test("requires all IoTDB native platforms when reusing a release", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all RocketMQ native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { rocketmq: "0.1.40" },
|
||||
modules: ["rocketmq"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$requiredVariables = @(
|
||||
"GITHUB_ENV",
|
||||
"RUNNER_TEMP",
|
||||
"VSIGN_CERT_HASH",
|
||||
"VSIGN_OPERATOR_PFX_B64",
|
||||
"VSIGN_OPERATOR_PWD",
|
||||
"VSIGN_OPERATOR_SHA1",
|
||||
"VSIGN_SERVER",
|
||||
"VSIGN_TOOL_SHA256"
|
||||
)
|
||||
|
||||
foreach ($variableName in $requiredVariables) {
|
||||
$value = [Environment]::GetEnvironmentVariable($variableName)
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
throw "Required VSign configuration is missing: $variableName"
|
||||
}
|
||||
}
|
||||
|
||||
$serverUri = $null
|
||||
if (![Uri]::TryCreate($env:VSIGN_SERVER, [UriKind]::Absolute, [ref]$serverUri) -or $serverUri.Scheme -ne "https") {
|
||||
throw "VSIGN_SERVER must be an absolute HTTPS URL"
|
||||
}
|
||||
|
||||
$expectedToolSha256 = $env:VSIGN_TOOL_SHA256.Replace(" ", "").ToUpperInvariant()
|
||||
$expectedOperatorSha1 = $env:VSIGN_OPERATOR_SHA1.Replace(" ", "").ToUpperInvariant()
|
||||
$expectedCertificateSha1 = $env:VSIGN_CERT_HASH.Replace(" ", "").ToUpperInvariant()
|
||||
if ($expectedToolSha256 -notmatch '^[0-9A-F]{64}$') {
|
||||
throw "VSIGN_TOOL_SHA256 is invalid"
|
||||
}
|
||||
if ($expectedOperatorSha1 -notmatch '^[0-9A-F]{40}$') {
|
||||
throw "VSIGN_OPERATOR_SHA1 is invalid"
|
||||
}
|
||||
if ($expectedCertificateSha1 -notmatch '^[0-9A-F]{40}$') {
|
||||
throw "VSIGN_CERT_HASH is invalid"
|
||||
}
|
||||
|
||||
$runId = if ([string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID)) { "local" } else { $env:GITHUB_RUN_ID }
|
||||
$runAttempt = if ([string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ATTEMPT)) { "1" } else { $env:GITHUB_RUN_ATTEMPT }
|
||||
$workDir = Join-Path $env:RUNNER_TEMP "vsign-$runId-$runAttempt"
|
||||
$operatorDir = Join-Path $workDir "operator"
|
||||
$operatorFile = Join-Path $operatorDir "dbx-github-ci@ssigncode.pfx"
|
||||
$toolPath = Join-Path $workDir "ssigncode.exe"
|
||||
$toolUrl = "https://github.com/g5wsg/vsign-github-test1/releases/download/vsign-cli-v1/ssigncode.exe"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $operatorDir | Out-Null
|
||||
Invoke-WebRequest -Uri $toolUrl -OutFile $toolPath
|
||||
|
||||
$actualToolSha256 = (Get-FileHash -LiteralPath $toolPath -Algorithm SHA256).Hash
|
||||
if ($actualToolSha256 -ne $expectedToolSha256) {
|
||||
throw "VSign CLI SHA256 mismatch. Expected $expectedToolSha256, got $actualToolSha256"
|
||||
}
|
||||
|
||||
& $toolPath version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "VSign CLI version check failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
try {
|
||||
$pfxBytes = [Convert]::FromBase64String($env:VSIGN_OPERATOR_PFX_B64.Trim())
|
||||
[IO.File]::WriteAllBytes($operatorFile, $pfxBytes)
|
||||
$flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
|
||||
$operatorCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
$operatorFile,
|
||||
$env:VSIGN_OPERATOR_PWD,
|
||||
$flags
|
||||
)
|
||||
}
|
||||
catch {
|
||||
throw "Cannot restore or decrypt the VSign operator PFX: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
$actualOperatorSha1 = $operatorCertificate.Thumbprint.Replace(" ", "").ToUpperInvariant()
|
||||
if ($actualOperatorSha1 -ne $expectedOperatorSha1) {
|
||||
throw "Operator certificate mismatch. Expected $expectedOperatorSha1, got $actualOperatorSha1"
|
||||
}
|
||||
if (!$operatorCertificate.HasPrivateKey) {
|
||||
throw "The operator PFX does not contain a private key"
|
||||
}
|
||||
if ($operatorCertificate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow) {
|
||||
throw "The operator certificate has expired"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$operatorCertificate.Dispose()
|
||||
}
|
||||
|
||||
$githubEnvironment = [ordered]@{
|
||||
VSIGN_WORK_DIR = $workDir
|
||||
VSIGN_CLI = $toolPath
|
||||
SSIGNCODE_OPERATOR_STORE = "file"
|
||||
SSIGNCODE_OPERATOR_DIR = $operatorDir
|
||||
SSIGNCODE_OPERATOR_FILE = $operatorFile
|
||||
}
|
||||
|
||||
foreach ($entry in $githubEnvironment.GetEnumerator()) {
|
||||
"$($entry.Key)=$($entry.Value)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
}
|
||||
|
||||
Write-Host "VSign CLI and operator certificate are ready."
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -10,6 +10,10 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || "";
|
||||
const OUT_CN = "releases-cn.json";
|
||||
const OUT_EN = "releases-en.json";
|
||||
const OUT_INDEX_CN = "index-cn.json";
|
||||
const OUT_INDEX_EN = "index-en.json";
|
||||
const RELEASES_CN_DIR = "releases-cn";
|
||||
const RELEASES_EN_DIR = "releases-en";
|
||||
const LATEST_EN_OUT = "latest-en.json";
|
||||
const LATEST_NOTES_OUT = "latest-notes.json";
|
||||
const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json";
|
||||
@@ -68,6 +72,7 @@ function stripIssueRefs(text) {
|
||||
// 可能是旧版脚本生成的(desc 仍含 closes #),在写文件前统一清理一次。
|
||||
function stripIssueRefsInReleases(json) {
|
||||
for (const release of json.releases || []) {
|
||||
if (release.markdown) release.markdown = stripIssueRefs(release.markdown);
|
||||
for (const section of release.sections || []) {
|
||||
for (const item of section.items || []) {
|
||||
if (item.desc) item.desc = stripIssueRefs(item.desc);
|
||||
@@ -138,12 +143,30 @@ export function buildReleasesJson(releases, now = new Date()) {
|
||||
name: r.name || r.tag_name,
|
||||
date: r.published_at.slice(0, 10),
|
||||
_sourceHash: buildReleaseSourceHash(r),
|
||||
markdown: stripIssueRefs(stripDownloadSection(r.body || "")),
|
||||
sections: parseBody(r.body || ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChangelogIndex(json) {
|
||||
return {
|
||||
updatedAt: json.updatedAt,
|
||||
releases: json.releases.map(({ tag, name, date }) => ({ tag, name, date })),
|
||||
};
|
||||
}
|
||||
|
||||
function writeReleaseFiles(json, directory) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
mkdirSync(directory, { recursive: true });
|
||||
for (const release of json.releases) {
|
||||
writeFileSync(resolve(directory, `${release.tag}.json`), JSON.stringify(release, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function releaseToMarkdown(release) {
|
||||
if (release.markdown !== undefined) return release.markdown;
|
||||
|
||||
return release.sections
|
||||
.map((s) => {
|
||||
const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join("\n");
|
||||
@@ -199,7 +222,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
|
||||
for (const release of cnJson.releases) {
|
||||
const cachedRelease = cachedByTag.get(release.tag);
|
||||
if (cachedRelease?._sourceHash === release._sourceHash) {
|
||||
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash });
|
||||
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash, markdown: cachedRelease.markdown || releaseToMarkdown(cachedRelease) });
|
||||
reusedCount++;
|
||||
} else {
|
||||
enReleases.push(release);
|
||||
@@ -218,6 +241,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
|
||||
name: release.name,
|
||||
date: release.date,
|
||||
_sourceHash: release._sourceHash,
|
||||
markdown: cachedRelease.markdown || releaseToMarkdown(cachedRelease),
|
||||
});
|
||||
reusedCount++;
|
||||
continue;
|
||||
@@ -226,7 +250,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
|
||||
const sectionsText = releaseToMarkdown(release);
|
||||
|
||||
if (!sectionsText.trim()) {
|
||||
enReleases.push({ ...release, sections: [] });
|
||||
enReleases.push({ ...release, sections: [], markdown: "" });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -256,7 +280,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
|
||||
const data = await res.json();
|
||||
const translated = data.choices?.[0]?.message?.content || "";
|
||||
const enSections = parseBody(translated);
|
||||
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections });
|
||||
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections, markdown: enSections.length > 0 ? translated.trim() : release.markdown });
|
||||
translatedCount++;
|
||||
|
||||
await sleep(200);
|
||||
@@ -276,6 +300,9 @@ async function main() {
|
||||
|
||||
writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2));
|
||||
console.log(`Wrote ${OUT_CN}`);
|
||||
writeFileSync(OUT_INDEX_CN, JSON.stringify(buildChangelogIndex(cnJson), null, 2));
|
||||
writeReleaseFiles(cnJson, RELEASES_CN_DIR);
|
||||
console.log(`Wrote ${OUT_INDEX_CN} and ${RELEASES_CN_DIR}/`);
|
||||
|
||||
const latestNotes = buildLatestReleaseNotes(releases);
|
||||
if (latestNotes) {
|
||||
@@ -293,6 +320,9 @@ async function main() {
|
||||
stripIssueRefsInReleases(enJson);
|
||||
writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2));
|
||||
console.log(`Wrote ${OUT_EN}`);
|
||||
writeFileSync(OUT_INDEX_EN, JSON.stringify(buildChangelogIndex(enJson), null, 2));
|
||||
writeReleaseFiles(enJson, RELEASES_EN_DIR);
|
||||
console.log(`Wrote ${OUT_INDEX_EN} and ${RELEASES_EN_DIR}/`);
|
||||
|
||||
// 应用内更新提示用的英文 notes(单条最新版本)
|
||||
const latestEn = buildLatestEnNotes(enJson);
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
echo "AppImage input-method verification failed: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
die "usage: $0 <AppImage file or bundle directory>"
|
||||
fi
|
||||
|
||||
input_path=$1
|
||||
if [[ -f "$input_path" ]]; then
|
||||
appimage=$input_path
|
||||
elif [[ -d "$input_path" ]]; then
|
||||
appimages=()
|
||||
while IFS= read -r -d '' candidate; do
|
||||
appimages+=("$candidate")
|
||||
done < <(find "$input_path" -maxdepth 1 -type f -name '*.AppImage' -print0)
|
||||
[[ ${#appimages[@]} -eq 1 ]] \
|
||||
|| die "expected exactly one AppImage in $input_path, found ${#appimages[@]}"
|
||||
appimage=${appimages[0]}
|
||||
else
|
||||
die "path does not exist: $input_path"
|
||||
fi
|
||||
appimage=$(realpath "$appimage")
|
||||
|
||||
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/dbx-appimage-input-methods.XXXXXX")
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
|
||||
(
|
||||
cd "$work_dir"
|
||||
"$appimage" --appimage-extract >/dev/null
|
||||
)
|
||||
appdir="$work_dir/squashfs-root"
|
||||
[[ -d "$appdir" ]] || die "AppImage extraction did not create squashfs-root"
|
||||
appdir=$(realpath "$appdir")
|
||||
|
||||
require_artifact() {
|
||||
local basename=$1
|
||||
local match
|
||||
local resolved
|
||||
match=$(find "$appdir" \( -type f -o -type l \) -name "$basename" -print -quit)
|
||||
[[ -n "$match" ]] || die "missing bundled artifact: $basename"
|
||||
[[ -e "$match" ]] || die "bundled artifact is a broken symbolic link: ${match#"$appdir"/}"
|
||||
resolved=$(realpath "$match")
|
||||
[[ "$resolved" == "$appdir"/* ]] \
|
||||
|| die "bundled artifact resolves outside the AppImage: ${match#"$appdir"/}"
|
||||
echo "Found $basename at ${match#"$appdir"/}"
|
||||
}
|
||||
|
||||
require_artifact im-fcitx5.so
|
||||
require_artifact im-ibus.so
|
||||
require_artifact libFcitx5GClient.so.2
|
||||
require_artifact libibus-1.0.so.5
|
||||
|
||||
cache=''
|
||||
while IFS= read -r -d '' candidate; do
|
||||
if grep -Fq 'im-fcitx5.so' "$candidate" && grep -Fq 'im-ibus.so' "$candidate"; then
|
||||
cache=$candidate
|
||||
break
|
||||
fi
|
||||
done < <(find "$appdir" -type f -path '*/gtk-3.0/*/immodules.cache' -print0)
|
||||
|
||||
[[ -n "$cache" ]] || die "no private GTK 3 immodules.cache registers both Fcitx 5 and IBus"
|
||||
echo "Verified GTK input-method cache at ${cache#"$appdir"/}"
|
||||
@@ -0,0 +1,72 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$FilePath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$resolvedPath = (Resolve-Path -LiteralPath $FilePath -ErrorAction Stop).Path
|
||||
if (!(Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
|
||||
throw "VSign target is not a file: $resolvedPath"
|
||||
}
|
||||
|
||||
$extension = [IO.Path]::GetExtension($resolvedPath).ToLowerInvariant()
|
||||
if ($extension -eq ".dll") {
|
||||
Write-Host "Skipping Authenticode signing for DLL: $resolvedPath"
|
||||
return
|
||||
}
|
||||
if ($extension -notin @(".exe", ".msi")) {
|
||||
throw "Unsupported VSign target type: $extension"
|
||||
}
|
||||
|
||||
$requiredVariables = @(
|
||||
"SSIGNCODE_OPERATOR_DIR",
|
||||
"SSIGNCODE_OPERATOR_FILE",
|
||||
"SSIGNCODE_OPERATOR_PWD",
|
||||
"SSIGNCODE_OPERATOR_STORE",
|
||||
"VSIGN_CERT_HASH",
|
||||
"VSIGN_CLI",
|
||||
"VSIGN_KEY_PIN",
|
||||
"VSIGN_SERVER"
|
||||
)
|
||||
|
||||
foreach ($variableName in $requiredVariables) {
|
||||
$value = [Environment]::GetEnvironmentVariable($variableName)
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
throw "Required VSign runtime configuration is missing: $variableName"
|
||||
}
|
||||
}
|
||||
|
||||
$directory = [IO.Path]::GetDirectoryName($resolvedPath)
|
||||
$fileName = [IO.Path]::GetFileNameWithoutExtension($resolvedPath)
|
||||
$signedPath = Join-Path $directory "$fileName.vsign-$([Guid]::NewGuid().ToString('N'))$extension"
|
||||
|
||||
try {
|
||||
& $env:VSIGN_CLI vsign `
|
||||
-s $env:VSIGN_SERVER `
|
||||
--cert_from csign `
|
||||
-u dbx-github-ci `
|
||||
--cert_hash $env:VSIGN_CERT_HASH `
|
||||
-k $env:VSIGN_KEY_PIN `
|
||||
--hash sha256 `
|
||||
-i $resolvedPath `
|
||||
-o $signedPath
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "VSign failed for $resolvedPath with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $signedPath -PathType Leaf)) {
|
||||
throw "VSign did not create the signed output for $resolvedPath"
|
||||
}
|
||||
|
||||
& "$PSScriptRoot/assert-authenticode.ps1" -Path $signedPath
|
||||
Move-Item -LiteralPath $signedPath -Destination $resolvedPath -Force
|
||||
Write-Host "Signed with TrustAsia VSign: $resolvedPath"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $signedPath) {
|
||||
Remove-Item -LiteralPath $signedPath -Force
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
|
||||
- name: Download agent version patch
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -146,7 +147,11 @@ jobs:
|
||||
for module in "${MODULES[@]}"; do
|
||||
TASKS+=(":${module}:shadowJar")
|
||||
done
|
||||
./gradlew "${TASKS[@]}" --parallel
|
||||
# agents/common changes trigger a parallel mass rebuild of every
|
||||
# packaged module; the Gradle daemon default heap is too small for
|
||||
# ~25 shadowJar fat jars at once on a 16 GB runner.
|
||||
./gradlew "${TASKS[@]}" --parallel \
|
||||
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g"
|
||||
working-directory: agents
|
||||
- run: python3 scripts/validate_agent_jars.py
|
||||
working-directory: agents
|
||||
@@ -272,7 +277,6 @@ jobs:
|
||||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
@@ -284,11 +288,105 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
# Go 1.20 is the last release that supports Windows 7; keep other targets on Go 1.22.
|
||||
- name: Set up Go for Windows 7-compatible RabbitMQ agent
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.20.14"
|
||||
cache-dependency-path: agents/drivers/rabbitmq/go.sum
|
||||
- name: Build Windows 7-compatible RabbitMQ agent
|
||||
shell: bash
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
run: |
|
||||
output="../../../release-native/dbx-agent-rabbitmq-windows-x64.exe"
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
go version -m "$output" | tee /tmp/rabbitmq-windows-x64-build-info.txt
|
||||
grep -q ': go1\.20\.14$' /tmp/rabbitmq-windows-x64-build-info.txt
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rabbitmq-native
|
||||
path: "release-native/dbx-agent-rabbitmq-*"
|
||||
|
||||
build-rocketmq-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'rocketmq') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
- name: Test RocketMQ native agent
|
||||
working-directory: agents/drivers/rocketmq
|
||||
run: go test -race ./...
|
||||
- name: Cross-compile RocketMQ native agent
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-native
|
||||
cd agents/drivers/rocketmq
|
||||
declare -A TARGETS=(
|
||||
["macos-aarch64"]="darwin/arm64"
|
||||
["macos-x64"]="darwin/amd64"
|
||||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
output="../../../release-native/dbx-agent-rocketmq-${platform}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
output="${output}.exe"
|
||||
fi
|
||||
echo "Building $platform ($goos/$goarch)"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rocketmq-native
|
||||
path: "release-native/dbx-agent-rocketmq-*"
|
||||
|
||||
build-zookeeper-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'zookeeper') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
- name: Test ZooKeeper native agent
|
||||
working-directory: agents/drivers/zookeeper
|
||||
run: go test -race ./...
|
||||
- name: Cross-compile ZooKeeper native agent
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-native
|
||||
cd agents/drivers/zookeeper
|
||||
declare -A TARGETS=(
|
||||
["macos-aarch64"]="darwin/arm64"
|
||||
["macos-x64"]="darwin/amd64"
|
||||
["linux-aarch64"]="linux/arm64"
|
||||
["linux-x64"]="linux/amd64"
|
||||
["windows-aarch64"]="windows/arm64"
|
||||
["windows-x64"]="windows/amd64"
|
||||
)
|
||||
for platform in "${!TARGETS[@]}"; do
|
||||
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
|
||||
output="../../../release-native/dbx-agent-zookeeper-${platform}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
output="${output}.exe"
|
||||
fi
|
||||
echo "Building $platform ($goos/$goarch)"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
ls -lh ../../../release-native
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: zookeeper-native
|
||||
path: "release-native/dbx-agent-zookeeper-*"
|
||||
|
||||
build-cassandra-native:
|
||||
needs: [bump-versions]
|
||||
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'cassandra') }}
|
||||
@@ -868,7 +966,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
|
||||
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-rocketmq-native, build-zookeeper-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
|
||||
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -889,6 +987,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ steps.release-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
@@ -985,11 +1088,13 @@ jobs:
|
||||
native_only_label() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
kingbase) echo "人大金仓 KingbaseES" ;;
|
||||
kingbase) echo "金仓KingbaseES" ;;
|
||||
vastbase) echo "Vastbase" ;;
|
||||
duckdb) echo "DuckDB" ;;
|
||||
xugu) echo "虚谷 XuguDB" ;;
|
||||
rabbitmq) echo "RabbitMQ" ;;
|
||||
rocketmq) echo "Apache RocketMQ" ;;
|
||||
zookeeper) echo "Apache ZooKeeper" ;;
|
||||
cassandra) echo "Apache Cassandra" ;;
|
||||
hive) echo "Apache Hive" ;;
|
||||
neo4j) echo "Neo4j" ;;
|
||||
@@ -1058,7 +1163,7 @@ jobs:
|
||||
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
|
||||
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
|
||||
done
|
||||
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq cassandra hive tdengine; do
|
||||
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq rocketmq zookeeper cassandra hive tdengine; do
|
||||
version=$(get_module_version "$name")
|
||||
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
|
||||
native_json=$(generate_native_platforms "$name" "$version")
|
||||
@@ -1090,9 +1195,22 @@ jobs:
|
||||
echo "=== agent-registry.json ==="
|
||||
cat release/agent-registry.json
|
||||
|
||||
- name: Build managed JDBC offline payload
|
||||
shell: bash
|
||||
run: |
|
||||
./plugins/jdbc/package.sh
|
||||
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
|
||||
node agents/scripts/build_offline_jdbc_payload.mjs \
|
||||
release \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver"
|
||||
|
||||
- name: Build offline ZIP bundles
|
||||
run: bash agents/scripts/build_offline_zip.sh release
|
||||
|
||||
- name: Verify managed JDBC assets in all offline ZIPs
|
||||
run: node agents/scripts/verify_offline_jdbc_release.mjs release
|
||||
|
||||
- name: Build single-driver packages
|
||||
run: |
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
|
||||
@@ -1122,12 +1240,14 @@ jobs:
|
||||
native_only_label() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
kingbase) echo "人大金仓 KingbaseES" ;;
|
||||
kingbase) echo "金仓KingbaseES" ;;
|
||||
vastbase) echo "Vastbase" ;;
|
||||
duckdb) echo "DuckDB" ;;
|
||||
oracle) echo "Oracle" ;;
|
||||
xugu) echo "虚谷 XuguDB" ;;
|
||||
rabbitmq) echo "RabbitMQ" ;;
|
||||
rocketmq) echo "Apache RocketMQ" ;;
|
||||
zookeeper) echo "Apache ZooKeeper" ;;
|
||||
cassandra) echo "Apache Cassandra" ;;
|
||||
hive) echo "Apache Hive" ;;
|
||||
neo4j) echo "Neo4j" ;;
|
||||
@@ -1177,6 +1297,11 @@ jobs:
|
||||
"agents/go-common/gohive/"
|
||||
"agents/go-common/gosasl/"
|
||||
)
|
||||
elif [ "$name" = "zookeeper" ]; then
|
||||
LOG_PATHS+=(
|
||||
"agents/go-common/go-gssapi/"
|
||||
"agents/go-common/gosasl/"
|
||||
)
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && NOTES="${NOTES}- ${line}"$'\n'
|
||||
|
||||
+185
-4
@@ -71,6 +71,13 @@ jobs:
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. These jobs are
|
||||
# native Linux builds, so plain CC/CXX select the target compiler.
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
@@ -337,8 +344,8 @@ jobs:
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The fast lane skips only system font discovery while retaining the other default capabilities.
|
||||
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
|
||||
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
|
||||
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
|
||||
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
|
||||
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
|
||||
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
@@ -378,6 +385,13 @@ jobs:
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. These jobs are
|
||||
# native Linux builds, so plain CC/CXX select the target compiler.
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
@@ -416,8 +430,8 @@ jobs:
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The fast lane skips only system font discovery while retaining the other default capabilities.
|
||||
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
|
||||
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
|
||||
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
|
||||
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
|
||||
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
|
||||
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
@@ -455,6 +469,13 @@ jobs:
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. These jobs are
|
||||
# native Linux builds, so plain CC/CXX select the target compiler.
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
@@ -526,6 +547,46 @@ jobs:
|
||||
- name: JDBC plugin package check
|
||||
run: ./plugins/jdbc/package.sh
|
||||
|
||||
offline-jdbc-release:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offline_jdbc == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
- name: Cache approved Maven assets
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/dbx-offline-jdbc-maven
|
||||
key: offline-jdbc-${{ hashFiles('apps/desktop/src/lib/database/managedJdbcAssets.json') }}
|
||||
|
||||
- name: Build real managed JDBC payload
|
||||
env:
|
||||
DBX_OFFLINE_JDBC_MAVEN_CACHE: ~/.cache/dbx-offline-jdbc-maven
|
||||
run: |
|
||||
./plugins/jdbc/package.sh
|
||||
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
|
||||
node agents/scripts/build_offline_jdbc_payload.mjs \
|
||||
release \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}.zip" \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${JDBC_VERSION}/bin/dbx-maven-resolver"
|
||||
|
||||
- name: Build and unpack all six platform ZIPs
|
||||
run: |
|
||||
printf '{}\n' > release/agent-registry.json
|
||||
for platform in macos-aarch64 macos-x64 linux-x64 linux-aarch64 windows-x64 windows-aarch64; do
|
||||
printf '%s\n' "$platform" > "release/dbx-jre-21-${platform}.tar.zst"
|
||||
done
|
||||
bash agents/scripts/build_offline_zip.sh release
|
||||
node agents/scripts/verify_offline_jdbc_release.mjs release
|
||||
|
||||
nix-packaging:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.nix == 'true'
|
||||
@@ -548,6 +609,7 @@ jobs:
|
||||
rust: ${{ steps.filter.outputs.rust }}
|
||||
rust_full: ${{ steps.rust-mode.outputs.full }}
|
||||
jdbc: ${{ steps.filter.outputs.jdbc }}
|
||||
offline_jdbc: ${{ steps.filter.outputs.offline_jdbc }}
|
||||
agents: ${{ steps.filter.outputs.agents }}
|
||||
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
@@ -595,6 +657,15 @@ jobs:
|
||||
- '.github/workflows/ci.yml'
|
||||
jdbc:
|
||||
- 'plugins/jdbc/**'
|
||||
offline_jdbc:
|
||||
- 'plugins/jdbc/**'
|
||||
- 'agents/scripts/build_offline_jdbc_payload.mjs'
|
||||
- 'agents/scripts/build_offline_zip.sh'
|
||||
- 'agents/scripts/verify_offline_jdbc_release.mjs'
|
||||
- 'apps/desktop/src/lib/database/managedJdbcAssets.json'
|
||||
- '.github/scripts/offline-jdbc-payload.test.mjs'
|
||||
- '.github/workflows/agents-release.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
agents:
|
||||
- 'agents/**'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
@@ -740,6 +811,14 @@ jobs:
|
||||
run: go test ./...
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
|
||||
- name: RocketMQ native agent tests
|
||||
run: go test -race ./...
|
||||
working-directory: agents/drivers/rocketmq
|
||||
|
||||
- name: ZooKeeper native agent tests
|
||||
run: go test -race ./...
|
||||
working-directory: agents/drivers/zookeeper
|
||||
|
||||
- name: Cassandra native agent tests
|
||||
run: go test ./...
|
||||
working-directory: agents/drivers/cassandra-go
|
||||
@@ -772,6 +851,108 @@ jobs:
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rabbitmq-linux-x64 .
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
|
||||
- name: RocketMQ native agent build
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rocketmq-linux-x64 .
|
||||
working-directory: agents/drivers/rocketmq
|
||||
|
||||
- name: ZooKeeper native agent cross-builds
|
||||
shell: bash
|
||||
working-directory: agents/drivers/zookeeper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64 windows/arm64 windows/amd64; do
|
||||
IFS=/ read -r goos goarch <<< "$target"
|
||||
output="/tmp/dbx-agent-zookeeper-${goos}-${goarch}"
|
||||
[ "$goos" = windows ] && output="${output}.exe"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
|
||||
done
|
||||
|
||||
- name: ZooKeeper native agent integration tests
|
||||
shell: bash
|
||||
working-directory: agents/drivers/zookeeper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for version in 3.4.14 3.5.5 3.7.0 3.9.5; do
|
||||
name="dbx-zookeeper-${version//./-}"
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$name" -p 2181:2181 "zookeeper:$version"
|
||||
cleanup() {
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
ready=false
|
||||
for _ in $(seq 1 90); do
|
||||
if timeout 1 bash -c '</dev/tcp/127.0.0.1/2181' >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$ready" != true ]; then
|
||||
docker logs "$name"
|
||||
exit 1
|
||||
fi
|
||||
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
|
||||
go test -run '^TestZooKeeperIntegration$' -count=1 ./...
|
||||
if [ "$version" = "3.7.0" ]; then
|
||||
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
|
||||
go test -run '^TestZooKeeperLargeChildrenIntegration$' -count=1 ./...
|
||||
fi
|
||||
cleanup
|
||||
trap - EXIT
|
||||
done
|
||||
|
||||
- name: ZooKeeper SASL DIGEST-MD5 integration test
|
||||
shell: bash
|
||||
working-directory: agents/drivers/zookeeper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="dbx-zookeeper-sasl-3-7-0"
|
||||
jaas_file=$(mktemp)
|
||||
cat > "$jaas_file" <<'EOF'
|
||||
Server {
|
||||
org.apache.zookeeper.server.auth.DigestLoginModule required
|
||||
user_dbx="dbx-secret";
|
||||
};
|
||||
EOF
|
||||
chmod 0644 "$jaas_file"
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$name" -p 2181:2181 \
|
||||
-v "$jaas_file:/conf/server-jaas.conf:ro" \
|
||||
-e ZOO_CFG_EXTRA="authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider" \
|
||||
-e JVMFLAGS="-Djava.security.auth.login.config=/conf/server-jaas.conf -Dzookeeper.sasl.serverconfig=Server -Dzookeeper.sessionRequireClientSASLAuth=true" \
|
||||
zookeeper:3.7.0
|
||||
cleanup() {
|
||||
docker rm -fv "$name" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
ready=false
|
||||
for _ in $(seq 1 90); do
|
||||
if timeout 1 bash -c '</dev/tcp/127.0.0.1/2181' >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$ready" != true ]; then
|
||||
docker logs "$name"
|
||||
exit 1
|
||||
fi
|
||||
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
|
||||
DBX_ZOOKEEPER_TEST_AUTH_SCHEME=sasl_digest \
|
||||
DBX_ZOOKEEPER_TEST_USERNAME=dbx \
|
||||
DBX_ZOOKEEPER_TEST_PASSWORD=dbx-secret \
|
||||
go test -run '^TestZooKeeperIntegration$' -count=1 ./...
|
||||
|
||||
- name: RocketMQ native agent integration tests
|
||||
shell: bash
|
||||
working-directory: agents/drivers/rocketmq
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for version in 4.9.8 5.3.1; do
|
||||
./scripts/run-integration.sh "$version"
|
||||
done
|
||||
|
||||
- name: Cassandra native agent build
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-cassandra-linux-x64 .
|
||||
working-directory: agents/drivers/cassandra-go
|
||||
|
||||
@@ -117,11 +117,10 @@ jobs:
|
||||
const updateLockDependency = (dependency, packageDirectory) => {
|
||||
const escapedDependency = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const dependencyPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+(\\n\\s+version: )([^\\n]+)`);
|
||||
const updated = lockfile.replace(dependencyPattern, `$1${version}$2link:../${packageDirectory}`);
|
||||
if (updated === lockfile) {
|
||||
if (!dependencyPattern.test(lockfile)) {
|
||||
throw new Error(`Unable to update ${dependency} in pnpm-lock.yaml.`);
|
||||
}
|
||||
lockfile = updated;
|
||||
lockfile = lockfile.replace(dependencyPattern, `$1${version}$2link:../${packageDirectory}`);
|
||||
};
|
||||
for (const dependency of Object.keys(mcpPackage.optionalDependencies ?? {})) {
|
||||
updateLockDependency(dependency, dependency.replace("@dbx-app/", ""));
|
||||
@@ -194,6 +193,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
|
||||
- name: Download package release patch
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -201,15 +201,10 @@ jobs:
|
||||
name: package-release-patch
|
||||
|
||||
- name: Apply package release patch and push
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ needs.prepare.outputs.tag }}"
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
if [ -n "${RELEASE_TOKEN}" ]; then
|
||||
git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main --no-tags
|
||||
@@ -348,6 +343,22 @@ jobs:
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Keep the server alive through the post-compile packaging tail so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. All matrix legs
|
||||
# are native builds, so plain CC/CXX select the target compiler;
|
||||
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
|
||||
case "${RUNNER_OS}" in
|
||||
Linux)
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
;;
|
||||
macOS)
|
||||
echo "CC=${SCCACHE_PATH} clang"
|
||||
echo "CXX=${SCCACHE_PATH} clang++"
|
||||
;;
|
||||
esac
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
@@ -357,6 +368,34 @@ jobs:
|
||||
cache-targets: false
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Setup full Perl for vendored OpenSSL on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
PERL_ARCHIVE_URL: https://github.com/shogo82148/build-perl/releases/download/perl-5.42.3-20260803012115/perl-5.42.3-thr-win32-x64.zip
|
||||
PERL_ARCHIVE_SHA256: 4f0ac6fe1a4221c1fa1804c2d81999bdecebd505a61d0d04720e9b3925cd6db4
|
||||
run: |
|
||||
$archive = Join-Path $env:RUNNER_TEMP "perl-5.42.3-thr-win32-x64.zip"
|
||||
$destination = Join-Path $env:RUNNER_TEMP "openssl-perl"
|
||||
curl.exe --fail --location --retry 5 --retry-all-errors $env:PERL_ARCHIVE_URL --output $archive
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to download the full Perl runtime."
|
||||
}
|
||||
$actualHash = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $env:PERL_ARCHIVE_SHA256) {
|
||||
throw "Unexpected Perl archive SHA256: $actualHash"
|
||||
}
|
||||
Expand-Archive -Path $archive -DestinationPath $destination -Force
|
||||
$perl = Get-ChildItem -Path $destination -Filter perl.exe -Recurse | Where-Object { $_.FullName -match '[\\/]bin[\\/]perl\.exe$' } | Select-Object -First 1
|
||||
if (-not $perl) {
|
||||
throw "Unable to locate perl.exe in the downloaded runtime."
|
||||
}
|
||||
& $perl.FullName -MParams::Check -e 1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "The downloaded Perl runtime cannot load Params::Check."
|
||||
}
|
||||
"OPENSSL_SRC_PERL=$($perl.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install Linux native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -460,7 +499,7 @@ jobs:
|
||||
echo "@dbx-app/cli@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
pnpm --filter "@dbx-app/cli" publish --access public --provenance --no-git-checks
|
||||
pnpm publish "./packages/cli" --access public --provenance --no-git-checks
|
||||
|
||||
publish-mcp-platforms:
|
||||
name: Publish ${{ matrix.package-name }}
|
||||
@@ -538,6 +577,22 @@ jobs:
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Keep the server alive through the post-compile packaging tail so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. All matrix legs
|
||||
# are native builds, so plain CC/CXX select the target compiler;
|
||||
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
|
||||
case "${RUNNER_OS}" in
|
||||
Linux)
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
;;
|
||||
macOS)
|
||||
echo "CC=${SCCACHE_PATH} clang"
|
||||
echo "CXX=${SCCACHE_PATH} clang++"
|
||||
;;
|
||||
esac
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
@@ -547,6 +602,34 @@ jobs:
|
||||
cache-targets: false
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Setup full Perl for vendored OpenSSL on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
PERL_ARCHIVE_URL: https://github.com/shogo82148/build-perl/releases/download/perl-5.42.3-20260803012115/perl-5.42.3-thr-win32-x64.zip
|
||||
PERL_ARCHIVE_SHA256: 4f0ac6fe1a4221c1fa1804c2d81999bdecebd505a61d0d04720e9b3925cd6db4
|
||||
run: |
|
||||
$archive = Join-Path $env:RUNNER_TEMP "perl-5.42.3-thr-win32-x64.zip"
|
||||
$destination = Join-Path $env:RUNNER_TEMP "openssl-perl"
|
||||
curl.exe --fail --location --retry 5 --retry-all-errors $env:PERL_ARCHIVE_URL --output $archive
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to download the full Perl runtime."
|
||||
}
|
||||
$actualHash = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $env:PERL_ARCHIVE_SHA256) {
|
||||
throw "Unexpected Perl archive SHA256: $actualHash"
|
||||
}
|
||||
Expand-Archive -Path $archive -DestinationPath $destination -Force
|
||||
$perl = Get-ChildItem -Path $destination -Filter perl.exe -Recurse | Where-Object { $_.FullName -match '[\\/]bin[\\/]perl\.exe$' } | Select-Object -First 1
|
||||
if (-not $perl) {
|
||||
throw "Unable to locate perl.exe in the downloaded runtime."
|
||||
}
|
||||
& $perl.FullName -MParams::Check -e 1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "The downloaded Perl runtime cannot load Params::Check."
|
||||
}
|
||||
"OPENSSL_SRC_PERL=$($perl.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install Linux native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -660,7 +743,7 @@ jobs:
|
||||
echo "@dbx-app/mcp-server@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
pnpm --filter "@dbx-app/mcp-server" publish --access public --provenance --no-git-checks
|
||||
pnpm publish "./packages/mcp-server" --access public --provenance --no-git-checks
|
||||
|
||||
publish-mcp-github-release:
|
||||
name: Publish MCP GitHub Release assets
|
||||
|
||||
@@ -193,8 +193,9 @@ jobs:
|
||||
# ${AI_ANALYSIS}"
|
||||
# fi
|
||||
|
||||
MSG=$(echo "$MSG_CONTENT" | jq -Rs .)
|
||||
ssh "root@${HOST}" "curl -s -X POST http://localhost:6185/api/v1/im/message \
|
||||
PAYLOAD=$(jq -nc --arg msg "$MSG_CONTENT" \
|
||||
'{"umo": "default:GroupMessage:1087880322", "message": $msg}')
|
||||
printf '%s' "$PAYLOAD" | ssh "root@${HOST}" "curl -fsS -X POST http://localhost:6185/api/v1/im/message \
|
||||
-H 'Authorization: Bearer ${ASTRBOT_API_KEY}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{\"umo\": \"default:GroupMessage:1087880322\", \"message\": ${MSG}}'"
|
||||
--data-binary @-"
|
||||
|
||||
@@ -452,6 +452,7 @@ jobs:
|
||||
PYEOF
|
||||
|
||||
- name: Send to QQ group
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
|
||||
+268
-78
@@ -62,6 +62,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
|
||||
- name: Find previous release tag
|
||||
id: prev-tag
|
||||
@@ -89,6 +90,7 @@ jobs:
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
cp plugins/jdbc/build.gradle "$TMP_DIR/build.gradle"
|
||||
cp plugins/jdbc/manifest.json "$TMP_DIR/manifest.json"
|
||||
git restore plugins/jdbc/build.gradle plugins/jdbc/manifest.json
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
@@ -128,12 +130,6 @@ jobs:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- platform: windows-2022
|
||||
target: x86_64-pc-windows-msvc
|
||||
arch: x64
|
||||
- platform: windows-2022
|
||||
target: aarch64-pc-windows-msvc
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
@@ -184,6 +180,23 @@ jobs:
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Linking, bundling, and signing run for 30+ minutes after the last
|
||||
# rustc call; the server's 10-minute default idle timeout would
|
||||
# exit first and zero the counters shown by "Show sccache stats".
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. All matrix legs
|
||||
# are native builds, so plain CC/CXX select the target compiler;
|
||||
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
|
||||
case "${RUNNER_OS}" in
|
||||
Linux)
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
;;
|
||||
macOS)
|
||||
echo "CC=${SCCACHE_PATH} clang"
|
||||
echo "CXX=${SCCACHE_PATH} clang++"
|
||||
;;
|
||||
esac
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Compute Rust dependency hash
|
||||
@@ -230,7 +243,9 @@ jobs:
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
|
||||
# Bundle GTK input-method modules from the same Jammy ABI baseline
|
||||
# instead of loading potentially incompatible modules from the host.
|
||||
sudo apt-get install -y fcitx5-frontend-gtk3 ibus-gtk3 libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
|
||||
|
||||
- name: Setup Tauri signing key
|
||||
shell: bash
|
||||
@@ -255,18 +270,212 @@ jobs:
|
||||
releaseDraft: true
|
||||
args: --target ${{ matrix.target }}
|
||||
|
||||
- name: Verify AppImage input methods
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
shell: bash
|
||||
run: ./.github/scripts/verify-appimage-input-methods.sh "target/${{ matrix.target }}/release/bundle/appimage"
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
build-windows:
|
||||
needs: release-ready
|
||||
environment: release-signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-pc-windows-msvc
|
||||
arch: x64
|
||||
- target: aarch64-pc-windows-msvc
|
||||
arch: arm64
|
||||
runs-on: windows-2022
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
|
||||
VSIGN_OPERATOR_SHA1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
|
||||
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
|
||||
VSIGN_TOOL_SHA256: ${{ vars.VSIGN_TOOL_SHA256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
version: "v0.10.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Linking, bundling, and signing run for 30+ minutes after the last
|
||||
# rustc call; the server's 10-minute default idle timeout would
|
||||
# exit first and zero the counters shown by "Show sccache stats".
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. All matrix legs
|
||||
# are native builds, so plain CC/CXX select the target compiler;
|
||||
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
|
||||
case "${RUNNER_OS}" in
|
||||
Linux)
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
;;
|
||||
macOS)
|
||||
echo "CC=${SCCACHE_PATH} clang"
|
||||
echo "CXX=${SCCACHE_PATH} clang++"
|
||||
;;
|
||||
esac
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Compute Rust dependency hash
|
||||
id: deps-hash
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
find . -name Cargo.toml -not -path './target/*' -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sed -E '/^version = /d'
|
||||
grep -v '^version = ' Cargo.lock
|
||||
} | sha256sum | cut -d' ' -f1 | {
|
||||
read -r hash
|
||||
echo "hash=${hash:0:20}" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./ -> target"
|
||||
shared-key: release-${{ matrix.target }}-${{ steps.deps-hash.outputs.hash }}
|
||||
add-rust-environment-hash-key: false
|
||||
cache-targets: false
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Setup Tauri signing key
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/updater.key"
|
||||
KEY_B64=$(base64 < "$RUNNER_TEMP/updater.key" | tr -d '\r\n')
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
|
||||
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Setup TrustAsia VSign
|
||||
shell: pwsh
|
||||
env:
|
||||
VSIGN_OPERATOR_PFX_B64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
|
||||
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
run: ./.github/scripts/setup-vsign.ps1
|
||||
|
||||
- name: Build and sign Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseDraft: true
|
||||
args: --target ${{ matrix.target }} --config src-tauri/tauri.vsign.conf.json
|
||||
|
||||
- name: Verify Windows bundles
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "target/${{ matrix.target }}/release/bundle"
|
||||
$nsisInstallers = @(
|
||||
Get-ChildItem (Join-Path $bundleRoot "nsis") -Filter "*.exe" -File -ErrorAction SilentlyContinue
|
||||
)
|
||||
$msiInstallers = @(
|
||||
Get-ChildItem (Join-Path $bundleRoot "msi") -Filter "*.msi" -File -ErrorAction SilentlyContinue
|
||||
)
|
||||
if ($nsisInstallers.Count -eq 0) {
|
||||
throw "No NSIS installer was produced for ${{ matrix.target }}"
|
||||
}
|
||||
if ($msiInstallers.Count -eq 0) {
|
||||
throw "No MSI installer was produced for ${{ matrix.target }}"
|
||||
}
|
||||
./.github/scripts/assert-authenticode.ps1 -Path @(
|
||||
$nsisInstallers.FullName
|
||||
$msiInstallers.FullName
|
||||
)
|
||||
|
||||
- name: Upload Windows WebView2 offline installer
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$bundleDir = "target/${{ matrix.target }}/release/bundle/nsis"
|
||||
$offlineName = "DBX_${version}_${arch}-webview2-offline-setup.exe"
|
||||
$before = @{}
|
||||
|
||||
if (Test-Path $bundleDir) {
|
||||
Get-ChildItem $bundleDir -Filter "*.exe" | ForEach-Object { $before[$_.FullName] = $_.LastWriteTimeUtc }
|
||||
}
|
||||
|
||||
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} `
|
||||
--config src-tauri/tauri.webview2-offline.conf.json `
|
||||
--config src-tauri/tauri.vsign.conf.json
|
||||
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Where-Object { !$before.ContainsKey($_.FullName) -or $_.LastWriteTimeUtc -gt $before[$_.FullName] } |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (!$installer) {
|
||||
throw "Missing WebView2 offline installer in ${bundleDir}"
|
||||
}
|
||||
|
||||
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
|
||||
Copy-Item $installer.FullName $offlineName -Force
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Upload Windows portable ZIP
|
||||
if: matrix.arch == 'x64' || matrix.arch == 'arm64'
|
||||
timeout-minutes: 20
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$arch = "${{ matrix.arch }}"
|
||||
@@ -278,14 +487,15 @@ jobs:
|
||||
$appVersion = ($cargoMetadata.packages | Where-Object { $_.name -eq "dbx" } | Select-Object -First 1).version
|
||||
|
||||
if (!(Test-Path $exePath)) {
|
||||
Write-Error "Missing Windows executable: $exePath"
|
||||
exit 1
|
||||
throw "Missing Windows executable: $exePath"
|
||||
}
|
||||
if (!$appVersion -or $appVersion -ne $version) {
|
||||
Write-Error "Release tag version $version does not match the built DBX package version $appVersion"
|
||||
exit 1
|
||||
throw "Release tag version $version does not match the built DBX package version $appVersion"
|
||||
}
|
||||
|
||||
./.github/scripts/vsign-sign.ps1 -FilePath $exePath
|
||||
./.github/scripts/assert-authenticode.ps1 -Path $exePath
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item $exePath (Join-Path $portableDir "DBX.exe") -Force
|
||||
Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force
|
||||
@@ -313,51 +523,41 @@ jobs:
|
||||
|
||||
$signatureName = "${zipName}.sig"
|
||||
if (!(Test-Path $signatureName)) {
|
||||
Write-Error "Missing portable update signature: $signatureName"
|
||||
exit 1
|
||||
throw "Missing portable update signature: $signatureName"
|
||||
}
|
||||
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $zipName $signatureName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Upload Windows WebView2 offline installer
|
||||
if: matrix.arch == 'x64' || matrix.arch == 'arm64'
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
- name: Remove temporary VSign files
|
||||
if: always()
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$bundleDir = "target/${{ matrix.target }}/release/bundle/nsis"
|
||||
$offlineName = "DBX_${version}_${arch}-webview2-offline-setup.exe"
|
||||
$before = @{}
|
||||
|
||||
if (Test-Path $bundleDir) {
|
||||
Get-ChildItem $bundleDir -Filter "*.exe" | ForEach-Object { $before[$_.FullName] = $_.LastWriteTimeUtc }
|
||||
$workDir = if ([string]::IsNullOrWhiteSpace($env:VSIGN_WORK_DIR)) {
|
||||
Join-Path $env:RUNNER_TEMP "vsign-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
|
||||
} else {
|
||||
$env:VSIGN_WORK_DIR
|
||||
}
|
||||
|
||||
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} --config src-tauri/tauri.webview2-offline.conf.json
|
||||
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Where-Object { !$before.ContainsKey($_.FullName) -or $_.LastWriteTimeUtc -gt $before[$_.FullName] } |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (!$installer) {
|
||||
Write-Error "Missing WebView2 offline installer in ${bundleDir}"
|
||||
exit 1
|
||||
if (Test-Path -LiteralPath $workDir) {
|
||||
Remove-Item -LiteralPath $workDir -Recurse -Force
|
||||
}
|
||||
|
||||
Copy-Item $installer.FullName $offlineName -Force
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
build-windows-7-offline:
|
||||
needs: release-ready
|
||||
runs-on: windows-2022
|
||||
environment: release-signing
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
|
||||
VSIGN_OPERATOR_SHA1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
|
||||
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
|
||||
VSIGN_TOOL_SHA256: ${{ vars.VSIGN_TOOL_SHA256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -378,35 +578,6 @@ jobs:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
# sccache must be set up before the WebView2 loader/runtime scripts: they
|
||||
# run `cargo fetch` under the job-wide RUSTC_WRAPPER=sccache, so installing
|
||||
# sccache only after them made cargo abort with "program not found".
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
with:
|
||||
version: "v0.10.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
@@ -441,10 +612,19 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Setup TrustAsia VSign
|
||||
shell: pwsh
|
||||
env:
|
||||
VSIGN_OPERATOR_PFX_B64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
|
||||
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
run: ./.github/scripts/setup-vsign.ps1
|
||||
|
||||
- name: Bundle and upload Windows 7 fixed-runtime installer
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
|
||||
@@ -457,7 +637,9 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc `
|
||||
--config src-tauri/tauri.webview2-win7-fixed.conf.json `
|
||||
--config src-tauri/tauri.vsign.conf.json
|
||||
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
@@ -468,15 +650,23 @@ jobs:
|
||||
}
|
||||
|
||||
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
|
||||
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
|
||||
|
||||
Copy-Item $installer.FullName $offlineName -Force
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Show sccache stats
|
||||
- name: Remove temporary VSign files
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
shell: pwsh
|
||||
run: |
|
||||
$workDir = if ([string]::IsNullOrWhiteSpace($env:VSIGN_WORK_DIR)) {
|
||||
Join-Path $env:RUNNER_TEMP "vsign-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
|
||||
} else {
|
||||
$env:VSIGN_WORK_DIR
|
||||
}
|
||||
if (Test-Path -LiteralPath $workDir) {
|
||||
Remove-Item -LiteralPath $workDir -Recurse -Force
|
||||
}
|
||||
|
||||
static-browser:
|
||||
# Fully static musl builds of the browser (dbx-web) variant. No glibc
|
||||
@@ -535,7 +725,7 @@ jobs:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
run: |
|
||||
cargo zigbuild --release -p dbx-web --target ${{ matrix.target }} \
|
||||
--no-default-features --features "duckdb-sidecar,mq-admin"
|
||||
--no-default-features --features "duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled"
|
||||
|
||||
- name: Package static browser runtime
|
||||
env:
|
||||
@@ -581,7 +771,7 @@ jobs:
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
cleanup-release-signatures:
|
||||
needs: [build, build-windows-7-offline]
|
||||
needs: [build, build-windows, build-windows-7-offline]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove standalone updater signature assets
|
||||
|
||||
@@ -31,11 +31,29 @@ jobs:
|
||||
pip install awscli --quiet
|
||||
aws s3 cp releases-cn.json "s3://${R2_BUCKET_NAME}/changelog/releases-cn.json" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--content-type application/json
|
||||
--content-type application/json \
|
||||
--cache-control "public, max-age=300, stale-while-revalidate=86400"
|
||||
aws s3 cp index-cn.json "s3://${R2_BUCKET_NAME}/changelog/index-cn.json" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--content-type application/json \
|
||||
--cache-control "public, max-age=300, stale-while-revalidate=86400"
|
||||
aws s3 sync releases-cn/ "s3://${R2_BUCKET_NAME}/changelog/releases-cn/" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--delete --content-type application/json \
|
||||
--cache-control "public, max-age=86400, stale-while-revalidate=604800"
|
||||
if [ -f releases-en.json ]; then
|
||||
aws s3 cp releases-en.json "s3://${R2_BUCKET_NAME}/changelog/releases-en.json" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--content-type application/json
|
||||
--content-type application/json \
|
||||
--cache-control "public, max-age=300, stale-while-revalidate=86400"
|
||||
aws s3 cp index-en.json "s3://${R2_BUCKET_NAME}/changelog/index-en.json" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--content-type application/json \
|
||||
--cache-control "public, max-age=300, stale-while-revalidate=86400"
|
||||
aws s3 sync releases-en/ "s3://${R2_BUCKET_NAME}/changelog/releases-en/" \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
|
||||
--delete --content-type application/json \
|
||||
--cache-control "public, max-age=86400, stale-while-revalidate=604800"
|
||||
fi
|
||||
if [ -f latest-en.json ]; then
|
||||
aws s3 cp latest-en.json "s3://${R2_BUCKET_NAME}/changelog/latest-en.json" \
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
name: VSign Operator Bootstrap
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "trustasia-vsign-init-*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bootstrap-operator:
|
||||
runs-on: windows-2022
|
||||
environment: release-signing
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Download and verify VSign CLI
|
||||
shell: pwsh
|
||||
env:
|
||||
VSIGN_CLI_URL: https://github.com/g5wsg/vsign-github-test1/releases/download/vsign-cli-v1/ssigncode.exe
|
||||
VSIGN_CLI_SHA256: 25A680C653CF0E89BFD53D0A2399F4D748E33DE6644E15B0DAB29974765BFA40
|
||||
run: |
|
||||
$toolPath = Join-Path $env:RUNNER_TEMP "ssigncode.exe"
|
||||
Invoke-WebRequest -Uri $env:VSIGN_CLI_URL -OutFile $toolPath
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $toolPath -Algorithm SHA256).Hash
|
||||
if ($actualHash -ne $env:VSIGN_CLI_SHA256) {
|
||||
throw "VSign CLI SHA256 mismatch. Expected $env:VSIGN_CLI_SHA256, got $actualHash"
|
||||
}
|
||||
|
||||
"VSIGN_CLI=$toolPath" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
Write-Host "VSign CLI SHA256: $actualHash"
|
||||
|
||||
- name: Inspect VSign CLI commands
|
||||
if: startsWith(github.ref_name, 'trustasia-vsign-init-probe-')
|
||||
shell: pwsh
|
||||
run: |
|
||||
& $env:VSIGN_CLI version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "VSign CLI version command failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
& $env:VSIGN_CLI install --help
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "VSign CLI install help failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
& $env:VSIGN_CLI vsign --help
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "VSign CLI vsign help failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
- name: Install and export VSign operator
|
||||
if: startsWith(github.ref_name, 'trustasia-vsign-init-run-')
|
||||
shell: pwsh
|
||||
env:
|
||||
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
|
||||
VSIGN_OPERATOR_TOKEN: ${{ secrets.VSIGN_OPERATOR_TOKEN }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_ARTIFACT_PWD: ${{ secrets.VSIGN_ARTIFACT_PWD }}
|
||||
run: |
|
||||
$requiredValues = @{
|
||||
VSIGN_SERVER = $env:VSIGN_SERVER
|
||||
VSIGN_OPERATOR_TOKEN = $env:VSIGN_OPERATOR_TOKEN
|
||||
VSIGN_KEY_PIN = $env:VSIGN_KEY_PIN
|
||||
VSIGN_OPERATOR_PWD = $env:VSIGN_OPERATOR_PWD
|
||||
VSIGN_ARTIFACT_PWD = $env:VSIGN_ARTIFACT_PWD
|
||||
}
|
||||
foreach ($entry in $requiredValues.GetEnumerator()) {
|
||||
if ([string]::IsNullOrWhiteSpace($entry.Value)) {
|
||||
throw "$($entry.Key) is not configured"
|
||||
}
|
||||
}
|
||||
|
||||
$operatorDir = Join-Path $env:RUNNER_TEMP "vsign-operator"
|
||||
$operatorFile = Join-Path $operatorDir "dbx-github-ci@ssigncode.pfx"
|
||||
$bundleDir = Join-Path $env:RUNNER_TEMP "vsign-bundle"
|
||||
$bundlePath = Join-Path $env:RUNNER_TEMP "vsign-operator-bundle.7z"
|
||||
New-Item -ItemType Directory -Force -Path $operatorDir, $bundleDir | Out-Null
|
||||
|
||||
$env:SSIGNCODE_OPERATOR_STORE = "file"
|
||||
$env:SSIGNCODE_OPERATOR_DIR = $operatorDir
|
||||
$env:SSIGNCODE_OPERATOR_FILE = $operatorFile
|
||||
$env:SSIGNCODE_OPERATOR_PWD = $env:VSIGN_OPERATOR_PWD
|
||||
|
||||
& $env:VSIGN_CLI install `
|
||||
-s $env:VSIGN_SERVER `
|
||||
--auth $env:VSIGN_OPERATOR_TOKEN `
|
||||
--vsign `
|
||||
--key_pin $env:VSIGN_KEY_PIN
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "VSign operator installation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$pfxCandidates = @(
|
||||
Get-ChildItem -LiteralPath $operatorDir -Filter "*.pfx" -File -Recurse -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -LiteralPath $env:USERPROFILE -Filter "*@ssigncode.pfx" -File -Recurse -ErrorAction SilentlyContinue
|
||||
) | Sort-Object LastWriteTimeUtc -Descending -Unique
|
||||
|
||||
if ($pfxCandidates.Count -eq 0) {
|
||||
throw "VSign operator installation completed but no PFX file was found"
|
||||
}
|
||||
|
||||
$sourcePfx = $pfxCandidates[0]
|
||||
$flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable `
|
||||
-bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
|
||||
$operatorCert = $null
|
||||
foreach ($candidatePassword in @($env:VSIGN_OPERATOR_PWD, $env:VSIGN_KEY_PIN, "")) {
|
||||
try {
|
||||
$operatorCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
$sourcePfx.FullName,
|
||||
$candidatePassword,
|
||||
$flags
|
||||
)
|
||||
if ($operatorCert.HasPrivateKey) {
|
||||
break
|
||||
}
|
||||
$operatorCert.Dispose()
|
||||
$operatorCert = $null
|
||||
}
|
||||
catch {
|
||||
$operatorCert = $null
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $operatorCert) {
|
||||
throw "The generated operator PFX could not be opened with the configured passwords"
|
||||
}
|
||||
|
||||
try {
|
||||
$exportedPfx = Join-Path $bundleDir "dbx-github-ci@ssigncode.pfx"
|
||||
$pfxBytes = $operatorCert.Export(
|
||||
[System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx,
|
||||
$env:VSIGN_OPERATOR_PWD
|
||||
)
|
||||
[IO.File]::WriteAllBytes($exportedPfx, $pfxBytes)
|
||||
|
||||
$thumbprint = $operatorCert.Thumbprint.Replace(" ", "").ToUpperInvariant()
|
||||
$metadata = [ordered]@{
|
||||
operator = "dbx-github-ci"
|
||||
sha1 = $thumbprint
|
||||
expires = $operatorCert.NotAfter.ToUniversalTime().ToString("o")
|
||||
pfx_sha256 = (Get-FileHash -LiteralPath $exportedPfx -Algorithm SHA256).Hash
|
||||
run_id = $env:GITHUB_RUN_ID
|
||||
}
|
||||
$metadata | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $bundleDir "metadata.json") -Encoding utf8
|
||||
|
||||
$sevenZip = "C:\Program Files\7-Zip\7z.exe"
|
||||
if (!(Test-Path -LiteralPath $sevenZip -PathType Leaf)) {
|
||||
throw "7-Zip was not found on the Windows runner"
|
||||
}
|
||||
|
||||
& $sevenZip a -t7z "-p$env:VSIGN_ARTIFACT_PWD" -mhe=on $bundlePath (Join-Path $bundleDir "*")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Encrypted operator bundle creation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
"VSIGN_OPERATOR_SHA1=$thumbprint" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VSIGN_BUNDLE_PATH=$bundlePath" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
Write-Host "Operator PFX created and encrypted for retrieval."
|
||||
Write-Host "Operator certificate SHA1: $thumbprint"
|
||||
}
|
||||
finally {
|
||||
$operatorCert.Dispose()
|
||||
}
|
||||
|
||||
- name: Upload encrypted operator bundle
|
||||
if: startsWith(github.ref_name, 'trustasia-vsign-init-run-')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vsign-operator-${{ github.run_id }}
|
||||
path: ${{ env.VSIGN_BUNDLE_PATH }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Remove temporary VSign files
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($path in @(
|
||||
(Join-Path $env:RUNNER_TEMP "ssigncode.exe"),
|
||||
(Join-Path $env:RUNNER_TEMP "vsign-operator"),
|
||||
(Join-Path $env:RUNNER_TEMP "vsign-bundle"),
|
||||
(Join-Path $env:RUNNER_TEMP "vsign-operator-bundle.7z")
|
||||
)) {
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
Remove-Item -LiteralPath $path -Recurse -Force
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -1,7 +1,8 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
node_modules
|
||||
.claude/
|
||||
.codex/
|
||||
.cortexkit/
|
||||
.workbuddy/
|
||||
.superpowers/
|
||||
.trellis/
|
||||
@@ -15,10 +16,13 @@ CLAUDE.md
|
||||
dist/
|
||||
coverage/
|
||||
/target/
|
||||
vendor/wry/target/
|
||||
vendor/wry/Cargo.lock
|
||||
src-tauri/webview2-fixed-runtime/
|
||||
plugins/jdbc/target/
|
||||
plugins/jdbc/dependency-reduced-pom.xml
|
||||
plugins/jdbc/lib/*.jar
|
||||
agents/drivers/duckdb/target/
|
||||
plugins/jdbc/.gradle/
|
||||
plugins/jdbc/build/
|
||||
plugins/jdbc/dist/
|
||||
@@ -29,6 +33,10 @@ docs/superpowers/
|
||||
/docs/aegis/
|
||||
src-tauri/*.dylib
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -42,6 +50,7 @@ Thumbs.db
|
||||
|
||||
# GitHub (keep workflows)
|
||||
.github/*
|
||||
!.github/CODEOWNERS
|
||||
!.github/workflows/
|
||||
!.github/scripts/
|
||||
!.github/ISSUE_TEMPLATE/
|
||||
@@ -54,6 +63,11 @@ Thumbs.db
|
||||
# Temp
|
||||
tmp/
|
||||
|
||||
# AI background-run dev artifacts (never commit)
|
||||
.dlp-quarantine/
|
||||
outputs/
|
||||
target-ai-write-confirmation/
|
||||
|
||||
# Code review tool artifacts
|
||||
review_diff.json
|
||||
review_classified.json
|
||||
|
||||
Generated
+638
-59
File diff suppressed because it is too large
Load Diff
+16
-8
@@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"]
|
||||
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/wry"]
|
||||
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
|
||||
|
||||
[patch.crates-io]
|
||||
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
|
||||
@@ -17,14 +17,22 @@ pageant = { path = "vendor/pageant" }
|
||||
# bundled Fixed Runtime path explicitly so Windows 7 does not fall back to an
|
||||
# unavailable system Runtime. The vendored copy also intercepts the WebView2
|
||||
# F6 "Focus Next Pane" accelerator to prevent a black screen on frameless
|
||||
# Overlay-titlebar windows (see vendor/wry/src/webview2/mod.rs).
|
||||
# Overlay-titlebar windows, and registers a ProcessFailed handler that
|
||||
# auto-recovers the webview when the renderer process fails while the host
|
||||
# process stays alive (black screen after an overnight idle session, see
|
||||
# vendor/wry/src/webview2/mod.rs). Recovery is layered by process kind
|
||||
# (browser exit is fatal, GPU exits are self-healed by WebView2) and bounded
|
||||
# by a cooldown plus a maximum reload budget.
|
||||
wry = { path = "vendor/wry" }
|
||||
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
# Temporary compatibility patch for MySQL accounts using the deprecated
|
||||
# sha256_password auth plugin. Remove after upstream support lands.
|
||||
mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "b615bb0b10b5975182a4aa069cabc3d09021f6ab" }
|
||||
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
|
||||
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
|
||||
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
|
||||
# Temporary compatibility patches for deprecated sha256_password accounts and
|
||||
# rustls dangerous-mode connections to legacy certificates. Remove after upstream support lands.
|
||||
mysql_async = { git = "https://github.com/zipg/mysql_async.git", rev = "4a9e60a62f5a48e35ef9ccc9fc8277d5012b19dd" }
|
||||
# Preserve readable SQL Server result sets when a row contains an unpaired
|
||||
# UTF-16 surrogate. Remove after the behavior is available in upstream tiberius.
|
||||
tiberius = { path = "vendor/tiberius" }
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
@@ -61,6 +61,10 @@ install:
|
||||
docs-install:
|
||||
cd docs && $(PNPM) install --frozen-lockfile
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
check-tauri-dev-port:
|
||||
@powershell -NoProfile -Command "if (Get-NetTCPConnection -LocalPort $(TAURI_DEV_PORT) -State Listen -ErrorAction SilentlyContinue) { Write-Host 'Port $(TAURI_DEV_PORT) is already in use. DBX Tauri dev requires http://localhost:$(TAURI_DEV_PORT).'; Write-Host ''; Get-NetTCPConnection -LocalPort $(TAURI_DEV_PORT) -State Listen -ErrorAction SilentlyContinue | Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize; Write-Host 'Stop the process above, then run make dev again.'; exit 1 }"
|
||||
else
|
||||
check-tauri-dev-port:
|
||||
@if lsof -nP -iTCP:$(TAURI_DEV_PORT) -sTCP:LISTEN >/dev/null 2>&1; then \
|
||||
echo "Port $(TAURI_DEV_PORT) is already in use. DBX Tauri dev requires http://localhost:$(TAURI_DEV_PORT)."; \
|
||||
@@ -70,12 +74,13 @@ check-tauri-dev-port:
|
||||
echo "Stop the process above, then run make dev again. Example: kill <PID>"; \
|
||||
exit 1; \
|
||||
fi
|
||||
endif
|
||||
|
||||
dev: node_modules/.modules.yaml check-tauri-dev-port
|
||||
$(PNPM) dev:tauri
|
||||
|
||||
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
|
||||
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar
|
||||
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled
|
||||
|
||||
dev-web: node_modules/.modules.yaml
|
||||
$(PNPM) dev:web
|
||||
@@ -105,10 +110,10 @@ test: node_modules/.modules.yaml
|
||||
$(PNPM) test
|
||||
|
||||
cargo-check-fast:
|
||||
cargo check --no-default-features
|
||||
cargo check --no-default-features --features sqlite-bundled
|
||||
|
||||
cargo-test-fast:
|
||||
cargo test --no-default-features
|
||||
cargo test --no-default-features --features sqlite-bundled
|
||||
|
||||
db-list:
|
||||
@$(PNPM) db:env -- list
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>70+ databases in 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX screenshot" width="820" />
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
|
||||
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fshieldcn.dev%2Fgithub%2Fdownloads%2Ft8y2%2Fdbx%2Fshields.json&style=for-the-badge" /></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ1LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="Join QQ Group" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="Join WeChat Group" /></a>
|
||||
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="Join Discord" /></a>
|
||||
@@ -27,18 +27,20 @@
|
||||
<img src="https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/SelectDB-22C1C3?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/StarRocks-5C2D91?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Redshift-8C4FFF?logo=amazonredshift&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Cloud%20Spanner-4285F4?logo=googlecloudspanner&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/DM-3857FF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/OceanBase-006AFF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/openGauss-2B7BD9?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/GaussDB-E60012?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/KWDB-1c60e0?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/KingBase-003B8E?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/KingbaseES-003B8E?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TDengine-2F6FFF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/CockroachDB-6933FF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/InfluxDB-d30971?logo=influxdb&logoColor=white" />
|
||||
@@ -61,6 +63,78 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Sponsors & Partners
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/rainyun-dark.png" />
|
||||
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="RainYun" height="48" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
RainYun is a cloud service provider offering cloud servers, physical servers, game hosting, and developer-friendly infrastructure services.
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.qiniu.com/" target="_blank">
|
||||
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="Qiniu Cloud" height="64" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Qiniu Cloud provides DBX with object storage, CDN, and other cloud infrastructure resources.
|
||||
<a href="https://www.qiniu.com/" target="_blank">Visit Qiniu Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://easysearch.cn" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/easysearch-dark.png" />
|
||||
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Easysearch is an enterprise-grade distributed search engine compatible with Elasticsearch APIs, combining full-text, vector, geospatial search, real-time analytics, and AI capabilities in one platform.
|
||||
<a href="https://easysearch.cn" target="_blank">Visit Easysearch</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/atlas-dark.svg" />
|
||||
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Atlas Cloud gives developers one unified API for 400+ AI models across chat, image, video, and audio.
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">Visit Atlas Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/trustasia-dark.svg" />
|
||||
<img src="docs/public/sponsors/trustasia.png" alt="TrustAsia" height="44" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why DBX?
|
||||
|
||||
<table>
|
||||
@@ -88,9 +162,9 @@
|
||||
|
||||
## Features
|
||||
|
||||
### 70+ Databases, One Tool
|
||||
### 90+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
@@ -388,122 +462,33 @@ The installer will be in `src-tauri/target/release/bundle/`.
|
||||
- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments
|
||||
- [Examples](examples/) — CLI, MCP, Docker, and API samples
|
||||
|
||||
## Community
|
||||
|
||||
<a href="https://discord.gg/W7NyVDRt6a" target="_blank"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://qm.qq.com/q/1087880322" target="_blank"><img src="https://img.shields.io/badge/QQ%20群-1087880322-EB1923?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
[](https://linux.do)
|
||||
|
||||
## Support DBX
|
||||
|
||||
DBX is free and open source, but ongoing maintenance, database compatibility testing, infrastructure, and release work require sustained time and resources.
|
||||
|
||||
### Sponsors & Partners
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="RainYun" height="48" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
RainYun is a cloud service provider offering cloud servers, physical servers, game hosting, and developer-friendly infrastructure services.
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.qiniu.com/" target="_blank">
|
||||
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="Qiniu Cloud" height="64" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Qiniu Cloud provides DBX with object storage, CDN, and other cloud infrastructure resources.
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.qiniu.com/" target="_blank">Visit Qiniu Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://easysearch.cn" target="_blank">
|
||||
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Easysearch is an enterprise-grade distributed search engine compatible with Elasticsearch APIs, combining full-text, vector, geospatial search, real-time analytics, and AI capabilities in one platform.
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://easysearch.cn" target="_blank">Visit Easysearch</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Atlas Cloud gives developers one unified API for 400+ AI models across chat, image, video, and audio.
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">Visit Atlas Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- [Support DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) — voluntary donations via WeChat or Alipay
|
||||
- [Sponsors & Partners](https://my.feishu.cn/wiki/CgOWwwTzzify79k9Oq8cXpUNn6e) — sponsorship, infrastructure, tools, and community collaboration
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><strong>Is DBX free?</strong></summary>
|
||||
Yes. DBX is open source under Apache-2.0. All features are free.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does DBX phone home?</strong></summary>
|
||||
No. DBX does not collect telemetry. The auto-update feature checks GitHub Releases for new versions — you can disable it in settings.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I use DBX without an internet connection?</strong></summary>
|
||||
Yes. The desktop app works fully offline. For air-gapped driver installs, download offline driver packages from the [Offline Drivers page](https://dbxio.com/en/drivers) on an internet-connected machine, transfer them to the offline machine, then import them in DBX from Settings > Driver Manager. AI features need network access to the model endpoint (or a local model via Ollama).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How is DBX different from DBeaver / TablePlus / Beekeeper Studio?</strong></summary>
|
||||
DBX is 20 MB with no runtime dependencies for its native database features (no system Java or Python required). AI is built into the application, while MCP is provided as a separately installed Rust companion package or native binary. It supports 70+ databases across desktop, Docker, and web from a shared Rust core.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>What databases are supported?</strong></summary>
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How do I report a bug or request a feature?</strong></summary>
|
||||
Open an issue on <a href="https://github.com/t8y2/dbx/issues">GitHub Issues</a>.
|
||||
</details>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/t8y2/dbx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=t8y2/dbx" />
|
||||
</a>
|
||||
|
||||
## Community
|
||||
|
||||
<a href="https://discord.gg/W7NyVDRt6a" target="_blank"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://qm.qq.com/q/1087880322" target="_blank"><img src="https://img.shields.io/badge/QQ%20群-1087880322-EB1923?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
[](https://linux.do)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=t8y2%2Fdbx&type=date&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#t8y2/dbx&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+86
-69
@@ -1,12 +1,12 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 70+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260806.png" alt="DBX 截图" width="820" />
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/github/downloads/t8y2/dbx/total?style=for-the-badge&color=blue" /></a>
|
||||
<a href="https://github.com/t8y2/dbx/releases"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fshieldcn.dev%2Fgithub%2Fdownloads%2Ft8y2%2Fdbx%2Fshields.json&style=for-the-badge" /></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322"><img src="https://img.shields.io/badge/QQ_群-1087880322-EB1923?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ0LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+" alt="加入 QQ 群" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信交流群-点击加入-07C160?style=for-the-badge&logo=wechat&logoColor=white" alt="加入微信交流群" /></a>
|
||||
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="加入 Discord" /></a>
|
||||
@@ -27,18 +27,20 @@
|
||||
<img src="https://img.shields.io/badge/SQL%20Server-CC2927?logo=microsoftsqlserver&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/SelectDB-22C1C3?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/StarRocks-5C2D91?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Redshift-8C4FFF?logo=amazonredshift&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Cloud%20Spanner-4285F4?logo=googlecloudspanner&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/DM-3857FF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/OceanBase-006AFF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/openGauss-2B7BD9?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/GaussDB-E60012?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/KWDB-1c60e0?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/KingBase-003B8E?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/KingbaseES-003B8E?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TDengine-2F6FFF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/CockroachDB-6933FF?logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/InfluxDB-d30971?logo=influxdb&logoColor=white" />
|
||||
@@ -61,6 +63,78 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## 赞助商与合作伙伴
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/rainyun-dark.png" />
|
||||
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="雨云" height="48" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
雨云是面向开发者和站长的云服务提供商,提供云服务器、物理服务器、游戏云和配套基础设施服务。
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.qiniu.com/" target="_blank">
|
||||
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="七牛云" height="64" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
七牛云为 DBX 提供对象存储、CDN 等云基础设施资源支持。
|
||||
<a href="https://www.qiniu.com/" target="_blank">访问七牛云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://easysearch.cn" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/easysearch-dark.png" />
|
||||
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Easysearch 是一款企业级分布式搜索引擎,兼容 ES API、融合全文检索、向量检索、地理空间位置检索、实时分析与 AI 能力,为企业提供统一的数据检索与智能分析基础设施。
|
||||
<a href="https://easysearch.cn" target="_blank">访问 Easysearch</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/atlas-dark.svg" />
|
||||
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Atlas Cloud 为开发者提供统一的多模态 AI API,可通过一个接口访问聊天、图像、视频和音频等 400+ 模型。
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">访问 Atlas Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="220">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/public/sponsors/trustasia-dark.svg" />
|
||||
<img src="docs/public/sponsors/trustasia.png" alt="TrustAsia" height="44" />
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 为什么选择 DBX?
|
||||
|
||||
<table>
|
||||
@@ -88,9 +162,9 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 70+ 种数据库,一个工具搞定
|
||||
### 90+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -401,63 +475,6 @@ make package
|
||||
|
||||
DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础设施建设和版本发布都需要长期投入时间与资源。
|
||||
|
||||
### 赞助商与合作伙伴
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
<img src="https://www.rainyun.com/img/logo.d193755d.png" alt="雨云" height="48" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
雨云是面向开发者和站长的云服务提供商,提供云服务器、物理服务器、游戏云和配套基础设施服务。
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.qiniu.com/" target="_blank">
|
||||
<img src="https://www-static.qbox.me/_next/static/media/logo.0fc18feaa621d2068a7180631f742256.jpg" alt="七牛云" height="64" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
七牛云为 DBX 提供对象存储、CDN 等云基础设施资源支持。
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.qiniu.com/" target="_blank">访问七牛云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://easysearch.cn" target="_blank">
|
||||
<img src="docs/public/sponsors/easysearch.png" alt="Easysearch" width="150" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Easysearch 是一款企业级分布式搜索引擎,兼容 ES API、融合全文检索、向量检索、地理空间位置检索、实时分析与 AI 能力,为企业提供统一的数据检索与智能分析基础设施。
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://easysearch.cn" target="_blank">访问 Easysearch</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
<img src="https://www.atlascloud.ai/logo.svg" alt="Atlas Cloud" width="150" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Atlas Cloud 为开发者提供统一的多模态 AI API,可通过一个接口访问聊天、图像、视频和音频等 400+ 模型。
|
||||
</td>
|
||||
<td align="center" width="140">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">访问 Atlas Cloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- [支持 DBX](https://my.feishu.cn/wiki/WMTkwdATDiiu4rk14JMcoyhTnoh) —— 通过微信或支付宝自愿捐助
|
||||
- [赞助商与合作伙伴](https://my.feishu.cn/wiki/CgOWwwTzzify79k9Oq8cXpUNn6e) —— 支持基础设施、开发工具、服务或社区合作
|
||||
|
||||
@@ -481,12 +498,12 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
||||
|
||||
<details>
|
||||
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
|
||||
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 70+ 数据库、桌面端、Docker 和 Web。
|
||||
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>支持哪些数据库?</strong></summary>
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -502,11 +519,11 @@ MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHo
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=t8y2%2Fdbx&type=date&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#t8y2/dbx&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=t8y2/dbx&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
!drivers/uxdb/libs/uxdbjdbc-2.1.2.3p.jre8.jar
|
||||
gradle.properties
|
||||
build/
|
||||
*/bin/
|
||||
bin/
|
||||
.gradle/
|
||||
.DS_Store
|
||||
openspec
|
||||
|
||||
+12
-6
@@ -12,7 +12,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
|-------|----------|-------------|
|
||||
| access | Microsoft Access | UCanAccess |
|
||||
| dameng | 达梦 DM8 | DM JDBC |
|
||||
| kingbase | 人大金仓 KingbaseES | gokb Go native agent |
|
||||
| kingbase | 金仓KingbaseES | gokb Go native agent |
|
||||
| vastbase | Vastbase | openGauss Go native agent |
|
||||
| uxdb | UXDB | UXDB JDBC |
|
||||
| goldendb | GoldenDB | MySQL Connector/J |
|
||||
@@ -36,20 +36,24 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
| neo4j | Neo4j | Official Neo4j Go Driver native agent |
|
||||
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver native agent |
|
||||
| bigquery | Google BigQuery | BigQuery JDBC |
|
||||
| spanner | Google Cloud Spanner | Google Cloud Spanner JDBC |
|
||||
| kylin | Apache Kylin | Kylin JDBC |
|
||||
| ignite | Apache Ignite | Ignite JDBC thin (ignite-core) |
|
||||
| ignite3 | Apache Ignite 3 | Ignite 3 JDBC thin (ignite-jdbc) |
|
||||
| sundb | SunDB | SunDB JDBC |
|
||||
| tdengine | TDengine 2.4+ | taos-connector-rust native WebSocket agent |
|
||||
| yashandb | 崖山 YashanDB | YashanDB JDBC |
|
||||
| xugu | 虚谷 XuguDB | XuguDB Go native agent |
|
||||
| iotdb | Apache IoTDB | Apache IoTDB Go Client native agent |
|
||||
| etcd | etcd | jetcd |
|
||||
| zookeeper | Apache ZooKeeper | Apache Curator |
|
||||
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
|
||||
| rabbitmq | RabbitMQ | amqp091-go native agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
|
||||
|
||||
|
||||
## Multi-JRE Support
|
||||
|
||||
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
|
||||
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, and `zookeeper`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
|
||||
|
||||
## JDBC Connection Pooling
|
||||
|
||||
@@ -75,7 +79,7 @@ Set `DBX_AGENT_JDBC_POOL_ENABLED=false` for a runtime-level compatibility fallba
|
||||
|
||||
For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely.
|
||||
|
||||
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/hive-go` (native HS2), `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
|
||||
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/hive-go` (native HS2), `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, `drivers/rabbitmq` (amqp091-go), `drivers/rocketmq` (rocketmq-admin-go), and `drivers/zookeeper` (go-zookeeper) as reference implementations. No JRE download or management is needed.
|
||||
- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category.
|
||||
|
||||
Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both native and Java source implementations exist for the same database, publish only the native artifact unless the Java variant has a separately registered compatibility profile, such as `oracle-legacy` / `oracle-10g`.
|
||||
@@ -95,9 +99,11 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
|
||||
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
|
||||
(cd drivers/xugu && go build -o agent .)
|
||||
(cd drivers/rabbitmq && go build -o agent .)
|
||||
(cd drivers/rocketmq && go build -o agent .)
|
||||
(cd drivers/zookeeper && go build -o agent .)
|
||||
```
|
||||
|
||||
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/hive-go`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
|
||||
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/hive-go`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, `drivers/rabbitmq`, `drivers/rocketmq`, and `drivers/zookeeper`.
|
||||
|
||||
### Local DBX Runtime Test
|
||||
|
||||
@@ -111,7 +117,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
|
||||
|
||||
Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR.
|
||||
|
||||
Native agents such as `cassandra`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
|
||||
Native agents such as `cassandra`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, and `zookeeper` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
+12
-6
@@ -12,7 +12,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
||||
|-------|----------|-------------|
|
||||
| access | Microsoft Access | UCanAccess |
|
||||
| dameng | 达梦 DM8 | DM JDBC |
|
||||
| kingbase | 人大金仓 KingbaseES | gokb Go 原生 agent |
|
||||
| kingbase | 金仓KingbaseES | gokb Go 原生 agent |
|
||||
| vastbase | Vastbase | openGauss Go 原生 agent |
|
||||
| uxdb | 优炫 UXDB | UXDB JDBC |
|
||||
| goldendb | GoldenDB | MySQL Connector/J |
|
||||
@@ -36,20 +36,24 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
||||
| neo4j | Neo4j | 官方 Neo4j Go Driver 原生 Agent |
|
||||
| cassandra | Apache Cassandra 2.1+ | Apache cassandra-gocql-driver 原生 Agent |
|
||||
| bigquery | Google BigQuery | BigQuery JDBC |
|
||||
| spanner | Google Cloud Spanner | Google Cloud Spanner JDBC |
|
||||
| kylin | Apache Kylin | Kylin JDBC |
|
||||
| ignite | Apache Ignite | Ignite JDBC thin(ignite-core) |
|
||||
| ignite3 | Apache Ignite 3 | Ignite 3 JDBC thin(ignite-jdbc) |
|
||||
| sundb | SunDB | SunDB JDBC |
|
||||
| tdengine | TDengine 2.4+ | taos-connector-rust 原生 WebSocket agent |
|
||||
| yashandb | 崖山 YashanDB | YashanDB JDBC |
|
||||
| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent |
|
||||
| iotdb | Apache IoTDB | Apache IoTDB Go Client 原生 Agent |
|
||||
| etcd | etcd | jetcd |
|
||||
| zookeeper | Apache ZooKeeper | Apache Curator |
|
||||
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
|
||||
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
|
||||
|
||||
|
||||
## 多 JRE 支持
|
||||
|
||||
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
|
||||
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
|
||||
|
||||
## JDBC 连接池
|
||||
|
||||
@@ -75,7 +79,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
|
||||
|
||||
对于新 agent,只要存在成熟、许可证兼容的原生驱动,优先选择**原生(Go 或 Rust)驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE,可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。
|
||||
|
||||
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/hive-go`(原生 HS2)、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
|
||||
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/hive-go`(原生 HS2)、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu`、`drivers/rabbitmq`(amqp091-go)、`drivers/rocketmq`(rocketmq-admin-go)和 `drivers/zookeeper`(go-zookeeper)。无需 JRE 下载与管理。
|
||||
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
|
||||
|
||||
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`。
|
||||
@@ -95,9 +99,11 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
|
||||
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
|
||||
(cd drivers/xugu && go build -o agent .)
|
||||
(cd drivers/rabbitmq && go build -o agent .)
|
||||
(cd drivers/rocketmq && go build -o agent .)
|
||||
(cd drivers/zookeeper && go build -o agent .)
|
||||
```
|
||||
|
||||
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/hive-go`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
|
||||
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/hive-go`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu`、`drivers/rabbitmq`、`drivers/rocketmq` 和 `drivers/zookeeper` 构建。
|
||||
|
||||
### 本地 DBX 运行时测试
|
||||
|
||||
@@ -111,7 +117,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
|
||||
|
||||
重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。
|
||||
|
||||
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
|
||||
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
|
||||
|
||||
## 版本管理
|
||||
|
||||
|
||||
+3
-3
@@ -3,12 +3,12 @@ plugins {
|
||||
}
|
||||
|
||||
def infrastructureProjects = ['common', 'test-support'] as Set
|
||||
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
|
||||
def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
|
||||
def pooledJdbcProjects = [
|
||||
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
|
||||
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
|
||||
'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'ignite', 'ignite3', 'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'vertica', 'yashandb'
|
||||
] as Set
|
||||
def agentProjects = subprojects.findAll { !infrastructureProjects.contains(it.name) }
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
private static final String GAUSSDB_COMPATIBILITY_SQL =
|
||||
@@ -89,7 +90,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
return unchecked(() -> {
|
||||
loadDriver(params);
|
||||
try (Connection conn = openTestConnection(params)) {
|
||||
boolean valid = conn != null && conn.isValid(5);
|
||||
boolean valid = isConnectionValid(conn, 5);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ok", valid);
|
||||
if (valid) {
|
||||
@@ -274,6 +275,31 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
return true;
|
||||
}
|
||||
|
||||
final boolean isConnectionValid(Connection connection, int timeoutSecs) {
|
||||
if (connection == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (connection.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
String validationQuery = connectionValidationQuery();
|
||||
if (validationQuery == null || validationQuery.isBlank()) {
|
||||
return connection.isValid(timeoutSecs);
|
||||
}
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
try {
|
||||
statement.setQueryTimeout(timeoutSecs);
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
statement.execute(validationQuery);
|
||||
return true;
|
||||
}
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final synchronized boolean usesConnectionPool() {
|
||||
return poolRegistry != null;
|
||||
}
|
||||
@@ -457,7 +483,18 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
protected abstract String buildJdbcUrl(ConnectParams params);
|
||||
|
||||
protected Connection openConnection(ConnectParams params) throws Exception {
|
||||
return DriverManager.getConnection(buildJdbcUrl(params), params.getUsername(), params.getPassword());
|
||||
return DriverManager.getConnection(buildJdbcUrl(params), buildConnectionProperties(params));
|
||||
}
|
||||
|
||||
protected Properties buildConnectionProperties(ConnectParams params) {
|
||||
Properties properties = new Properties();
|
||||
if (params.getUsername() != null) {
|
||||
properties.setProperty("user", params.getUsername());
|
||||
}
|
||||
if (params.getPassword() != null) {
|
||||
properties.setProperty("password", params.getPassword());
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
protected Connection openTestConnection(ConnectParams params) throws Exception {
|
||||
@@ -473,6 +510,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
protected void afterDisconnect() throws Exception {
|
||||
}
|
||||
|
||||
protected String connectionValidationQuery() {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected void beforeQueryExecution(Connection connection, int timeoutSecs) throws Exception {
|
||||
}
|
||||
|
||||
@@ -574,6 +615,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
return registry.borrow(
|
||||
identity,
|
||||
JdbcSessionRole.from(params.getSessionRole()),
|
||||
connectionValidationQuery(),
|
||||
() -> openInitializedConnection(params)
|
||||
);
|
||||
}
|
||||
@@ -638,6 +680,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
appendPoolIdentity(identity, "driverPath" + index, driverPaths.get(index));
|
||||
}
|
||||
}
|
||||
appendPoolIdentity(identity, "driverProfile", params.getDriver_profile());
|
||||
return identity.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ public final class AgentProtocol {
|
||||
public static final String METHOD_GET_EXPLAIN_INFO = "get_explain_info";
|
||||
public static final String METHOD_EXECUTE_BATCH = "execute_batch";
|
||||
public static final String METHOD_EXECUTE_TRANSACTION = "execute_transaction";
|
||||
public static final String METHOD_BEGIN_MANUAL_TRANSACTION = "begin_manual_transaction";
|
||||
public static final String METHOD_COMMIT_MANUAL_TRANSACTION = "commit_manual_transaction";
|
||||
public static final String METHOD_ROLLBACK_MANUAL_TRANSACTION = "rollback_manual_transaction";
|
||||
public static final String METHOD_DISCONNECT = "disconnect";
|
||||
public static final String METHOD_SHUTDOWN = "shutdown";
|
||||
|
||||
@@ -64,10 +67,12 @@ public final class AgentProtocol {
|
||||
public static final String MONGO_METHOD_CLONE_COLLECTION = "clone_collection";
|
||||
public static final String MONGO_METHOD_DROP_DATABASE = "drop_database";
|
||||
public static final String MONGO_METHOD_INSERT_DOCUMENT = "insert_document";
|
||||
public static final String MONGO_METHOD_INSERT_DOCUMENTS = "insert_documents";
|
||||
public static final String MONGO_METHOD_UPDATE_DOCUMENT = "update_document";
|
||||
public static final String MONGO_METHOD_UPDATE_DOCUMENTS = "update_documents";
|
||||
public static final String MONGO_METHOD_DELETE_DOCUMENT = "delete_document";
|
||||
public static final String MONGO_METHOD_DELETE_DOCUMENTS = "delete_documents";
|
||||
public static final String MONGO_METHOD_RUN_COMMAND = "run_command";
|
||||
|
||||
public static final String KV_METHOD_LIST_PREFIX = "kv_list_prefix";
|
||||
public static final String KV_METHOD_GET = "kv_get";
|
||||
@@ -120,6 +125,8 @@ public final class AgentProtocol {
|
||||
public static final String CAPABILITY_ETCD_AUTH = "etcd_auth";
|
||||
public static final String CAPABILITY_MONGO_DROP_DATABASE = "mongo_drop_database";
|
||||
public static final String CAPABILITY_MONGO_CLONE_COLLECTION = "mongo_clone_collection";
|
||||
public static final String CAPABILITY_MONGO_RUN_COMMAND = "mongo_run_command";
|
||||
public static final String CAPABILITY_MONGO_INSERT_DOCUMENTS = "mongo_insert_documents";
|
||||
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
|
||||
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
|
||||
|
||||
@@ -153,7 +160,9 @@ public final class AgentProtocol {
|
||||
CAPABILITY_ETCD_LEASE,
|
||||
CAPABILITY_ETCD_AUTH,
|
||||
CAPABILITY_MONGO_DROP_DATABASE,
|
||||
CAPABILITY_MONGO_CLONE_COLLECTION
|
||||
CAPABILITY_MONGO_CLONE_COLLECTION,
|
||||
CAPABILITY_MONGO_RUN_COMMAND,
|
||||
CAPABILITY_MONGO_INSERT_DOCUMENTS
|
||||
));
|
||||
|
||||
public static final List<String> MULTI_SESSION_CAPABILITIES;
|
||||
@@ -194,6 +203,9 @@ public final class AgentProtocol {
|
||||
METHOD_GET_EXPLAIN_INFO,
|
||||
METHOD_EXECUTE_BATCH,
|
||||
METHOD_EXECUTE_TRANSACTION,
|
||||
METHOD_BEGIN_MANUAL_TRANSACTION,
|
||||
METHOD_COMMIT_MANUAL_TRANSACTION,
|
||||
METHOD_ROLLBACK_MANUAL_TRANSACTION,
|
||||
METHOD_DISCONNECT,
|
||||
METHOD_SHUTDOWN
|
||||
));
|
||||
@@ -212,11 +224,15 @@ public final class AgentProtocol {
|
||||
List<String> mongoCapabilities = new java.util.ArrayList<>(CAPABILITIES);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
|
||||
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
|
||||
|
||||
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_DROP_DATABASE);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
|
||||
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
|
||||
|
||||
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
|
||||
@@ -255,10 +271,12 @@ public final class AgentProtocol {
|
||||
MONGO_METHOD_CLONE_COLLECTION,
|
||||
MONGO_METHOD_DROP_DATABASE,
|
||||
MONGO_METHOD_INSERT_DOCUMENT,
|
||||
MONGO_METHOD_INSERT_DOCUMENTS,
|
||||
MONGO_METHOD_UPDATE_DOCUMENT,
|
||||
MONGO_METHOD_UPDATE_DOCUMENTS,
|
||||
MONGO_METHOD_DELETE_DOCUMENT,
|
||||
MONGO_METHOD_DELETE_DOCUMENTS
|
||||
MONGO_METHOD_DELETE_DOCUMENTS,
|
||||
MONGO_METHOD_RUN_COMMAND
|
||||
));
|
||||
|
||||
public static final List<String> KV_METHODS = Collections.unmodifiableList(Arrays.asList(
|
||||
|
||||
@@ -17,6 +17,8 @@ public enum CompletionAssistantCandidateKind {
|
||||
FUNCTION,
|
||||
@SerializedName("column")
|
||||
COLUMN,
|
||||
@SerializedName("sequence")
|
||||
SEQUENCE,
|
||||
@SerializedName("object")
|
||||
OBJECT
|
||||
}
|
||||
|
||||
@@ -18,5 +18,7 @@ public enum CompletionAssistantObjectKind {
|
||||
@SerializedName("function")
|
||||
FUNCTION,
|
||||
@SerializedName("column")
|
||||
COLUMN
|
||||
COLUMN,
|
||||
@SerializedName("sequence")
|
||||
SEQUENCE
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public final class ConnectParams {
|
||||
private String gbase_server;
|
||||
private String informix_server;
|
||||
private String sessionRole;
|
||||
private String driver_profile;
|
||||
|
||||
public ConnectParams() {
|
||||
this("", 0, "", "", "", "", "", false, "", Collections.emptyList());
|
||||
@@ -209,6 +210,14 @@ public final class ConnectParams {
|
||||
this.sessionRole = sessionRole;
|
||||
}
|
||||
|
||||
public String getDriver_profile() {
|
||||
return driver_profile;
|
||||
}
|
||||
|
||||
public void setDriver_profile(String driver_profile) {
|
||||
this.driver_profile = driver_profile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -231,13 +240,14 @@ public final class ConnectParams {
|
||||
&& Objects.equals(client_key_path, that.client_key_path)
|
||||
&& Objects.equals(gbase_server, that.gbase_server)
|
||||
&& Objects.equals(informix_server, that.informix_server)
|
||||
&& Objects.equals(sessionRole, that.sessionRole);
|
||||
&& Objects.equals(sessionRole, that.sessionRole)
|
||||
&& Objects.equals(driver_profile, that.driver_profile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(host, port, database, username, password, url_params, connection_string,
|
||||
port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server, sessionRole);
|
||||
port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server, sessionRole, driver_profile);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -260,6 +270,7 @@ public final class ConnectParams {
|
||||
+ ", gbase_server=" + gbase_server
|
||||
+ ", informix_server=" + informix_server
|
||||
+ ", sessionRole=" + sessionRole
|
||||
+ ", driver_profile=" + driver_profile
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,18 @@ public final class IndexInfo {
|
||||
private String index_type;
|
||||
private List<String> included_columns;
|
||||
private String comment;
|
||||
// Parallel to `columns`: true at index i means columns[i] is a raw expression (e.g. sourced
|
||||
// from pg_get_indexdef), not a plain column name. Empty when the introspection source
|
||||
// doesn't track this (provenance unknown for that dialect/path). Mirrors
|
||||
// crate::types::IndexInfo::key_is_expression on the Rust side (#6312 review).
|
||||
private List<Boolean> key_is_expression;
|
||||
|
||||
public IndexInfo() {
|
||||
this("", Collections.emptyList(), false, false);
|
||||
}
|
||||
|
||||
public IndexInfo(String name, List<String> columns, boolean is_unique, boolean is_primary) {
|
||||
this(name, columns, is_unique, is_primary, null, null, null, null);
|
||||
this(name, columns, is_unique, is_primary, null, null, null, null, Collections.emptyList());
|
||||
}
|
||||
|
||||
public IndexInfo(
|
||||
@@ -31,6 +36,20 @@ public final class IndexInfo {
|
||||
String index_type,
|
||||
List<String> included_columns,
|
||||
String comment
|
||||
) {
|
||||
this(name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, Collections.emptyList());
|
||||
}
|
||||
|
||||
public IndexInfo(
|
||||
String name,
|
||||
List<String> columns,
|
||||
boolean is_unique,
|
||||
boolean is_primary,
|
||||
String filter,
|
||||
String index_type,
|
||||
List<String> included_columns,
|
||||
String comment,
|
||||
List<Boolean> key_is_expression
|
||||
) {
|
||||
this.name = name;
|
||||
this.columns = columns == null ? Collections.emptyList() : columns;
|
||||
@@ -40,6 +59,7 @@ public final class IndexInfo {
|
||||
this.index_type = index_type;
|
||||
this.included_columns = included_columns;
|
||||
this.comment = comment;
|
||||
this.key_is_expression = key_is_expression == null ? Collections.emptyList() : key_is_expression;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
@@ -74,6 +94,10 @@ public final class IndexInfo {
|
||||
return comment;
|
||||
}
|
||||
|
||||
public List<Boolean> getKey_is_expression() {
|
||||
return key_is_expression;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -106,6 +130,10 @@ public final class IndexInfo {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public void setKey_is_expression(List<Boolean> key_is_expression) {
|
||||
this.key_is_expression = key_is_expression;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -118,12 +146,15 @@ public final class IndexInfo {
|
||||
&& Objects.equals(filter, that.filter)
|
||||
&& Objects.equals(index_type, that.index_type)
|
||||
&& Objects.equals(included_columns, that.included_columns)
|
||||
&& Objects.equals(comment, that.comment);
|
||||
&& Objects.equals(comment, that.comment)
|
||||
&& Objects.equals(key_is_expression, that.key_is_expression);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, columns, is_unique, is_primary, filter, index_type, included_columns, comment);
|
||||
return Objects.hash(
|
||||
name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, key_is_expression
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -136,6 +167,7 @@ public final class IndexInfo {
|
||||
+ ", index_type=" + index_type
|
||||
+ ", included_columns=" + included_columns
|
||||
+ ", comment=" + comment
|
||||
+ ", key_is_expression=" + key_is_expression
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ public class JdbcAgentProfile {
|
||||
return schemaSwitchPrefix + " " + quote + schema.replace(quote, quote + quote) + quote;
|
||||
}
|
||||
|
||||
private static String appendUrlParams(String url, String urlParams) {
|
||||
public static String appendUrlParams(String url, String urlParams) {
|
||||
String params = trimUrlParams(urlParams);
|
||||
if (params.isEmpty()) {
|
||||
return url;
|
||||
|
||||
@@ -81,10 +81,14 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
||||
}
|
||||
|
||||
Lease borrow(String identity, ConnectionFactory connectionFactory) throws Exception {
|
||||
return borrow(identity, JdbcSessionRole.WORKLOAD, connectionFactory);
|
||||
return borrow(identity, JdbcSessionRole.WORKLOAD, null, connectionFactory);
|
||||
}
|
||||
|
||||
Lease borrow(String identity, JdbcSessionRole role, ConnectionFactory connectionFactory) throws Exception {
|
||||
return borrow(identity, role, null, connectionFactory);
|
||||
}
|
||||
|
||||
Lease borrow(String identity, JdbcSessionRole role, String connectionTestQuery, ConnectionFactory connectionFactory) throws Exception {
|
||||
String key = digest(identity);
|
||||
while (true) {
|
||||
if (closed.get()) {
|
||||
@@ -96,7 +100,7 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
||||
}
|
||||
PoolEntry entry;
|
||||
try {
|
||||
entry = pools.computeIfAbsent(key, ignored -> createPoolEntry(key, connectionFactory));
|
||||
entry = pools.computeIfAbsent(key, ignored -> createPoolEntry(key, connectionTestQuery, connectionFactory));
|
||||
} catch (PoolCreationException error) {
|
||||
throw error.unwrap();
|
||||
}
|
||||
@@ -130,7 +134,7 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
||||
return entry != null && entry.hasActiveLeases();
|
||||
}
|
||||
|
||||
private PoolEntry createPoolEntry(String key, ConnectionFactory connectionFactory) {
|
||||
private PoolEntry createPoolEntry(String key, String connectionTestQuery, ConnectionFactory connectionFactory) {
|
||||
try {
|
||||
ConnectionFactoryDataSource factoryDataSource = new ConnectionFactoryDataSource(
|
||||
connectionFactory,
|
||||
@@ -146,6 +150,9 @@ final class JdbcConnectionPoolRegistry implements AutoCloseable {
|
||||
config.setMinimumIdle(settings.minimumIdle);
|
||||
config.setConnectionTimeout(settings.connectionTimeoutMillis);
|
||||
config.setValidationTimeout(settings.validationTimeoutMillis);
|
||||
if (connectionTestQuery != null && !connectionTestQuery.isBlank()) {
|
||||
config.setConnectionTestQuery(connectionTestQuery);
|
||||
}
|
||||
config.setIdleTimeout(settings.idleTimeoutMillis);
|
||||
config.setMaxLifetime(settings.maxLifetimeMillis);
|
||||
config.setInitializationFailTimeout(-1L);
|
||||
|
||||
@@ -171,8 +171,10 @@ public final class JsonRpcServer {
|
||||
boolean valid = false;
|
||||
if (conn != null) {
|
||||
try {
|
||||
valid = !conn.isClosed() && conn.isValid(2);
|
||||
} catch (Exception ignored) {
|
||||
valid = agent instanceof AbstractJdbcAgent jdbcAgent
|
||||
? jdbcAgent.isConnectionValid(conn, 2)
|
||||
: !conn.isClosed() && conn.isValid(2);
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
}
|
||||
if (!valid) {
|
||||
@@ -370,8 +372,10 @@ public final class JsonRpcServer {
|
||||
}
|
||||
boolean valid = false;
|
||||
try {
|
||||
valid = !conn.isClosed() && conn.isValid(2);
|
||||
} catch (Exception ignored) {
|
||||
valid = agent instanceof AbstractJdbcAgent validationAgent
|
||||
? validationAgent.isConnectionValid(conn, 2)
|
||||
: !conn.isClosed() && conn.isValid(2);
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
if (valid) {
|
||||
lastConnectionValidationTimeMillis = now;
|
||||
|
||||
@@ -533,47 +533,87 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
|
||||
return listIndexesFromCatalogArrays(schema, table, attributeCache);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// One row per index key position rather than array_agg/GROUP BY: avoids
|
||||
// rs.getArray(...), whose JDBC support is flaky enough across these vendor drivers
|
||||
// that readAttributeNumbers() below already has to defensively catch
|
||||
// SQLException/UnsupportedOperationException/IncompatibleClassChangeError around it.
|
||||
// LEFT JOIN pg_attribute (instead of an inner JOIN) so expression key parts
|
||||
// (attnum = 0, e.g. functional index columns) survive instead of being silently
|
||||
// dropped; their text comes from pg_get_indexdef instead of a.attname, and
|
||||
// is_expression records which case applied so Rust never has to guess from
|
||||
// characters in the text (#6312 review).
|
||||
Map<String, IndexBuilder> byName = new LinkedHashMap<>();
|
||||
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
|
||||
"ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " +
|
||||
"array_agg(a.attname ORDER BY k.n) AS columns " +
|
||||
"(ix.indisunique AND ix.indisvalid) AS is_unique, ix.indisprimary AS is_primary, " +
|
||||
"COALESCE(a.attname, " + profile.catalogPrefixedFunction("get_indexdef") + "(ix.indexrelid, k.n, true)) AS column_text, " +
|
||||
"(a.attname IS NULL) AS is_expression " +
|
||||
"FROM " + profile.catalogRelation("index") + " ix " +
|
||||
"JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " +
|
||||
"JOIN " + profile.catalogRelation("class") + " i ON i.oid = ix.indexrelid " +
|
||||
"JOIN " + profile.catalogRelation("namespace") + " n ON n.oid = t.relnamespace " +
|
||||
"JOIN " + profile.catalogRelation("am") + " am ON am.oid = i.relam " +
|
||||
"JOIN LATERAL (SELECT unnest(ix.indkey) AS attnum, generate_series(1, array_length(ix.indkey, 1)) AS n) AS k ON true " +
|
||||
"JOIN " + profile.catalogRelation("attribute") + " a ON a.attrelid = t.oid AND a.attnum = k.attnum " +
|
||||
"LEFT JOIN " + profile.catalogRelation("attribute") + " a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 " +
|
||||
"WHERE n.nspname = ? AND t.relname = ? " +
|
||||
"GROUP BY i.relname, am.amname, ix.indisunique, ix.indisprimary " +
|
||||
"ORDER BY i.relname";
|
||||
"ORDER BY i.relname, k.n";
|
||||
try (java.sql.PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Object[] columnArray = (Object[]) rs.getArray("columns").getArray();
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (Object column : columnArray) {
|
||||
columns.add(String.valueOf(column));
|
||||
}
|
||||
result.add(new IndexInfo(
|
||||
rs.getString("index_name"),
|
||||
columns,
|
||||
rs.getBoolean("is_unique"),
|
||||
rs.getBoolean("is_primary"),
|
||||
null,
|
||||
rs.getString("index_type"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
String indexName = rs.getString("index_name");
|
||||
String indexType = rs.getString("index_type");
|
||||
boolean isUnique = rs.getBoolean("is_unique");
|
||||
boolean isPrimary = rs.getBoolean("is_primary");
|
||||
IndexBuilder builder = byName.computeIfAbsent(
|
||||
indexName,
|
||||
name -> new IndexBuilder(name, indexType, isUnique, isPrimary)
|
||||
);
|
||||
builder.columns.add(rs.getString("column_text"));
|
||||
builder.keyIsExpression.add(rs.getBoolean("is_expression"));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
for (IndexBuilder builder : byName.values()) {
|
||||
result.add(new IndexInfo(
|
||||
builder.name,
|
||||
builder.columns,
|
||||
builder.isUnique,
|
||||
builder.isPrimary,
|
||||
null,
|
||||
builder.indexType,
|
||||
null,
|
||||
null,
|
||||
builder.keyIsExpression
|
||||
));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private static final class IndexBuilder {
|
||||
private final String name;
|
||||
private final String indexType;
|
||||
private final boolean isUnique;
|
||||
private final boolean isPrimary;
|
||||
private final List<String> columns = new ArrayList<>();
|
||||
private final List<Boolean> keyIsExpression = new ArrayList<>();
|
||||
|
||||
private IndexBuilder(String name, String indexType, boolean isUnique, boolean isPrimary) {
|
||||
this.name = name;
|
||||
this.indexType = indexType;
|
||||
this.isUnique = isUnique;
|
||||
this.isPrimary = isPrimary;
|
||||
}
|
||||
}
|
||||
|
||||
// No PostgresLikeAgentProfile currently calls withCatalogAttributeArraysMappedInJava(), so this
|
||||
// path is unreachable by any driver today (Highgo and the rest use listIndexes(...) above). It
|
||||
// still filters out expression key parts (attnum <= 0) via mapRequiredAttributeNumbers and
|
||||
// drops the whole index when that happens, and reports no key_is_expression provenance for the
|
||||
// indexes it does return. Left as-is rather than expanding this fix onto dead code; a future
|
||||
// profile enabling this path would need the same treatment as listIndexes(...) above.
|
||||
private List<IndexInfo> listIndexesFromCatalogArrays(
|
||||
String schema,
|
||||
String table,
|
||||
@@ -582,7 +622,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
|
||||
return unchecked(() -> {
|
||||
List<CatalogIndex> catalogIndexes = new ArrayList<>();
|
||||
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
|
||||
"ix.indisunique AS is_unique, ix.indisprimary AS is_primary, " +
|
||||
"(ix.indisunique AND ix.indisvalid) AS is_unique, ix.indisprimary AS is_primary, " +
|
||||
"ix.indkey AS column_numbers " +
|
||||
"FROM " + profile.catalogRelation("index") + " ix " +
|
||||
"JOIN " + profile.catalogRelation("class") + " t ON t.oid = ix.indrelid " +
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"protocolVersion": 1,
|
||||
"handshakeMethod": "handshake",
|
||||
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents"],
|
||||
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
|
||||
"commonMethods": [
|
||||
@@ -36,9 +36,12 @@
|
||||
"get_explain_info",
|
||||
"execute_batch",
|
||||
"execute_transaction",
|
||||
"begin_manual_transaction",
|
||||
"commit_manual_transaction",
|
||||
"rollback_manual_transaction",
|
||||
"disconnect",
|
||||
"shutdown"
|
||||
],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "delete_document", "delete_documents", "run_command"],
|
||||
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"protocolVersion": 2,
|
||||
"handshakeMethod": "handshake",
|
||||
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "multi_session", "structured_error_v1"],
|
||||
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "multi_session", "structured_error_v1"],
|
||||
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
|
||||
"commonMethods": [
|
||||
@@ -40,10 +40,13 @@
|
||||
"get_explain_info",
|
||||
"execute_batch",
|
||||
"execute_transaction",
|
||||
"begin_manual_transaction",
|
||||
"commit_manual_transaction",
|
||||
"rollback_manual_transaction",
|
||||
"disconnect",
|
||||
"shutdown"
|
||||
],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "update_document", "update_documents", "delete_document", "delete_documents"],
|
||||
"mongoLegacyMethods": ["list_databases", "list_collections", "find_documents", "find_one", "explain_find", "aggregate_documents", "find_documents_extended_json", "count_documents", "server_version", "create_index", "create_user", "drop_indexes", "drop_collection", "clone_collection", "drop_database", "insert_document", "insert_documents", "update_document", "update_documents", "delete_document", "delete_documents", "run_command"],
|
||||
"kvMethods": ["kv_list_prefix", "kv_get", "kv_put", "kv_delete", "kv_rename", "kv_history", "kv_status", "etcd_compact", "etcd_defrag", "etcd_watch_start", "etcd_watch_poll", "etcd_watch_stop", "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", "etcd_lease_keepalive_once", "etcd_lease_revoke", "etcd_auth_user_list", "etcd_auth_user_get", "etcd_auth_user_add", "etcd_auth_user_delete", "etcd_auth_user_change_password", "etcd_auth_user_grant_role", "etcd_auth_user_revoke_role", "etcd_auth_role_list", "etcd_auth_role_get", "etcd_auth_role_add", "etcd_auth_role_delete", "etcd_auth_role_grant_permission", "etcd_auth_role_revoke_permission"],
|
||||
"sessionField": "agentSessionId",
|
||||
"cursorSessionField": "sessionId"
|
||||
|
||||
@@ -16,6 +16,7 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -25,6 +26,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AbstractJdbcAgentTest {
|
||||
@Test
|
||||
void buildsStandardJdbcCredentialPropertiesByDefault() {
|
||||
TestAgent agent = new TestAgent(new TrackingConnection());
|
||||
|
||||
Properties properties = agent.buildConnectionProperties(
|
||||
new ConnectParams("localhost", 0, "demo", "user", "secret", "", "", false)
|
||||
);
|
||||
|
||||
assertEquals("user", properties.getProperty("user"));
|
||||
assertEquals("secret", properties.getProperty("password"));
|
||||
assertEquals(2, properties.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownsConnectionLifecycleAndConnectedState() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
@@ -113,6 +127,19 @@ class AbstractJdbcAgentTest {
|
||||
assertEquals(1, tracking.closeCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesLegacyJdbcConnectionsWithConfiguredQueryInsteadOfIsValid() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
tracking.isValidUnsupported = true;
|
||||
TestAgent agent = new TestAgent(tracking);
|
||||
agent.validationQuery = "SELECT 1";
|
||||
|
||||
assertTrue(agent.testConnection(new ConnectParams()));
|
||||
|
||||
assertEquals(0, tracking.isValidCount);
|
||||
assertEquals(Arrays.asList("setQueryTimeout:5", "execute:SELECT 1"), tracking.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConnectionCanSkipOpeningAPhysicalConnection() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
@@ -325,6 +352,7 @@ class AbstractJdbcAgentTest {
|
||||
private int afterConnectCount;
|
||||
private int afterDisconnectCount;
|
||||
private boolean skipTestConnectionOpen;
|
||||
private String validationQuery;
|
||||
|
||||
private TestAgent(TrackingConnection tracking) {
|
||||
this.tracking = tracking;
|
||||
@@ -361,6 +389,11 @@ class AbstractJdbcAgentTest {
|
||||
afterDisconnectCount += 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String connectionValidationQuery() {
|
||||
return validationQuery;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return Collections.emptyList();
|
||||
@@ -422,6 +455,7 @@ class AbstractJdbcAgentTest {
|
||||
private String identifierQuote = "\"";
|
||||
private boolean compatibilityQueryFails;
|
||||
private int compatibilityQueryCount;
|
||||
private boolean isValidUnsupported;
|
||||
|
||||
private Connection connection() {
|
||||
return proxy(Connection.class, new MethodHandler() {
|
||||
@@ -436,6 +470,9 @@ class AbstractJdbcAgentTest {
|
||||
}
|
||||
if ("isValid".equals(name)) {
|
||||
isValidCount += 1;
|
||||
if (isValidUnsupported) {
|
||||
throw new AbstractMethodError("legacy JDBC driver");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
|
||||
@@ -128,6 +128,54 @@ class PostgresLikeAgentTest {
|
||||
assertEquals(Integer.valueOf(255), columns.get(1).getCharacter_maximum_length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listIndexesPreservesRealColumnVsExpressionProvenance() {
|
||||
// PR #6312 review (round 2): the Highgo/Java Agent metadata path itself must carry
|
||||
// per-key column-vs-expression provenance, not just the native Rust postgres.rs path.
|
||||
// Fakes a mixed index: a plain column, a real column whose name is deliberately
|
||||
// collision-prone (the round-1 review's own "order item" example — spaces, so it would
|
||||
// be misclassified by the old character-based heuristic), and a genuine expression key
|
||||
// part sourced from pg_get_indexdef (attnum = 0, so a.attname comes back null and
|
||||
// COALESCE falls through to the expression text).
|
||||
String expressionKeyPart = "COALESCE(height, '-1'::integer::double precision)";
|
||||
TestPostgresLikeAgent agent = new TestPostgresLikeAgent(preparedConnection(resultSet(
|
||||
new String[]{"index_name", "index_type", "is_unique", "is_primary", "column_text", "is_expression"},
|
||||
new Object[][]{
|
||||
{"uq_tankong", "btree", true, false, "sta_id", false},
|
||||
{"uq_tankong", "btree", true, false, "order item", false},
|
||||
{"uq_tankong", "btree", true, false, expressionKeyPart, true}
|
||||
}
|
||||
)));
|
||||
agent.connect(new ConnectParams());
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("public", "tankong_data");
|
||||
|
||||
assertEquals(1, indexes.size());
|
||||
IndexInfo index = indexes.get(0);
|
||||
assertEquals(java.util.Arrays.asList("sta_id", "order item", expressionKeyPart), index.getColumns());
|
||||
assertEquals(java.util.Arrays.asList(false, false, true), index.getKey_is_expression());
|
||||
assertTrue(index.getIs_unique());
|
||||
assertFalse(index.getIs_primary());
|
||||
assertEquals("btree", index.getIndex_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listIndexesQueryLeftJoinsAttributesInsteadOfDroppingExpressionKeys() {
|
||||
// Regression guard for the actual #6295/#6312 bug: an inner JOIN on pg_attribute has no
|
||||
// matching row for an expression key part (attnum = 0), so it silently disappears from
|
||||
// the result instead of surfacing as an expression. The join must be a LEFT JOIN gated
|
||||
// on k.attnum > 0, and expression text must come from pg_get_indexdef.
|
||||
TestPostgresLikeAgent agent = new TestPostgresLikeAgent();
|
||||
agent.connect(new ConnectParams());
|
||||
|
||||
agent.listIndexes("app", "orders");
|
||||
|
||||
String sql = String.join("\n", MetadataSqlFake.statements);
|
||||
assertTrue(sql.contains("LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0"), sql);
|
||||
assertTrue(sql.contains("pg_catalog.pg_get_indexdef(ix.indexrelid, k.n, true)"), sql);
|
||||
assertTrue(sql.contains("ix.indisunique AND ix.indisvalid"), sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlIncludesNamedCheckConstraints() {
|
||||
ConstraintDdlAgent agent = new ConstraintDdlAgent();
|
||||
|
||||
@@ -11,6 +11,16 @@ Protocol v2 allows one Agent process to serve multiple isolated database session
|
||||
- `close_session` closes the session resources, query cursors, and table-read cursors without affecting other sessions.
|
||||
- `shutdown` closes all sessions and terminates the runtime.
|
||||
|
||||
## Manual (interactive) transactions
|
||||
|
||||
When a runtime advertises the `transaction` capability and supports sticky sessions, DBX may open a dedicated workload session and call:
|
||||
|
||||
- `begin_manual_transaction` `{ schema? }` — pin one physical connection / start an open transaction
|
||||
- `execute_query` (and related query methods) — run on the open transaction until commit/rollback
|
||||
- `commit_manual_transaction` / `rollback_manual_transaction` — end the interactive transaction
|
||||
|
||||
This is separate from one-shot `execute_transaction`, which begins, runs a statement list, and commits/rolls back inside a single RPC. Runtimes that reconnect a session (`validate_session`) must clear any open manual transaction on that session.
|
||||
|
||||
`agentSessionId` identifies a logical database connection. Existing `sessionId` fields remain pagination cursor identifiers and must not be used as logical connection identifiers.
|
||||
|
||||
`sessionRole` is `workload` by default. DBX sends `metadata` for object-tree, completion, and other read-only metadata sessions. New runtimes use this role to preserve metadata checkout capacity; older runtimes may ignore the field.
|
||||
|
||||
@@ -358,7 +358,8 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
|
||||
case "list_foreign_keys":
|
||||
return []foreignKeyInfo{}, false, nil
|
||||
case "list_triggers":
|
||||
return []triggerInfo{}, false, nil
|
||||
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
return result, false, err
|
||||
case "get_object_source":
|
||||
return nil, false, errors.New("object source is not supported by Cassandra")
|
||||
case "get_table_ddl":
|
||||
|
||||
@@ -82,6 +82,15 @@ type triggerInfo struct {
|
||||
Timing string `json:"timing"`
|
||||
}
|
||||
|
||||
const cassandraListTriggersCQL = "SELECT trigger_name, options FROM system_schema.triggers WHERE keyspace_name = ? AND table_name = ?"
|
||||
|
||||
type metadataIterator interface {
|
||||
Scan(...any) bool
|
||||
Close() error
|
||||
}
|
||||
|
||||
type metadataQuery func(string, ...any) metadataIterator
|
||||
|
||||
type metadataListConstraints struct {
|
||||
Filter string
|
||||
Limit int
|
||||
@@ -321,6 +330,35 @@ func (s *server) querySystemIndexes(schema, table string) ([]indexInfo, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
|
||||
session, err := s.runtime.sessionFor("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listTriggersWithQuery(func(query string, values ...any) metadataIterator {
|
||||
return session.Query(query, values...).Iter()
|
||||
}, schema, table)
|
||||
}
|
||||
|
||||
func listTriggersWithQuery(query metadataQuery, schema, table string) ([]triggerInfo, error) {
|
||||
iter := query(cassandraListTriggersCQL, schema, table)
|
||||
result := []triggerInfo{}
|
||||
var name string
|
||||
var options map[string]string
|
||||
for iter.Scan(&name, &options) {
|
||||
// Cassandra trigger metadata stores only the trigger class. Cassandra
|
||||
// invokes every trigger before any DML statement, so expose those fixed
|
||||
// semantics through the common TriggerInfo contract.
|
||||
result = append(result, triggerInfo{Name: name, Event: "DML", Timing: "BEFORE"})
|
||||
options = nil
|
||||
}
|
||||
if err := iter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mergeIndexes(first, second []indexInfo) []indexInfo {
|
||||
byName := make(map[string]indexInfo, len(first)+len(second))
|
||||
for _, index := range first {
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
type triggerMetadataIterator struct {
|
||||
rows []struct {
|
||||
name string
|
||||
options map[string]string
|
||||
}
|
||||
index int
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (i *triggerMetadataIterator) Scan(dest ...any) bool {
|
||||
if i.index >= len(i.rows) {
|
||||
return false
|
||||
}
|
||||
row := i.rows[i.index]
|
||||
i.index++
|
||||
*dest[0].(*string) = row.name
|
||||
*dest[1].(*map[string]string) = row.options
|
||||
return true
|
||||
}
|
||||
|
||||
func (i *triggerMetadataIterator) Close() error {
|
||||
return i.closeErr
|
||||
}
|
||||
|
||||
func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) {
|
||||
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
|
||||
intType := gocql.NewNativeType(4, gocql.TypeInt, "")
|
||||
@@ -84,3 +109,53 @@ func TestQuoteCQLIdentifierEscapesQuotes(t *testing.T) {
|
||||
t.Fatalf("unexpected quoted identifier: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTriggersQueriesExactTableAndMapsMetadata(t *testing.T) {
|
||||
var statement string
|
||||
var values []any
|
||||
triggers, err := listTriggersWithQuery(func(query string, args ...any) metadataIterator {
|
||||
statement = query
|
||||
values = args
|
||||
return &triggerMetadataIterator{rows: []struct {
|
||||
name string
|
||||
options map[string]string
|
||||
}{
|
||||
{name: "capture_changes", options: map[string]string{"class": "example.CaptureDataTrigger"}},
|
||||
}}
|
||||
}, "dev", "example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if statement != cassandraListTriggersCQL {
|
||||
t.Fatalf("unexpected trigger query: %s", statement)
|
||||
}
|
||||
if !reflect.DeepEqual(values, []any{"dev", "example"}) {
|
||||
t.Fatalf("trigger filters = %#v", values)
|
||||
}
|
||||
want := []triggerInfo{{Name: "capture_changes", Event: "DML", Timing: "BEFORE"}}
|
||||
if !reflect.DeepEqual(triggers, want) {
|
||||
t.Fatalf("triggers = %#v, want %#v", triggers, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTriggersReturnsEmptySlice(t *testing.T) {
|
||||
triggers, err := listTriggersWithQuery(func(string, ...any) metadataIterator {
|
||||
return &triggerMetadataIterator{}
|
||||
}, "dev", "empty_table")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if triggers == nil || len(triggers) != 0 {
|
||||
t.Fatalf("triggers = %#v, want a non-nil empty slice", triggers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTriggersReturnsQueryError(t *testing.T) {
|
||||
wantErr := errors.New("trigger metadata unavailable")
|
||||
triggers, err := listTriggersWithQuery(func(string, ...any) metadataIterator {
|
||||
return &triggerMetadataIterator{closeErr: wantErr}
|
||||
}, "dev", "example")
|
||||
if !errors.Is(err, wantErr) || triggers != nil {
|
||||
t.Fatalf("triggers = %#v, err = %v", triggers, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
@@ -18,12 +19,18 @@ import com.dbx.agent.QueryPageResult;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import com.dbx.agent.StandardJdbcMetadata;
|
||||
import java.io.PrintStream;
|
||||
import java.io.Reader;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.Blob;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.Driver;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -40,16 +47,33 @@ import java.sql.Types;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class DamengAgent extends AbstractJdbcAgent {
|
||||
private static final Logger LOGGER = Logger.getLogger("com.dbx.agent.dameng");
|
||||
private static final String AGENT_VERSION = "9999.06.04.1-fix-default";
|
||||
private static final int DBMS_OUTPUT_ENABLE_TIMEOUT_SECS = 5;
|
||||
private static final int DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS = 5_000;
|
||||
private static final Pattern DATABASE_VERSION_MAJOR_PATTERN = Pattern.compile("(\\d+)\\.");
|
||||
private static final JdbcAgentProfile DM6_METADATA_PROFILE = new JdbcAgentProfile(
|
||||
"dm6.jdbc.driver.DmDriver",
|
||||
"jdbc:dm6://{host}:{port}/{database}",
|
||||
5236,
|
||||
true
|
||||
);
|
||||
private static final String DAMENG_CLASSIFIED_OBJECT_TYPE_SQL =
|
||||
"CASE WHEN o.OBJECT_TYPE = 'MATERIALIZED VIEW' OR (o.OBJECT_TYPE = 'VIEW' AND mv.MVIEW_NAME IS NOT NULL) "
|
||||
+ "THEN 'MATERIALIZED_VIEW' ELSE o.OBJECT_TYPE END";
|
||||
@@ -81,6 +105,14 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
) mv ON mv.OWNER = o.OWNER AND mv.MVIEW_NAME = o.OBJECT_NAME
|
||||
""".stripIndent().trim();
|
||||
private String connectedUsername;
|
||||
private Driver externalDriver;
|
||||
private URLClassLoader externalDriverLoader;
|
||||
private List<URL> externalDriverUrls;
|
||||
private String externalDriverClass;
|
||||
private volatile boolean legacyJdbcMetadata;
|
||||
private volatile boolean dbmsOutputInitializationSupported = true;
|
||||
private final Map<Object, Boolean> dbmsOutputInitializedConnections =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
@@ -94,31 +126,246 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
protected void loadDriver(ConnectParams params) throws Exception {
|
||||
withSuppressedStdout(() -> super.loadDriver(params));
|
||||
withSuppressedStdout(() -> {
|
||||
if (params.getJdbc_driver_paths() == null || params.getJdbc_driver_paths().isEmpty()) {
|
||||
// Keep an existing external loader open: pooled connections may
|
||||
// still hold classes loaded from it. Only stop using it here.
|
||||
externalDriver = null;
|
||||
super.loadDriver(params);
|
||||
return;
|
||||
}
|
||||
|
||||
List<URL> urls = new ArrayList<>();
|
||||
for (String path : params.getJdbc_driver_paths()) {
|
||||
urls.add(Paths.get(path).toUri().toURL());
|
||||
}
|
||||
String driverClass = params.getJdbc_driver_class();
|
||||
if (driverClass == null || driverClass.trim().isEmpty()) {
|
||||
driverClass = driverClass();
|
||||
}
|
||||
// Reuse the loader for unchanged paths and class: recreating it
|
||||
// would strand classes that live pooled connections still need
|
||||
// (lazy driver classes would fail with NoClassDefFoundError). A
|
||||
// loader dropped for changed paths is never closed here for the
|
||||
// same reason; disconnect() releases the active one.
|
||||
if (externalDriverLoader != null
|
||||
&& urls.equals(externalDriverUrls)
|
||||
&& driverClass.equals(externalDriverClass)) {
|
||||
if (externalDriver == null) {
|
||||
externalDriver = (Driver) Class.forName(driverClass, true, externalDriverLoader)
|
||||
.getDeclaredConstructor()
|
||||
.newInstance();
|
||||
}
|
||||
return;
|
||||
}
|
||||
externalDriverLoader = new URLClassLoader(
|
||||
urls.toArray(new URL[0]),
|
||||
ClassLoader.getPlatformClassLoader()
|
||||
);
|
||||
externalDriverUrls = urls;
|
||||
externalDriverClass = driverClass;
|
||||
externalDriver = (Driver) Class.forName(driverClass, true, externalDriverLoader)
|
||||
.getDeclaredConstructor()
|
||||
.newInstance();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Connection openConnection(ConnectParams params) throws Exception {
|
||||
return withSuppressedStdout(
|
||||
() -> DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword())
|
||||
);
|
||||
return withSuppressedStdout(() -> {
|
||||
if (externalDriver == null) {
|
||||
return DriverManager.getConnection(buildUrl(params), params.getUsername(), params.getPassword());
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
if (params.getUsername() != null) {
|
||||
properties.setProperty("user", params.getUsername());
|
||||
}
|
||||
if (params.getPassword() != null) {
|
||||
properties.setProperty("password", params.getPassword());
|
||||
}
|
||||
Connection connection = externalDriver.connect(buildUrl(params), properties);
|
||||
if (connection == null) {
|
||||
throw new SQLException("Selected Dameng JDBC driver does not accept URL: " + buildUrl(params));
|
||||
}
|
||||
return connection;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String connectionValidationQuery() {
|
||||
return "SELECT 1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void disconnect() {
|
||||
super.disconnect();
|
||||
try {
|
||||
closeExternalDriverLoader();
|
||||
} catch (Exception error) {
|
||||
// Best-effort release during teardown.
|
||||
}
|
||||
}
|
||||
|
||||
private void closeExternalDriverLoader() throws Exception {
|
||||
externalDriver = null;
|
||||
externalDriverUrls = null;
|
||||
externalDriverClass = null;
|
||||
if (externalDriverLoader != null) {
|
||||
externalDriverLoader.close();
|
||||
externalDriverLoader = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
connectedUsername = params.getUsername();
|
||||
legacyJdbcMetadata = usesLegacyJdbcMetadata(connection);
|
||||
}
|
||||
|
||||
static boolean usesLegacyJdbcMetadata(Connection connection) {
|
||||
try {
|
||||
int major = connection.getMetaData().getDatabaseMajorVersion();
|
||||
if (major > 0) {
|
||||
return major < 7;
|
||||
}
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
try {
|
||||
String version = connection.getMetaData().getDatabaseProductVersion();
|
||||
if (version == null) {
|
||||
return false;
|
||||
}
|
||||
Matcher matcher = DATABASE_VERSION_MAJOR_PATTERN.matcher(version);
|
||||
return matcher.find() && Integer.parseInt(matcher.group(1)) < 7;
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterPhysicalConnect(ConnectParams params, Connection connection) throws SQLException {
|
||||
protected void afterPhysicalConnect(ConnectParams params, Connection connection) {
|
||||
// DBMS_OUTPUT is initialized lazily because some DM versions can block here indefinitely.
|
||||
}
|
||||
|
||||
private void initializeDbmsOutputIfNeeded(String sql) throws SQLException {
|
||||
if (legacyJdbcMetadata || !dbmsOutputInitializationSupported || !isDbmsOutputStatement(sql)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Connection connection = requireConnected();
|
||||
Object physicalConnection = physicalConnectionIdentity(connection);
|
||||
synchronized (dbmsOutputInitializedConnections) {
|
||||
if (dbmsOutputInitializedConnections.containsKey(physicalConnection)) {
|
||||
return;
|
||||
}
|
||||
dbmsOutputInitializedConnections.put(physicalConnection, Boolean.TRUE);
|
||||
}
|
||||
|
||||
Integer originalNetworkTimeout = applyDbmsOutputNetworkTimeout(connection);
|
||||
SQLException setupError = null;
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.setQueryTimeout(DBMS_OUTPUT_ENABLE_TIMEOUT_SECS);
|
||||
statement.execute("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;");
|
||||
} catch (SQLException error) {
|
||||
if (!isIgnorableDbmsOutputError(error)) {
|
||||
throw error;
|
||||
setupError = error;
|
||||
}
|
||||
|
||||
boolean setupTimedOut = setupError != null && isTimeoutError(setupError);
|
||||
if (originalNetworkTimeout != null && !setupTimedOut) {
|
||||
try {
|
||||
connection.setNetworkTimeout(Runnable::run, originalNetworkTimeout);
|
||||
} catch (SQLException restoreError) {
|
||||
dbmsOutputInitializationSupported = false;
|
||||
if (setupError != null) {
|
||||
restoreError.addSuppressed(setupError);
|
||||
}
|
||||
throw restoreError;
|
||||
}
|
||||
}
|
||||
|
||||
if (setupError == null) {
|
||||
return;
|
||||
}
|
||||
if (setupTimedOut) {
|
||||
// Force Hikari to discard this connection. Its retry uses the same agent
|
||||
// instance and skips optional DBMS_OUTPUT initialization.
|
||||
dbmsOutputInitializationSupported = false;
|
||||
throw setupError;
|
||||
}
|
||||
if (isIgnorableDbmsOutputError(setupError)) {
|
||||
dbmsOutputInitializationSupported = false;
|
||||
return;
|
||||
}
|
||||
throw setupError;
|
||||
}
|
||||
|
||||
private static boolean isDbmsOutputStatement(String sql) {
|
||||
if (sql == null) {
|
||||
return false;
|
||||
}
|
||||
int start = skipSqlTrivia(sql, 0);
|
||||
return startsWithKeyword(sql, start, "CALL")
|
||||
|| startsWithKeyword(sql, start, "BEGIN")
|
||||
|| startsWithKeyword(sql, start, "DECLARE")
|
||||
|| startsWithKeyword(sql, start, "EXEC")
|
||||
|| startsWithKeyword(sql, start, "EXECUTE");
|
||||
}
|
||||
|
||||
private Object physicalConnectionIdentity(Connection connection) {
|
||||
try {
|
||||
String driverClassName = externalDriver == null ? driverClass() : externalDriver.getClass().getName();
|
||||
int packageSeparator = driverClassName.lastIndexOf('.');
|
||||
if (packageSeparator < 0) {
|
||||
return connection;
|
||||
}
|
||||
String connectionClassName = driverClassName.substring(0, packageSeparator + 1) + "DmdbConnection";
|
||||
ClassLoader loader = externalDriverLoader == null ? getClass().getClassLoader() : externalDriverLoader;
|
||||
Class<?> connectionClass = Class.forName(connectionClassName, false, loader);
|
||||
return connection.unwrap(connectionClass);
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean startsWithKeyword(String sql, int start, String keyword) {
|
||||
int end = start + keyword.length();
|
||||
return end <= sql.length()
|
||||
&& sql.regionMatches(true, start, keyword, 0, keyword.length())
|
||||
&& (end == sql.length() || !isIdentifierPart(sql.charAt(end)));
|
||||
}
|
||||
|
||||
private static Integer applyDbmsOutputNetworkTimeout(Connection connection) throws SQLException {
|
||||
try {
|
||||
int originalNetworkTimeout = connection.getNetworkTimeout();
|
||||
connection.setNetworkTimeout(Runnable::run, DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS);
|
||||
return originalNetworkTimeout;
|
||||
} catch (SQLFeatureNotSupportedException | AbstractMethodError | UnsupportedOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTimeoutError(Throwable error) {
|
||||
return isTimeoutError(error, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
}
|
||||
|
||||
private static boolean isTimeoutError(Throwable error, Set<Throwable> visited) {
|
||||
if (error == null || !visited.add(error)) {
|
||||
return false;
|
||||
}
|
||||
if (error instanceof SQLTimeoutException || error instanceof SocketTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
String message = error.getMessage();
|
||||
if (message != null) {
|
||||
String normalized = message.toLowerCase(Locale.ROOT);
|
||||
if (normalized.contains("timeout") || normalized.contains("timed out") || normalized.contains("超时")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (error instanceof SQLException sqlError && isTimeoutError(sqlError.getNextException(), visited)) {
|
||||
return true;
|
||||
}
|
||||
return isTimeoutError(error.getCause(), visited);
|
||||
}
|
||||
|
||||
private static boolean isIgnorableDbmsOutputError(SQLException error) {
|
||||
@@ -211,11 +458,17 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
if (legacyJdbcMetadata) {
|
||||
return unchecked(() -> listJdbcSchemas().stream().map(DatabaseInfo::new).toList());
|
||||
}
|
||||
return unchecked(() -> listVisibleUsers().stream().map(DatabaseInfo::new).toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnected(), DM6_METADATA_PROFILE);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
try {
|
||||
return listVisibleSchemas();
|
||||
@@ -285,6 +538,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return executeJdbcMetadataTables(schema, constraints);
|
||||
}
|
||||
if (!constraints.includesTableLikeTypes()) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -434,6 +690,92 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断错误是否属于「元数据不可用」:DBMS_METADATA 系统包未安装/无权限、
|
||||
* 或系统字典视图缺失/无权限。典型 DM 错误码:[‑3325] 包/对象解析失败、
|
||||
* [‑2207] 无法解析的成员访问表达式(如 SF_DBMS_METADATA_RETURN_DDL)。
|
||||
* 此类错误应触发源码/DDL 降级,而不是让双击对象查看源码直接报错。
|
||||
*/
|
||||
private static boolean isDamengMetadataUnavailableError(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
if (!(current instanceof SQLException sqlError)) {
|
||||
continue;
|
||||
}
|
||||
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
|
||||
int errorCode = candidate.getErrorCode();
|
||||
if (errorCode == -3325 || errorCode == -2207) {
|
||||
return true;
|
||||
}
|
||||
String message = candidate.getMessage();
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
String normalized = message.toLowerCase(Locale.ROOT);
|
||||
boolean metadataObject = normalized.contains("all_objects")
|
||||
|| normalized.contains("sysobjects")
|
||||
|| normalized.contains("all_dependencies")
|
||||
|| normalized.contains("all_tab_comments")
|
||||
|| normalized.contains("dbms_metadata")
|
||||
|| normalized.contains("get_ddl")
|
||||
|| normalized.contains("return_ddl")
|
||||
|| normalized.contains("all_source")
|
||||
|| normalized.contains("all_views")
|
||||
|| normalized.contains("all_triggers")
|
||||
|| normalized.contains("all_sequences")
|
||||
|| normalized.contains("systexts");
|
||||
boolean permissionDenied = normalized.contains("权限")
|
||||
|| normalized.contains("privilege")
|
||||
|| normalized.contains("permission")
|
||||
|| normalized.contains("access denied")
|
||||
|| normalized.contains("not authorized");
|
||||
boolean missingObject = normalized.contains("解析失败")
|
||||
|| normalized.contains("无法解析")
|
||||
|| normalized.contains("不存在")
|
||||
|| normalized.contains("not exist")
|
||||
|| normalized.contains("does not exist")
|
||||
|| normalized.contains("not found")
|
||||
|| normalized.contains("未发现");
|
||||
if (metadataObject && (permissionDenied || missingObject)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 连接类错误(断连/超时等)不属于元数据不可用,必须继续上抛。 */
|
||||
private static boolean isDamengConnectionError(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
if (current instanceof SQLRecoverableException
|
||||
|| current instanceof SQLNonTransientConnectionException
|
||||
|| current instanceof SQLTransientConnectionException) {
|
||||
return true;
|
||||
}
|
||||
if (!(current instanceof SQLException sqlError)) {
|
||||
continue;
|
||||
}
|
||||
for (SQLException candidate = sqlError; candidate != null; candidate = candidate.getNextException()) {
|
||||
String message = candidate.getMessage();
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
String normalized = message.toLowerCase(Locale.ROOT);
|
||||
if (normalized.contains("connection reset")
|
||||
|| normalized.contains("connection lost")
|
||||
|| normalized.contains("connection closed")
|
||||
|| normalized.contains("关闭的连接")
|
||||
|| normalized.contains("网络通信异常")
|
||||
|| normalized.contains("通信异常")
|
||||
|| normalized.contains("socket")
|
||||
|| normalized.contains("timed out")
|
||||
|| normalized.contains("超时")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<TableInfo> executeConstrainedTables(MetadataQuery query, MetadataListConstraints constraints) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
@@ -717,6 +1059,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return executeJdbcMetadataObjects(schema, constraints);
|
||||
}
|
||||
if (!includesSupportedObjectTypes(constraints)) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -887,18 +1232,352 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
return unchecked(() -> {
|
||||
String dbmsType = damengDdlObjectType(objectType);
|
||||
String source;
|
||||
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, dbmsType);
|
||||
stmt.setString(2, name);
|
||||
stmt.setString(3, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
source = rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
|
||||
RuntimeException dbmsError;
|
||||
try {
|
||||
String source = readDbmsMetadataObjectSource(dbmsType, name, schema);
|
||||
return new ObjectSource(name, objectType, schema, source);
|
||||
} catch (Exception error) {
|
||||
// 检查型异常(SQLException 等)由 unchecked 透传后在此处统一处理。
|
||||
RuntimeException runtimeError = error instanceof RuntimeException runtime ? runtime : new RuntimeException(error);
|
||||
if (!isDamengMetadataUnavailableError(runtimeError)) {
|
||||
throw runtimeError;
|
||||
}
|
||||
dbmsError = runtimeError;
|
||||
}
|
||||
try {
|
||||
return buildCatalogObjectSource(schema, name, objectType, dbmsError);
|
||||
} catch (RuntimeException fallbackError) {
|
||||
fallbackError.addSuppressed(dbmsError);
|
||||
throw fallbackError;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String readDbmsMetadataObjectSource(String dbmsType, String name, String schema) throws Exception {
|
||||
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, dbmsType);
|
||||
stmt.setString(2, name);
|
||||
stmt.setString(3, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
return rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 DBMS_METADATA 系统包缺失/无权限时(典型错误:[‑3325] 包/对象解析失败、
|
||||
* [‑2207] 无法解析的成员访问表达式,如 SF_DBMS_METADATA_RETURN_DDL),
|
||||
* 从系统字典视图重建对象源码:
|
||||
* <ul>
|
||||
* <li>VIEW/MATERIALIZED_VIEW → ALL_VIEWS.TEXT</li>
|
||||
* <li>TRIGGER → ALL_TRIGGERS.TRIGGER_BODY</li>
|
||||
* <li>SEQUENCE → 由 ALL_SEQUENCES 元数据重建 CREATE SEQUENCE 语句</li>
|
||||
* <li>PROCEDURE/FUNCTION/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → ALL_SOURCE 按 LINE 拼接,
|
||||
* 再以 SYS.SYSOBJECTS + SYS.SYSTEXTS 作为最后一层</li>
|
||||
* </ul>
|
||||
* 所有字典来源都不可用时返回带原因说明的占位源码(不可编辑),避免双击对象查看源码直接报错;
|
||||
* 连接类错误(断连/超时等)不属于元数据不可用,继续上抛由上层处理会话。
|
||||
*/
|
||||
private ObjectSource buildCatalogObjectSource(
|
||||
String schema,
|
||||
String name,
|
||||
String objectType,
|
||||
RuntimeException dbmsError
|
||||
) throws Exception {
|
||||
String type = normalizeObjectSourceType(objectType);
|
||||
switch (type) {
|
||||
case "VIEW", "MATERIALIZED_VIEW" -> {
|
||||
ObjectSource fromView = catalogTextSource(
|
||||
schema,
|
||||
name,
|
||||
objectType,
|
||||
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?",
|
||||
false
|
||||
);
|
||||
if (fromView != null) {
|
||||
return fromView;
|
||||
}
|
||||
}
|
||||
return new ObjectSource(name, objectType, schema, source);
|
||||
});
|
||||
case "TRIGGER" -> {
|
||||
ObjectSource fromTrigger = catalogTextSource(
|
||||
schema,
|
||||
name,
|
||||
objectType,
|
||||
"SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = ? AND TRIGGER_NAME = ?",
|
||||
false
|
||||
);
|
||||
if (fromTrigger != null) {
|
||||
return fromTrigger;
|
||||
}
|
||||
}
|
||||
case "SEQUENCE" -> {
|
||||
ObjectSource fromSequence = catalogSequenceSource(schema, name, objectType);
|
||||
if (fromSequence != null) {
|
||||
return fromSequence;
|
||||
}
|
||||
}
|
||||
case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> {
|
||||
ObjectSource fromAllSource = catalogRoutineSource(schema, name, objectType);
|
||||
if (fromAllSource != null) {
|
||||
return fromAllSource;
|
||||
}
|
||||
ObjectSource fromSystemText = catalogRoutineSystemText(schema, name, objectType);
|
||||
if (fromSystemText != null) {
|
||||
return fromSystemText;
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
}
|
||||
return unavailableObjectSource(schema, name, objectType, dbmsError);
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典视图来源(ALL_VIEWS.TEXT / ALL_TRIGGERS.TRIGGER_BODY)通常只包含对象正文、
|
||||
* 不含完整的 CREATE/ALTER 语句头,不能作为可执行 DDL 保存,故标记为不可在线编辑。
|
||||
*/
|
||||
private static final String CATALOG_BODY_HINT =
|
||||
"-- 以下内容来自系统字典视图,仅为对象正文,可能不包含完整语句头,不可在线编辑。\n";
|
||||
|
||||
/** 读取单行单列文本(视图/触发器源码),空结果返回空串。 */
|
||||
private String scalarText(String sql, String schema, String name) throws Exception {
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
return rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectSource catalogTextSource(String schema, String name, String objectType, String sql, boolean editable) {
|
||||
try {
|
||||
String text = scalarText(sql, schema, name);
|
||||
if (!notBlank(text)) {
|
||||
return null;
|
||||
}
|
||||
if (!editable) {
|
||||
text = CATALOG_BODY_HINT + text;
|
||||
}
|
||||
return new ObjectSource(name, objectType, schema, text, editable);
|
||||
} catch (RuntimeException error) {
|
||||
return metadataTierError(error);
|
||||
} catch (Exception error) {
|
||||
return metadataTierError(new RuntimeException(error));
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectSource catalogSequenceSource(String schema, String name, String objectType) {
|
||||
try {
|
||||
String sql = """
|
||||
SELECT MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE
|
||||
FROM ALL_SEQUENCES
|
||||
WHERE SEQUENCE_OWNER = ? AND SEQUENCE_NAME = ?
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder ddl = new StringBuilder("CREATE SEQUENCE ").append(qualifiedName(schema, name));
|
||||
String minValue = rs.getString("MIN_VALUE");
|
||||
String maxValue = rs.getString("MAX_VALUE");
|
||||
String increment = rs.getString("INCREMENT_BY");
|
||||
if (increment != null && !"1".equals(increment.trim())) {
|
||||
ddl.append(" INCREMENT BY ").append(increment.trim());
|
||||
}
|
||||
if (minValue != null && !"1".equals(minValue.trim())) {
|
||||
ddl.append(" MINVALUE ").append(minValue.trim());
|
||||
}
|
||||
if (maxValue != null) {
|
||||
ddl.append(" MAXVALUE ").append(maxValue.trim());
|
||||
}
|
||||
String cache = rs.getString("CACHE_SIZE");
|
||||
if (cache != null && !"0".equals(cache.trim())) {
|
||||
ddl.append(" CACHE ").append(cache.trim());
|
||||
} else {
|
||||
ddl.append(" NOCACHE");
|
||||
}
|
||||
ddl.append("Y".equalsIgnoreCase(rs.getString("CYCLE_FLAG")) ? " CYCLE" : " NOCYCLE");
|
||||
ddl.append("Y".equalsIgnoreCase(rs.getString("ORDER_FLAG")) ? " ORDER" : " NOORDER");
|
||||
ddl.append(";");
|
||||
// 序列 DDL 由元数据重建,不提供在线编辑。
|
||||
return new ObjectSource(name, objectType, schema, ddl.toString(), false);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
return metadataTierError(error);
|
||||
} catch (Exception error) {
|
||||
return metadataTierError(new RuntimeException(error));
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectSource catalogRoutineSource(String schema, String name, String objectType) {
|
||||
try {
|
||||
String type = normalizeObjectSourceType(objectType);
|
||||
List<String> typeCandidates = switch (type) {
|
||||
// 不同 DM 版本对函数/过程的 TYPE 取值不一致(有的统一为 PROCEDURE),逐一尝试。
|
||||
case "FUNCTION" -> List.of("FUNCTION", "PROCEDURE");
|
||||
case "PROCEDURE" -> List.of("PROCEDURE", "FUNCTION");
|
||||
case "PACKAGE" -> List.of("PACKAGE");
|
||||
case "PACKAGE_BODY" -> List.of("PACKAGE BODY", "PACKAGE_BODY");
|
||||
case "TYPE" -> List.of("TYPE");
|
||||
case "TYPE_BODY" -> List.of("TYPE BODY", "TYPE_BODY");
|
||||
default -> List.of();
|
||||
};
|
||||
for (String candidate : typeCandidates) {
|
||||
String source = readAllSourceLines(schema, name, candidate);
|
||||
if (notBlank(source)) {
|
||||
return catalogRoutineObjectSource(schema, name, objectType, source);
|
||||
}
|
||||
}
|
||||
// 兜底:忽略 TYPE 过滤按名称读取全部源码行(同一 schema 内对象名唯一)。
|
||||
String anyTypeSource = readAllSourceLines(schema, name, null);
|
||||
if (notBlank(anyTypeSource)) {
|
||||
return catalogRoutineObjectSource(schema, name, objectType, anyTypeSource);
|
||||
}
|
||||
return null;
|
||||
} catch (RuntimeException error) {
|
||||
return metadataTierError(error);
|
||||
} catch (Exception error) {
|
||||
return metadataTierError(new RuntimeException(error));
|
||||
}
|
||||
}
|
||||
|
||||
private String readAllSourceLines(String schema, String name, String sourceType) throws Exception {
|
||||
String sql = sourceType == null
|
||||
? "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? ORDER BY LINE"
|
||||
: "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? AND TYPE = ? ORDER BY LINE";
|
||||
StringBuilder source = new StringBuilder();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, name);
|
||||
if (sourceType != null) {
|
||||
stmt.setString(3, sourceType);
|
||||
}
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String line = coalesce(readTextColumn(rs, 1));
|
||||
source.append(line);
|
||||
if (!line.endsWith("\n")) {
|
||||
source.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.toString();
|
||||
}
|
||||
|
||||
private static ObjectSource catalogRoutineObjectSource(
|
||||
String schema,
|
||||
String name,
|
||||
String objectType,
|
||||
String source
|
||||
) {
|
||||
String normalizedSource = source.stripLeading();
|
||||
String upperSource = normalizedSource.toUpperCase(Locale.ROOT);
|
||||
boolean executable = upperSource.startsWith("CREATE ") || upperSource.startsWith("ALTER ");
|
||||
if (!executable) {
|
||||
String declarationType = normalizeObjectSourceType(objectType).replace('_', ' ');
|
||||
if (startsWithRoutineDeclaration(normalizedSource, declarationType)) {
|
||||
normalizedSource = "CREATE OR REPLACE " + normalizedSource;
|
||||
executable = true;
|
||||
}
|
||||
}
|
||||
if (!executable) {
|
||||
normalizedSource = CATALOG_BODY_HINT + normalizedSource;
|
||||
}
|
||||
return new ObjectSource(name, objectType, schema, normalizedSource, executable);
|
||||
}
|
||||
|
||||
private static boolean startsWithRoutineDeclaration(String source, String declarationType) {
|
||||
if (!source.regionMatches(true, 0, declarationType, 0, declarationType.length())) {
|
||||
return false;
|
||||
}
|
||||
return source.length() == declarationType.length()
|
||||
|| Character.isWhitespace(source.charAt(declarationType.length()));
|
||||
}
|
||||
|
||||
private ObjectSource catalogRoutineSystemText(String schema, String name, String objectType) {
|
||||
try {
|
||||
// DM 将过程/函数/包/类型的定义文本按行存放在 SYS.SYSTEXTS,
|
||||
// SYS.SYSOBJECTS 的 SCHID 关联所属 schema;适用于 ALL_SOURCE 不可用的实例。
|
||||
String sql = """
|
||||
SELECT t.TEXT
|
||||
FROM SYS.SYSTEXTS t
|
||||
JOIN SYS.SYSOBJECTS o ON o.ID = t.ID
|
||||
JOIN SYS.SYSOBJECTS s ON s.ID = o.SCHID AND s.TYPE$ = 'SCH' AND s.NAME = ?
|
||||
WHERE o.NAME = ?
|
||||
ORDER BY t.LINE
|
||||
""".stripIndent().trim();
|
||||
StringBuilder source = new StringBuilder();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, name);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String line = coalesce(readTextColumn(rs, 1));
|
||||
source.append(line);
|
||||
if (!line.endsWith("\n")) {
|
||||
source.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return notBlank(source.toString())
|
||||
? catalogRoutineObjectSource(schema, name, objectType, source.toString())
|
||||
: null;
|
||||
} catch (RuntimeException error) {
|
||||
return metadataTierError(error);
|
||||
} catch (Exception error) {
|
||||
return metadataTierError(new RuntimeException(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据降级分层中的单层错误处理:连接类错误继续上抛(会话处理需要真实错误),
|
||||
* 元数据不可用/未知错误返回 {@code null} 让调用方尝试下一层。
|
||||
*/
|
||||
private static ObjectSource metadataTierError(RuntimeException error) {
|
||||
if (isDamengConnectionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
LOGGER.log(
|
||||
Level.FINE,
|
||||
"Dameng metadata catalog tier unavailable for source fallback: " + firstLine(error.getMessage()),
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 所有降级来源都不可用时返回带原因说明的占位源码,避免双击对象查看源码直接报错。 */
|
||||
private ObjectSource unavailableObjectSource(
|
||||
String schema,
|
||||
String name,
|
||||
String objectType,
|
||||
RuntimeException dbmsError
|
||||
) {
|
||||
String reason = dbmsError == null ? "未知" : firstLine(dbmsError.getMessage());
|
||||
String source = "-- 无法获取 " + qualifiedName(schema, name) + "(" + objectType + ")的完整定义。\n"
|
||||
+ "-- 当前连接缺少 DBMS_METADATA 系统包(未安装或无执行权限),系统字典中也没有可用源码。\n"
|
||||
+ "-- 如需完整 DDL,请由 DBA 执行 SP_CREATE_SYSTEM_PACKAGES(1) 安装系统包后重试。\n"
|
||||
+ "-- 底层错误:" + reason;
|
||||
return new ObjectSource(name, objectType, schema, source, false);
|
||||
}
|
||||
|
||||
private static String firstLine(String message) {
|
||||
if (message == null || message.isEmpty()) {
|
||||
return "未知";
|
||||
}
|
||||
int newline = message.indexOf('\n');
|
||||
return newline < 0 ? message.trim() : message.substring(0, newline).trim();
|
||||
}
|
||||
|
||||
private static String normalizeObjectSourceType(String objectType) {
|
||||
String value = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT);
|
||||
return value.replace(' ', '_');
|
||||
}
|
||||
|
||||
static String damengDdlObjectType(String objectType) {
|
||||
@@ -912,12 +1591,18 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
case "PACKAGE_BODY", "PACKAGE BODY" -> "PKG_BODY";
|
||||
// DM DBMS_METADATA accepts TRIGGER directly and returns executable CREATE OR REPLACE DDL.
|
||||
case "TRIGGER" -> "TRIGGER";
|
||||
// 用户自定义类型:与 Oracle 兼容的 DBMS_METADATA 对象类型名。
|
||||
case "TYPE" -> "TYPE";
|
||||
case "TYPE_BODY", "TYPE BODY" -> "TYPE BODY";
|
||||
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return super.getTableDdl(schema, table);
|
||||
}
|
||||
try {
|
||||
return unchecked(() -> {
|
||||
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
|
||||
@@ -939,7 +1624,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
|
||||
});
|
||||
} catch (RuntimeException error) {
|
||||
if (!isDamengMetadataPermissionError(error)) {
|
||||
if (!isDamengMetadataUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -953,6 +1638,15 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.getColumns(
|
||||
requireConnected(),
|
||||
DM6_METADATA_PROFILE,
|
||||
getConfiguredDatabase(),
|
||||
schema,
|
||||
table
|
||||
);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
Set<String> pkColumns = new java.util.HashSet<>();
|
||||
String pkSql = """
|
||||
@@ -1057,6 +1751,15 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listIndexes(
|
||||
requireConnected(),
|
||||
DM6_METADATA_PROFILE,
|
||||
getConfiguredDatabase(),
|
||||
schema,
|
||||
table
|
||||
);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
String sql = """
|
||||
@@ -1097,6 +1800,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnected(), schema, table);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
String sql = """
|
||||
@@ -1128,6 +1834,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listTriggers(schema, table);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<TriggerInfo> result = new ArrayList<>();
|
||||
String sql = """
|
||||
@@ -1156,6 +1865,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
// DM JDBC reports raw EXPLAIN as an update count; its driver API is the only source of plan rows.
|
||||
return executeExplainQuery(explainSql, schema, options);
|
||||
}
|
||||
uncheckedVoid(() -> initializeDbmsOutputIfNeeded(sql));
|
||||
return JdbcExecutor.current().execute(
|
||||
requireConnected(),
|
||||
sql,
|
||||
@@ -1188,7 +1898,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
String message = (String) value;
|
||||
return message.isEmpty() ? List.of() : message.lines().toList();
|
||||
} catch (Exception ignored) {
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -1279,6 +1989,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
false
|
||||
);
|
||||
}
|
||||
uncheckedVoid(() -> initializeDbmsOutputIfNeeded(sql));
|
||||
return JdbcExecutor.current().executePage(
|
||||
requireConnected(),
|
||||
sql,
|
||||
@@ -1316,7 +2027,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
case Types.FLOAT, Types.REAL -> rs.getFloat(index);
|
||||
case Types.DOUBLE -> rs.getDouble(index);
|
||||
case Types.DECIMAL, Types.NUMERIC -> rs.getBigDecimal(index);
|
||||
case Types.BOOLEAN, Types.BIT -> rs.getBoolean(index);
|
||||
case Types.BOOLEAN -> rs.getBoolean(index);
|
||||
case Types.BIT -> rs.getByte(index);
|
||||
case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR,
|
||||
Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR,
|
||||
Types.CLOB, Types.NCLOB -> rs.getString(index);
|
||||
@@ -1385,6 +2097,11 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private static String buildUrl(ConnectParams params) {
|
||||
String connectionString = params.getConnection_string() == null ? "" : params.getConnection_string().trim();
|
||||
if (!connectionString.isEmpty()
|
||||
&& !connectionString.regionMatches(true, 0, "dm://", 0, "dm://".length())) {
|
||||
return connectionString;
|
||||
}
|
||||
String database = params.getDatabase() == null ? "" : params.getDatabase().trim();
|
||||
String suffix = database.isEmpty() ? "" : "/" + database;
|
||||
String url = "jdbc:dm://" + params.getHost() + ":" + params.getPort() + suffix;
|
||||
|
||||
+489
@@ -1,6 +1,7 @@
|
||||
package com.dbx.agent.dameng;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
@@ -25,6 +26,31 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
class DamengAgentMetadataTest {
|
||||
@Test
|
||||
void detectsLegacyDamengMetadataFromDatabaseVersion() {
|
||||
Assertions.assertTrue(DamengAgent.usesLegacyJdbcMetadata(versionConnection(6, "6.0.2.79")));
|
||||
Assertions.assertFalse(DamengAgent.usesLegacyJdbcMetadata(versionConnection(8, "8.1.5.45")));
|
||||
Assertions.assertTrue(DamengAgent.usesLegacyJdbcMetadata(versionConnection(0, "DM Database Server x64 V6.0.2.79")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyDamengUsesJdbcMetadataWithoutQueryingDm8CatalogViews() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> metadataCalls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, legacyMetadataConnection(metadataCalls));
|
||||
setLegacyJdbcMetadata(agent, true);
|
||||
|
||||
List<DatabaseInfo> databases = agent.listDatabases();
|
||||
List<String> schemas = agent.listSchemas();
|
||||
List<TableInfo> tables = agent.listTables("DBX_TEST");
|
||||
|
||||
Assertions.assertEquals(List.of("DBX_TEST", "SYSDBA"), databases.stream().map(DatabaseInfo::getName).toList());
|
||||
Assertions.assertEquals(List.of("DBX_TEST", "SYSDBA"), schemas);
|
||||
Assertions.assertEquals(List.of("CONNECTION_SMOKE"), tables.stream().map(TableInfo::getName).toList());
|
||||
Assertions.assertTrue(metadataCalls.contains("getSchemas"), metadataCalls.toString());
|
||||
Assertions.assertTrue(metadataCalls.contains("getTables:DBX\\_TEST"), metadataCalls.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesColumnCommentsMetadataQuery() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -508,6 +534,341 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE VIEW"), source.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAllSourceFunctionDdlWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(
|
||||
Arrays.asList("CREATE OR REPLACE FUNCTION \"APP\".\"CALC_SCORE\"(V_ID INT) RETURN INT AS"),
|
||||
Arrays.asList("BEGIN"),
|
||||
Arrays.asList(" RETURN V_ID * 2;"),
|
||||
Arrays.asList("END;")
|
||||
),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE FUNCTION"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("RETURN V_ID * 2;"), source.getSource());
|
||||
Assertions.assertTrue(source.isEditable());
|
||||
Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAllSourceTypeDdlWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(
|
||||
Arrays.asList("CREATE OR REPLACE TYPE \"APP\".\"T_ADDR\" AS OBJECT("),
|
||||
Arrays.asList(" CITY VARCHAR2(64)"),
|
||||
Arrays.asList(")")
|
||||
),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE TYPE"), source.getSource());
|
||||
Assertions.assertTrue(source.isEditable());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAllSourcePackageBodyDdlWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(
|
||||
Arrays.asList("CREATE OR REPLACE PACKAGE BODY \"APP\".\"PKG_DEMO\" AS"),
|
||||
Arrays.asList(" PROCEDURE P IS BEGIN NULL; END;"),
|
||||
Arrays.asList("END PKG_DEMO;")
|
||||
),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE_BODY");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE PACKAGE BODY"), source.getSource());
|
||||
Assertions.assertTrue(source.isEditable());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapsBareAllSourcePackageBodyAsExecutableDdl() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(
|
||||
Arrays.asList("PACKAGE BODY \"APP\".\"PKG_DEMO\" AS"),
|
||||
Arrays.asList(" PROCEDURE P IS BEGIN NULL; END;"),
|
||||
Arrays.asList("END PKG_DEMO;")
|
||||
),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE_BODY");
|
||||
|
||||
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE PACKAGE BODY"), source.getSource());
|
||||
Assertions.assertTrue(source.isEditable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToViewTextWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "V_ACTIVE_USERS", "VIEW");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("SELECT 1 AS ID FROM DUAL"), source.getSource());
|
||||
// 字典视图正文不包含完整 CREATE VIEW 头,标记为不可编辑。
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_VIEWS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTriggerBodyWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "TRG_USERS", "TRIGGER");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE TRIGGER"), source.getSource());
|
||||
// 触发器正文可能不含完整创建语句头,标记为不可编辑。
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_TRIGGERS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconstructsSequenceDdlWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(missingDbmsMetadataPackageError(), sqls, List.of(), null, List.of(), null)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "SEQ_ORDERS", "SEQUENCE");
|
||||
|
||||
Assertions.assertTrue(
|
||||
source.getSource().contains("CREATE SEQUENCE \"APP\".\"SEQ_ORDERS\""),
|
||||
source.getSource()
|
||||
);
|
||||
Assertions.assertTrue(source.getSource().contains("INCREMENT BY 5"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("MAXVALUE 9999999999"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("CACHE 20"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("NOCYCLE"), source.getSource());
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SEQUENCES")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToSysTextsWhenAllSourceIsUnavailable() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(),
|
||||
missingCatalogViewError("ALL_SOURCE"),
|
||||
List.of(
|
||||
Arrays.asList("CREATE OR REPLACE FUNCTION \"APP\".\"CALC_SCORE\"(V_ID INT) RETURN INT AS"),
|
||||
Arrays.asList("BEGIN"),
|
||||
Arrays.asList(" RETURN V_ID * 2;"),
|
||||
Arrays.asList("END;")
|
||||
),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE FUNCTION"), source.getSource());
|
||||
Assertions.assertTrue(source.isEditable());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS") && sql.contains("SYS.SYSOBJECTS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapsBareSysTextsTypeAsExecutableDdl() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(),
|
||||
missingCatalogViewError("ALL_SOURCE"),
|
||||
List.of(
|
||||
Arrays.asList("TYPE \"APP\".\"T_ADDR\" AS OBJECT("),
|
||||
Arrays.asList(" CITY VARCHAR2(64)"),
|
||||
Arrays.asList(")")
|
||||
),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE");
|
||||
|
||||
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE TYPE"), source.getSource());
|
||||
Assertions.assertTrue(source.isEditable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsUnrecognizedRoutineCatalogTextReadOnly() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(Arrays.asList("BEGIN NULL; END;")),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "PKG_DEMO", "PACKAGE");
|
||||
|
||||
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsUnavailablePlaceholderWhenAllCatalogSourcesFail() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
missingDbmsMetadataPackageError(),
|
||||
sqls,
|
||||
List.of(),
|
||||
missingCatalogViewError("ALL_SOURCE"),
|
||||
List.of(),
|
||||
missingCatalogViewError("SYS.SYSTEXTS")
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION");
|
||||
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
Assertions.assertTrue(source.getSource().startsWith("--"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("SP_CREATE_SYSTEM_PACKAGES"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("DBMS_METADATA"), source.getSource());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesConnectionResetWithoutCatalogFallback() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(
|
||||
new SQLException("DBMS_METADATA.GET_DDL connection reset"),
|
||||
sqls,
|
||||
List.of(),
|
||||
null,
|
||||
List.of(),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("DBMS_METADATA.GET_DDL connection reset", error.getCause().getMessage());
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotFallBackForUnrelatedDbmsMetadataErrors() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
SQLException unrelated = new SQLException("DBMS_METADATA.GET_DDL 内部执行错误", "99999", -9001);
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
catalogFallbackConnection(unrelated, sqls, List.of(), null, List.of(), null)
|
||||
);
|
||||
|
||||
Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.getObjectSource("APP", "CALC_SCORE", "FUNCTION")
|
||||
);
|
||||
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlFallsBackWhenDbmsMetadataPackageIsMissing() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
metadataConnectionWithDbmsMetadataError(
|
||||
sqls,
|
||||
"[-2207]:无法解析的成员访问表达式[SF_DBMS_METADATA_RETURN_DDL]\n[-3325]:包/对象[DBMS_METADATA]解析失败"
|
||||
)
|
||||
);
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TAB_COLUMNS")), String.join("\n", sqls));
|
||||
Assertions.assertEquals(1, sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void triggerMetadataDoesNotRequireOracleTriggerTypeColumn() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -1000,6 +1361,56 @@ class DamengAgentMetadataTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void setLegacyJdbcMetadata(DamengAgent agent, boolean value) {
|
||||
try {
|
||||
Field field = DamengAgent.class.getDeclaredField("legacyJdbcMetadata");
|
||||
field.setAccessible(true);
|
||||
field.set(agent, value);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException("Unable to set legacy JDBC metadata mode", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Connection versionConnection(int majorVersion, String productVersion) {
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> switch (method.getName()) {
|
||||
case "getDatabaseMajorVersion" -> majorVersion;
|
||||
case "getDatabaseProductVersion" -> productVersion;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
return proxy(Connection.class, (method, args) ->
|
||||
"getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType())
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection legacyMetadataConnection(List<String> calls) {
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getSchemas":
|
||||
calls.add("getSchemas");
|
||||
return metadataResultSet(List.of(List.of("SYSDBA"), List.of("DBX_TEST")));
|
||||
case "getTableTypes":
|
||||
return metadataResultSet(List.of(List.of("", "TABLE", "")));
|
||||
case "getTables":
|
||||
Assertions.assertNull(args[3], "DM6 JDBC rejects non-null getTables types");
|
||||
calls.add("getTables:" + args[1]);
|
||||
return metadataResultSet(List.of(List.of("CONNECTION_SMOKE", "TABLE", "smoke table")));
|
||||
case "getSearchStringEscape":
|
||||
return "\\";
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return metadata;
|
||||
}
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
throw new AssertionError("Legacy metadata mode must not query DM8 catalog views: " + args[0]);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection objectSourceConnection(List<String> params, String source) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
@@ -1016,6 +1427,78 @@ class DamengAgentMetadataTest {
|
||||
});
|
||||
}
|
||||
|
||||
/** 模拟 DM 实例未安装 DBMS_METADATA 系统包时的典型错误([‑2207]/[‑3325])。 */
|
||||
private static SQLException missingDbmsMetadataPackageError() {
|
||||
return new SQLException(
|
||||
"[-2207]:无法解析的成员访问表达式[SF_DBMS_METADATA_RETURN_DDL]\n"
|
||||
+ "[-3325]:包/对象[DBMS_METADATA]解析失败",
|
||||
"22000",
|
||||
-3325
|
||||
);
|
||||
}
|
||||
|
||||
/** 模拟系统字典视图(ALL_SOURCE / SYS.SYSTEXTS 等)不存在的错误。 */
|
||||
private static SQLException missingCatalogViewError(String view) {
|
||||
return new SQLException("表或视图不存在: " + view, "42000", -2106);
|
||||
}
|
||||
|
||||
/**
|
||||
* DBMS_METADATA 失败时走系统字典降级的连接:ALL_VIEWS/ALL_TRIGGERS/ALL_SEQUENCES
|
||||
* 固定返回一行样例数据;ALL_SOURCE / SYS.SYSTEXTS 的行与错误可通过参数定制。
|
||||
*/
|
||||
private static Connection catalogFallbackConnection(
|
||||
SQLException dbmsMetadataError,
|
||||
List<String> sqls,
|
||||
List<List<Object>> allSourceRows,
|
||||
SQLException allSourceError,
|
||||
List<List<Object>> systemTextRows,
|
||||
SQLException systemTextError
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("prepareStatement".equals(name)) {
|
||||
String sql = (String) args[0];
|
||||
if (sqls != null) {
|
||||
sqls.add(sql);
|
||||
}
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
if (dbmsMetadataError != null) {
|
||||
return failingMetadataStatement(dbmsMetadataError);
|
||||
}
|
||||
return metadataStatement(List.of(List.of("CREATE OR REPLACE PROCEDURE placeholder")));
|
||||
}
|
||||
if (sql.contains("FROM ALL_SOURCE")) {
|
||||
if (allSourceError != null) {
|
||||
return failingMetadataStatement(allSourceError);
|
||||
}
|
||||
return metadataStatement(allSourceRows);
|
||||
}
|
||||
if (sql.contains("SYS.SYSTEXTS")) {
|
||||
if (systemTextError != null) {
|
||||
return failingMetadataStatement(systemTextError);
|
||||
}
|
||||
return metadataStatement(systemTextRows);
|
||||
}
|
||||
if (sql.contains("FROM ALL_VIEWS")) {
|
||||
return metadataStatement(List.of(List.of("SELECT 1 AS ID FROM DUAL")));
|
||||
}
|
||||
if (sql.contains("FROM ALL_TRIGGERS")) {
|
||||
return metadataStatement(List.of(List.of("CREATE TRIGGER \"APP\".\"TRG_USERS\" BEFORE INSERT ON \"APP\".\"USERS\" BEGIN NULL; END;")));
|
||||
}
|
||||
if (sql.contains("FROM ALL_SEQUENCES")) {
|
||||
return metadataStatement(List.of(List.of("1", "9999999999", "5", "N", "N", "20")));
|
||||
}
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection restrictedCrossOwnerMetadataConnection(List<String> sqls) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
@@ -1274,6 +1757,12 @@ class DamengAgentMetadataTest {
|
||||
case "CHAR_USED" -> string(rows, index[0], 7);
|
||||
case "COMMENTS", "COMMENT$", "REMARKS" -> string(rows, index[0], rows.get(index[0]).size() - 1);
|
||||
case "COLNAME" -> string(rows, index[0], 0);
|
||||
case "MIN_VALUE" -> string(rows, index[0], 0);
|
||||
case "MAX_VALUE" -> string(rows, index[0], 1);
|
||||
case "INCREMENT_BY" -> string(rows, index[0], 2);
|
||||
case "CYCLE_FLAG" -> string(rows, index[0], 3);
|
||||
case "ORDER_FLAG" -> string(rows, index[0], 4);
|
||||
case "CACHE_SIZE" -> string(rows, index[0], 5);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
+70
-1
@@ -20,6 +20,23 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DamengAgentPagingTest {
|
||||
@Test
|
||||
void executeQueryPageSerializesBitValuesAsNumericBytes() {
|
||||
assertBitResponse(Boolean.FALSE, "0");
|
||||
assertBitResponse(Boolean.TRUE, "1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeQueryPagePreservesNullBitAndBooleanValues() {
|
||||
String nullBitResponse = executeSingleValueQuery("FLAG", Types.BIT, null, null, null);
|
||||
String booleanResponse = executeSingleValueQuery("FLAG", Types.BOOLEAN, Boolean.TRUE, null, null);
|
||||
|
||||
assertFalse(nullBitResponse.contains("\"error\""), nullBitResponse);
|
||||
assertTrue(nullBitResponse.contains("\"rows\":[[null]]"), nullBitResponse);
|
||||
assertFalse(booleanResponse.contains("\"error\""), booleanResponse);
|
||||
assertTrue(booleanResponse.contains("\"rows\":[[true]]"), booleanResponse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeQueryPageStringifiesTimestampValuesBeforeJsonRpcSerialization() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -118,6 +135,40 @@ class DamengAgentPagingTest {
|
||||
assertFalse(response.contains("SerialBlob"), response);
|
||||
}
|
||||
|
||||
private static void assertBitResponse(Boolean value, String expected) {
|
||||
String response = executeSingleValueQuery("FLAG", Types.BIT, value, null, null);
|
||||
|
||||
assertFalse(response.contains("\"error\""), response);
|
||||
assertTrue(response.contains("\"rows\":[[" + expected + "]]"), response);
|
||||
assertFalse(response.contains("\"rows\":[[" + value + "]]"), response);
|
||||
}
|
||||
|
||||
private static String executeSingleValueQuery(
|
||||
String columnLabel,
|
||||
int sqlType,
|
||||
Object objectValue,
|
||||
String stringValue,
|
||||
byte[] bytesValue
|
||||
) {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
singleValueConnection(columnLabel, sqlType, objectValue, stringValue, bytesValue)
|
||||
);
|
||||
return handleRequest(new JsonRpcServer(agent), """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "execute_query_page",
|
||||
"params": {
|
||||
"sql": "SELECT FLAG FROM sample",
|
||||
"schema": "SYS",
|
||||
"pageSize": 100
|
||||
}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static Connection singleValueConnection(
|
||||
String columnLabel,
|
||||
int sqlType,
|
||||
@@ -191,6 +242,24 @@ class DamengAgentPagingTest {
|
||||
if ("getObject".equals(name)) {
|
||||
return objectValue;
|
||||
}
|
||||
if ("getBoolean".equals(name)) {
|
||||
if (objectValue instanceof Boolean value) {
|
||||
return value;
|
||||
}
|
||||
if (objectValue instanceof Number value) {
|
||||
return value.intValue() != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if ("getByte".equals(name)) {
|
||||
if (objectValue instanceof Boolean value) {
|
||||
return value ? (byte) 1 : (byte) 0;
|
||||
}
|
||||
if (objectValue instanceof Number value) {
|
||||
return value.byteValue();
|
||||
}
|
||||
return (byte) 0;
|
||||
}
|
||||
if ("getString".equals(name)) {
|
||||
return stringValue;
|
||||
}
|
||||
@@ -198,7 +267,7 @@ class DamengAgentPagingTest {
|
||||
return bytesValue;
|
||||
}
|
||||
if ("wasNull".equals(name)) {
|
||||
return false;
|
||||
return objectValue == null && stringValue == null && bytesValue == null;
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
|
||||
@@ -12,13 +12,14 @@ import com.dbx.agent.test.JdbcAgentFake;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
@@ -40,7 +41,7 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
|
||||
@Override
|
||||
protected String resultSetSql() {
|
||||
return "CALL SP_SAMPLE()";
|
||||
return "VALUES (1)";
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,72 +61,152 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsEnableDbmsOutputWithoutChangingUserSql() throws Exception {
|
||||
void physicalConnectionsDoNotEnableDbmsOutput() throws Exception {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
List<Integer> queryTimeouts = new ArrayList<>();
|
||||
List<Integer> networkTimeouts = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
||||
agent.afterPhysicalConnect(null, printMessageConnection(null, executedSql, queryTimeouts));
|
||||
agent.afterPhysicalConnect(null, printMessageConnection(null, executedSql, queryTimeouts, networkTimeouts));
|
||||
|
||||
assertEquals(List.of(5), queryTimeouts);
|
||||
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), executedSql);
|
||||
assertTrue(queryTimeouts.isEmpty());
|
||||
assertTrue(networkTimeouts.isEmpty());
|
||||
assertTrue(executedSql.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsIgnoreUnsupportedOrRestrictedDbmsOutput() {
|
||||
void ordinaryQueriesDoNotEnableDbmsOutput() {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
|
||||
|
||||
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
|
||||
null,
|
||||
failingDbmsOutputConnection(new SQLFeatureNotSupportedException("unsupported", "0A000"))
|
||||
));
|
||||
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
|
||||
null,
|
||||
failingDbmsOutputConnection(new SQLException("permission denied", "42000"))
|
||||
));
|
||||
agent.executeQuery("SELECT 1 FROM DUAL", null, new ExecuteQueryOptions());
|
||||
|
||||
assertEquals(List.of("SELECT 1 FROM DUAL"), executedSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsIgnoreTimedOutDbmsOutputInitialization() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
void outputStatementsEnableDbmsOutputAfterCommentsWithKeywordBoundaries() {
|
||||
assertLazilyEnablesDbmsOutput("CALL LOG_ONLY_PROCEDURE()");
|
||||
assertLazilyEnablesDbmsOutput(" -- leading comment\nBEGIN NULL; END;");
|
||||
assertLazilyEnablesDbmsOutput("/* leading block */ DECLARE value INT; BEGIN NULL; END;");
|
||||
assertLazilyEnablesDbmsOutput("EXEC LOG_ONLY_PROCEDURE");
|
||||
assertLazilyEnablesDbmsOutput("EXECUTE LOG_ONLY_PROCEDURE");
|
||||
|
||||
assertDoesNotThrow(() -> agent.afterPhysicalConnect(
|
||||
null,
|
||||
failingDbmsOutputConnection(new SQLTimeoutException("DBMS_OUTPUT enable timed out"))
|
||||
));
|
||||
assertDoesNotEnableDbmsOutput("CALLBACK()");
|
||||
assertDoesNotEnableDbmsOutput("BEGINNING SELECT 1");
|
||||
assertDoesNotEnableDbmsOutput("DECLARE_VALUE INT");
|
||||
assertDoesNotEnableDbmsOutput("EXECUTOR RUN");
|
||||
assertDoesNotEnableDbmsOutput("EXECUTE_IMMEDIATE 'SELECT 1'");
|
||||
assertDoesNotEnableDbmsOutput("/* CALL hidden in a comment */ SELECT 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsPropagateConnectionFailures() {
|
||||
void legacyDamengSkipsUnavailableDbmsOutputInitialization() {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
|
||||
setLegacyJdbcMetadata(agent, true);
|
||||
|
||||
agent.executeQuery("BEGIN NULL; END;", null, new ExecuteQueryOptions());
|
||||
|
||||
assertEquals(List.of("BEGIN NULL; END;"), executedSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outputStatementsEnableDbmsOutputOncePerPhysicalConnection() {
|
||||
List<String> firstConnectionSql = new ArrayList<>();
|
||||
List<String> secondConnectionSql = new ArrayList<>();
|
||||
Object firstPhysicalConnection = new Object();
|
||||
Object secondPhysicalConnection = new Object();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
printMessageConnection(null, firstConnectionSql, firstPhysicalConnection, null)
|
||||
);
|
||||
agent.executeQuery("CALL FIRST_PROCEDURE()", null, new ExecuteQueryOptions());
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
printMessageConnection(null, firstConnectionSql, firstPhysicalConnection, null)
|
||||
);
|
||||
agent.executeQuery("BEGIN NULL; END;", null, new ExecuteQueryOptions());
|
||||
|
||||
TestSupport.setPrivateConnection(
|
||||
agent,
|
||||
printMessageConnection(null, secondConnectionSql, secondPhysicalConnection, null)
|
||||
);
|
||||
agent.executeQuery("EXEC SECOND_PROCEDURE", null, new ExecuteQueryOptions());
|
||||
|
||||
assertEquals(List.of(
|
||||
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
|
||||
"CALL FIRST_PROCEDURE()",
|
||||
"BEGIN NULL; END;"
|
||||
), firstConnectionSql);
|
||||
assertEquals(List.of(
|
||||
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
|
||||
"EXEC SECOND_PROCEDURE"
|
||||
), secondConnectionSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedOrRestrictedDbmsOutputDoesNotBlockUserSql() {
|
||||
assertDbmsOutputFailureFallsBack(new SQLFeatureNotSupportedException("unsupported", "0A000"));
|
||||
assertDbmsOutputFailureFallsBack(new SQLException("permission denied", "42000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dbmsOutputTimeoutDisablesFutureAttempts() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
SQLException timeout = new SQLException("network communication failed");
|
||||
timeout.initCause(new SocketTimeoutException("Read timed out"));
|
||||
List<String> firstConnectionSql = new ArrayList<>();
|
||||
List<String> retryConnectionSql = new ArrayList<>();
|
||||
List<Integer> firstNetworkTimeouts = new ArrayList<>();
|
||||
|
||||
TestSupport.setPrivateConnection(agent, statementConnection(
|
||||
null,
|
||||
firstConnectionSql,
|
||||
new ArrayList<>(),
|
||||
firstNetworkTimeouts,
|
||||
new Object(),
|
||||
timeout
|
||||
));
|
||||
RuntimeException error = assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.executeQuery("CALL FIRST_PROCEDURE()", null, new ExecuteQueryOptions())
|
||||
);
|
||||
assertSame(timeout, error.getCause());
|
||||
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), firstConnectionSql);
|
||||
assertEquals(List.of(5_000), firstNetworkTimeouts);
|
||||
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(
|
||||
null,
|
||||
retryConnectionSql,
|
||||
new Object(),
|
||||
null
|
||||
));
|
||||
agent.executeQuery("CALL RETRY_PROCEDURE()", null, new ExecuteQueryOptions());
|
||||
assertEquals(List.of("CALL RETRY_PROCEDURE()"), retryConnectionSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dbmsOutputInitializationPropagatesConnectionFailures() {
|
||||
SQLException transientFailure = new SQLTransientConnectionException("connection closed");
|
||||
SQLException sqlStateFailure = new SQLException("connection failure", "08006");
|
||||
SQLException wrappedFailure = new SQLException("permission denied", "42000");
|
||||
wrappedFailure.initCause(new SQLTransientConnectionException("connection closed"));
|
||||
|
||||
assertSame(transientFailure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(transientFailure))
|
||||
));
|
||||
assertSame(sqlStateFailure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(sqlStateFailure))
|
||||
));
|
||||
assertSame(wrappedFailure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(wrappedFailure))
|
||||
));
|
||||
assertDbmsOutputFailurePropagates(transientFailure);
|
||||
assertDbmsOutputFailurePropagates(sqlStateFailure);
|
||||
assertDbmsOutputFailurePropagates(wrappedFailure);
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsPropagateUnrelatedSetupFailures() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
void dbmsOutputInitializationPropagatesUnrelatedSetupFailures() {
|
||||
SQLException failure = new SQLException("resource busy", "HY000");
|
||||
|
||||
assertSame(failure, assertThrows(
|
||||
SQLException.class,
|
||||
() -> agent.afterPhysicalConnect(null, failingDbmsOutputConnection(failure))
|
||||
));
|
||||
assertDbmsOutputFailurePropagates(failure);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -142,7 +223,45 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
|
||||
assertEquals(List.of("Message"), result.getColumns());
|
||||
assertEquals(List.of(List.of("first"), List.of("中文日志")), result.getRows());
|
||||
assertEquals(List.of("CALL LOG_ONLY_PROCEDURE('input')"), executedSql);
|
||||
assertEquals(List.of(
|
||||
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
|
||||
"CALL LOG_ONLY_PROCEDURE('input')"
|
||||
), executedSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyStatementWithoutJdbc4UnwrapReturnsNoPrintMessages() {
|
||||
Statement statement = (Statement) Proxy.newProxyInstance(
|
||||
DamengAgentTest.class.getClassLoader(),
|
||||
new Class<?>[]{Statement.class},
|
||||
(unused, method, args) -> {
|
||||
if ("unwrap".equals(method.getName())) {
|
||||
throw new AbstractMethodError("legacy JDBC driver");
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
|
||||
assertEquals(List.of(), DamengAgent.statementPrintMessages(statement));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagedOutputStatementsEnableDbmsOutputLazily() {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection("paged message", executedSql));
|
||||
|
||||
QueryPageResult result = agent.executeQueryPage(
|
||||
"/* output */ CALL PAGED_LOG_PROCEDURE()",
|
||||
null,
|
||||
new QueryPageOptions(100, 100, 1000)
|
||||
);
|
||||
|
||||
assertEquals(List.of(
|
||||
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
|
||||
"/* output */ CALL PAGED_LOG_PROCEDURE()"
|
||||
), executedSql);
|
||||
assertEquals(List.of(List.of("paged message")), result.getRows());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -382,26 +501,57 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
List<String> executedSql,
|
||||
List<Integer> queryTimeouts
|
||||
) {
|
||||
return statementConnection(printMessage, executedSql, queryTimeouts, null);
|
||||
return printMessageConnection(printMessage, executedSql, queryTimeouts, new ArrayList<>());
|
||||
}
|
||||
|
||||
private static Connection failingDbmsOutputConnection(SQLException failure) {
|
||||
return statementConnection(null, new ArrayList<>(), new ArrayList<>(), failure);
|
||||
private static Connection printMessageConnection(
|
||||
String printMessage,
|
||||
List<String> executedSql,
|
||||
List<Integer> queryTimeouts,
|
||||
List<Integer> networkTimeouts
|
||||
) {
|
||||
return statementConnection(
|
||||
printMessage,
|
||||
executedSql,
|
||||
queryTimeouts,
|
||||
networkTimeouts,
|
||||
new Object(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection printMessageConnection(
|
||||
String printMessage,
|
||||
List<String> executedSql,
|
||||
Object physicalConnection,
|
||||
SQLException dbmsOutputFailure
|
||||
) {
|
||||
return statementConnection(
|
||||
printMessage,
|
||||
executedSql,
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>(),
|
||||
physicalConnection,
|
||||
dbmsOutputFailure
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection statementConnection(
|
||||
String printMessage,
|
||||
List<String> executedSql,
|
||||
List<Integer> queryTimeouts,
|
||||
SQLException executeFailure
|
||||
List<Integer> networkTimeouts,
|
||||
Object physicalConnection,
|
||||
SQLException dbmsOutputFailure
|
||||
) {
|
||||
InvocationHandler statementHandler = (Object unused, Method method, Object[] args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute":
|
||||
if (executeFailure != null) {
|
||||
throw executeFailure;
|
||||
String sql = (String) args[0];
|
||||
executedSql.add(sql);
|
||||
if (dbmsOutputFailure != null && sql.contains("DBMS_OUTPUT.ENABLE")) {
|
||||
throw dbmsOutputFailure;
|
||||
}
|
||||
executedSql.add((String) args[0]);
|
||||
return false;
|
||||
case "setQueryTimeout":
|
||||
queryTimeouts.add((Integer) args[0]);
|
||||
@@ -420,10 +570,27 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
statementHandler
|
||||
);
|
||||
InvocationHandler connectionHandler = (Object unused, Method method, Object[] args) -> {
|
||||
if (method.getName().equals("createStatement")) {
|
||||
return statement;
|
||||
switch (method.getName()) {
|
||||
case "createStatement":
|
||||
return statement;
|
||||
case "getNetworkTimeout":
|
||||
return 0;
|
||||
case "setNetworkTimeout":
|
||||
networkTimeouts.add((Integer) args[1]);
|
||||
return null;
|
||||
case "isWrapperFor":
|
||||
return ((Class<?>) args[0]).getName().equals("dm.jdbc.driver.DmdbConnection");
|
||||
case "unwrap":
|
||||
return ((Class<?>) args[0]).getName().equals("dm.jdbc.driver.DmdbConnection")
|
||||
? physicalConnection
|
||||
: null;
|
||||
case "equals":
|
||||
return unused == args[0];
|
||||
case "hashCode":
|
||||
return System.identityHashCode(unused);
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
};
|
||||
return (Connection) Proxy.newProxyInstance(
|
||||
DamengAgentTest.class.getClassLoader(),
|
||||
@@ -432,6 +599,66 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
);
|
||||
}
|
||||
|
||||
private static void assertLazilyEnablesDbmsOutput(String sql) {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
|
||||
|
||||
agent.executeQuery(sql, null, new ExecuteQueryOptions());
|
||||
|
||||
assertEquals(2, executedSql.size());
|
||||
assertEquals("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;", executedSql.get(0));
|
||||
}
|
||||
|
||||
private static void assertDoesNotEnableDbmsOutput(String sql) {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql));
|
||||
|
||||
agent.executeQuery(sql, null, new ExecuteQueryOptions());
|
||||
|
||||
assertEquals(List.of(sql), executedSql);
|
||||
}
|
||||
|
||||
private static void assertDbmsOutputFailureFallsBack(SQLException failure) {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql, new Object(), failure));
|
||||
|
||||
assertDoesNotThrow(() -> agent.executeQuery("CALL LOG_ONLY_PROCEDURE()", null, new ExecuteQueryOptions()));
|
||||
assertDoesNotThrow(() -> agent.executeQuery("BEGIN NULL; END;", null, new ExecuteQueryOptions()));
|
||||
|
||||
assertEquals(List.of(
|
||||
"BEGIN DBMS_OUTPUT.ENABLE(1000000); END;",
|
||||
"CALL LOG_ONLY_PROCEDURE()",
|
||||
"BEGIN NULL; END;"
|
||||
), executedSql);
|
||||
}
|
||||
|
||||
private static void assertDbmsOutputFailurePropagates(SQLException failure) {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, printMessageConnection(null, executedSql, new Object(), failure));
|
||||
|
||||
RuntimeException error = assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.executeQuery("CALL LOG_ONLY_PROCEDURE()", null, new ExecuteQueryOptions())
|
||||
);
|
||||
|
||||
assertSame(failure, error.getCause());
|
||||
assertEquals(List.of("BEGIN DBMS_OUTPUT.ENABLE(1000000); END;"), executedSql);
|
||||
}
|
||||
|
||||
private static void setLegacyJdbcMetadata(DamengAgent agent, boolean value) {
|
||||
try {
|
||||
Field field = DamengAgent.class.getDeclaredField("legacyJdbcMetadata");
|
||||
field.setAccessible(true);
|
||||
field.set(agent, value);
|
||||
} catch (ReflectiveOperationException error) {
|
||||
throw new IllegalStateException("Unable to set legacy JDBC metadata mode", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == Boolean.TYPE) return false;
|
||||
if (type == Byte.TYPE) return (byte) 0;
|
||||
|
||||
@@ -21,6 +21,42 @@ class DamengAgentUrlTest {
|
||||
Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebuildsLegacyDbxUrlFromConnectionFields() throws Exception {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1",
|
||||
5236,
|
||||
"MAIN",
|
||||
"SYSDBA",
|
||||
"pwd",
|
||||
"",
|
||||
"dm://SYSDBA:SYSDBA@legacy-host:5236/OLD",
|
||||
false
|
||||
);
|
||||
|
||||
String url = invokeBuildUrl(params);
|
||||
|
||||
Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesCompleteCustomJdbcUrlWithoutRewritingIt() throws Exception {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"ignored",
|
||||
5236,
|
||||
"IGNORED",
|
||||
"",
|
||||
"",
|
||||
"ssl=true",
|
||||
"jdbc:dm6://dm6.internal:5237/MAIN?compatibleMode=oracle",
|
||||
false
|
||||
);
|
||||
|
||||
String url = invokeBuildUrl(params);
|
||||
|
||||
Assertions.assertEquals("jdbc:dm6://dm6.internal:5237/MAIN?compatibleMode=oracle", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsDmJdbcUrlParameters() throws Exception {
|
||||
String url = invokeBuildUrl(new ConnectParams(
|
||||
|
||||
Generated
+185
-9
@@ -875,6 +875,35 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -1227,10 +1256,13 @@ dependencies = [
|
||||
"minijinja",
|
||||
"mongodb",
|
||||
"mysql_async",
|
||||
"nix",
|
||||
"notify",
|
||||
"openssl",
|
||||
"pageant",
|
||||
"percent-encoding",
|
||||
"portpicker",
|
||||
"postgres-openssl",
|
||||
"prometheus-parse",
|
||||
"quick-xml",
|
||||
"rayon",
|
||||
@@ -1263,6 +1295,7 @@ dependencies = [
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"webpki-roots 0.26.11",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"zip 4.6.1",
|
||||
"zstd",
|
||||
@@ -1467,6 +1500,15 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "duckdb"
|
||||
version = "1.10505.0"
|
||||
@@ -1647,6 +1689,20 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55"
|
||||
|
||||
[[package]]
|
||||
name = "fast-socks5"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d09fe4a491909a716088083eeb5bcc25427330fdbcd4ecd3dfa5469b3da795df"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1710,6 +1766,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -2241,7 +2312,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2769,7 +2840,6 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -2792,6 +2862,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -3032,6 +3108,7 @@ dependencies = [
|
||||
"bson",
|
||||
"derive-where",
|
||||
"derive_more",
|
||||
"fast-socks5",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
@@ -3082,7 +3159,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mysql-common-derive"
|
||||
version = "0.32.2"
|
||||
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=77085e91e5081309d585153e3b656ce33bc1fe74#77085e91e5081309d585153e3b656ce33bc1fe74"
|
||||
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=47740d85a8277167a5717afac785d37b46a36296#47740d85a8277167a5717afac785d37b46a36296"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"heck",
|
||||
@@ -3099,7 +3176,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mysql_async"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/t8y2/mysql_async.git?rev=2be6e392eb9b06d20dcd2d8ed8eae748d413c9ec#2be6e392eb9b06d20dcd2d8ed8eae748d413c9ec"
|
||||
source = "git+https://github.com/t8y2/mysql_async.git?rev=b615bb0b10b5975182a4aa069cabc3d09021f6ab#b615bb0b10b5975182a4aa069cabc3d09021f6ab"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
@@ -3128,7 +3205,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.38.0"
|
||||
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=77085e91e5081309d585153e3b656ce33bc1fe74#77085e91e5081309d585153e3b656ce33bc1fe74"
|
||||
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=47740d85a8277167a5717afac785d37b46a36296#47740d85a8277167a5717afac785d37b46a36296"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.1",
|
||||
@@ -3341,6 +3418,31 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
@@ -3353,6 +3455,28 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0-rc.7"
|
||||
@@ -3663,10 +3787,22 @@ dependencies = [
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-openssl"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06743eefaa1a5c0ef2ccb6d9abf6528790a229eabd62ddcabf9b2a3aeff09fa4"
|
||||
dependencies = [
|
||||
"openssl",
|
||||
"tokio",
|
||||
"tokio-openssl",
|
||||
"tokio-postgres",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-protocol"
|
||||
version = "0.6.12"
|
||||
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=115f9fef10f0fc3669b5337955e4eb461fc349a6#115f9fef10f0fc3669b5337955e4eb461fc349a6"
|
||||
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=ac8ad476de59093d33ab9f294531c956e0c6a724#ac8ad476de59093d33ab9f294531c956e0c6a724"
|
||||
dependencies = [
|
||||
"base64 0.23.0",
|
||||
"byteorder",
|
||||
@@ -3685,7 +3821,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "postgres-types"
|
||||
version = "0.2.14"
|
||||
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=115f9fef10f0fc3669b5337955e4eb461fc349a6#115f9fef10f0fc3669b5337955e4eb461fc349a6"
|
||||
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=ac8ad476de59093d33ab9f294531c956e0c6a724#ac8ad476de59093d33ab9f294531c956e0c6a724"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -3801,6 +3937,12 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.31"
|
||||
@@ -3831,6 +3973,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -4133,6 +4285,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -5253,7 +5407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -5460,10 +5614,21 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-openssl"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd"
|
||||
dependencies = [
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-postgres"
|
||||
version = "0.7.18"
|
||||
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=115f9fef10f0fc3669b5337955e4eb461fc349a6#115f9fef10f0fc3669b5337955e4eb461fc349a6"
|
||||
source = "git+https://github.com/t8y2/tokio-postgres-gaussdb.git?rev=ac8ad476de59093d33ab9f294531c956e0c6a724#ac8ad476de59093d33ab9f294531c956e0c6a724"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"byteorder",
|
||||
@@ -5520,6 +5685,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
|
||||
@@ -37,10 +37,10 @@ tokio-util = "0.7"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[patch.crates-io]
|
||||
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" }
|
||||
mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "2be6e392eb9b06d20dcd2d8ed8eae748d413c9ec" }
|
||||
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
|
||||
postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
|
||||
postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" }
|
||||
mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "b615bb0b10b5975182a4aa069cabc3d09021f6ab" }
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
@@ -23,7 +23,6 @@ impl DuckDbConnection {
|
||||
}
|
||||
|
||||
// Preserve PoisonError ownership so close_connection can still release the contained DuckDB handle.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn into_inner(self) -> std::sync::LockResult<duckdb::Connection> {
|
||||
self.connection.into_inner()
|
||||
@@ -116,8 +115,7 @@ pub fn is_memory_database_path(path: &str) -> bool {
|
||||
/// Unlike relying on Drop, this calls `duckdb_disconnect` synchronously
|
||||
/// so the file handle is released before this function returns.
|
||||
/// On Windows this prevents "file already in use" errors when reconnecting.
|
||||
#[cfg(test)]
|
||||
fn close_connection(con: Arc<DuckDbConnection>) {
|
||||
pub(crate) fn close_connection(con: Arc<DuckDbConnection>) {
|
||||
match Arc::try_unwrap(con) {
|
||||
Ok(handle) => match handle.into_inner() {
|
||||
Ok(conn) => {
|
||||
|
||||
@@ -50,8 +50,8 @@ mod tests {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
con.execute_batch("CREATE TABLE users (id INTEGER, name VARCHAR)").expect("create table");
|
||||
|
||||
let inserted = duckdb_execute(&con, "INSERT INTO users VALUES (1, 'Ada') RETURNING id, name")
|
||||
.expect("insert returning");
|
||||
let inserted =
|
||||
duckdb_execute(&con, "INSERT INTO users VALUES (1, 'Ada') RETURNING id, name").expect("insert returning");
|
||||
let updated = duckdb_execute(&con, "UPDATE users SET name = 'Ada Lovelace' RETURNING id, name")
|
||||
.expect("update returning");
|
||||
let deleted = duckdb_execute(&con, "DELETE FROM users RETURNING id, name").expect("delete returning");
|
||||
@@ -144,6 +144,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_resolves_current_date_arithmetic() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
|
||||
let result = duckdb_execute(&con, "SELECT CURRENT_DATE - 4 AS d").expect("execute CURRENT_DATE - literal");
|
||||
let expected = (Utc::now().date_naive() - ChronoDuration::days(4)).to_string();
|
||||
assert_eq!(result.rows[0][0], serde_json::Value::String(expected));
|
||||
|
||||
con.execute_batch("CREATE VIEW recent AS SELECT CURRENT_DATE - 4 AS start_date, CURRENT_DATE - 1 AS end_date")
|
||||
.expect("create view using CURRENT_DATE arithmetic");
|
||||
let view_result =
|
||||
duckdb_execute(&con, "SELECT * FROM recent").expect("query view built on CURRENT_DATE arithmetic");
|
||||
assert_eq!(view_result.rows.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_formats_temporal_values_by_column_type() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
@@ -378,6 +393,8 @@ pub fn duckdb_execute_with_max_rows(
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let row_limit = query_result_row_limit(max_rows);
|
||||
let sql = crate::sql::rewrite_duckdb_current_date_literal_arithmetic(sql);
|
||||
let sql = sql.as_ref();
|
||||
|
||||
if crate::sql::starts_with_duckdb_result_sql_keyword(sql) {
|
||||
let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -148,6 +148,27 @@ impl DuckDbWorkerSession {
|
||||
Ok(connection.interrupt_handle())
|
||||
}
|
||||
|
||||
/// Takes and closes the connection so DuckDB's shutdown checkpoint runs and
|
||||
/// the database WAL is removed before this worker exits. Returns false —
|
||||
/// leaving the connection untouched — when the connection is poisoned
|
||||
/// (dropping it aborts the process) so the caller falls back to an
|
||||
/// immediate exit and the WAL replays on the next open.
|
||||
fn close_connection_for_shutdown(&mut self) -> bool {
|
||||
if self.connection.is_none() {
|
||||
return true;
|
||||
}
|
||||
if !self.is_connection_healthy() {
|
||||
return false;
|
||||
}
|
||||
match self.connection.take() {
|
||||
Some(connection) => {
|
||||
crate::connection::close_connection(connection);
|
||||
true
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes whether the connection is still usable after an execute error.
|
||||
///
|
||||
/// duckdb-rs 1.10503.1 has a bug where `Connection::prepare()` failing at the
|
||||
@@ -205,6 +226,21 @@ struct WorkerHandleResult {
|
||||
}
|
||||
|
||||
impl DuckDbWorkerRuntime {
|
||||
/// Closes the session connection when the worker is idle and the connection
|
||||
/// is healthy, so the DuckDB shutdown checkpoint removes the WAL. Returns
|
||||
/// false when a query is running (the session is locked or an interrupt is
|
||||
/// registered) or the connection is poisoned; callers then keep the legacy
|
||||
/// immediate-exit behavior and rely on WAL replay at the next open.
|
||||
fn close_session_for_shutdown(&self) -> bool {
|
||||
if self.active_interrupt.lock().unwrap_or_else(|e| e.into_inner()).is_some() {
|
||||
return false;
|
||||
}
|
||||
match self.session.try_lock() {
|
||||
Ok(mut session) => session.close_connection_for_shutdown(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
&self,
|
||||
request: DuckDbWorkerRequest,
|
||||
@@ -327,10 +363,18 @@ impl DuckDbWorkerRuntime {
|
||||
shutdown: false,
|
||||
}
|
||||
}
|
||||
DuckDbWorkerMethod::Shutdown => WorkerHandleResult {
|
||||
response: Some(DuckDbWorkerResponse::ok(request.id, serde_json::json!({ "shutdown": true }))),
|
||||
shutdown: true,
|
||||
},
|
||||
DuckDbWorkerMethod::Shutdown => {
|
||||
// Close the connection before acking: the checkpoint that removes
|
||||
// the WAL must finish before the parent observes this response
|
||||
// (or the process exit it waits for). Busy or poisoned sessions
|
||||
// skip the close; the parent's kill fallback and WAL replay on
|
||||
// the next open cover them.
|
||||
self.close_session_for_shutdown();
|
||||
WorkerHandleResult {
|
||||
response: Some(DuckDbWorkerResponse::ok(request.id, serde_json::json!({ "shutdown": true }))),
|
||||
shutdown: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +421,15 @@ pub async fn run_stdio_worker() -> Result<(), String> {
|
||||
loop {
|
||||
let line = lines.next_line().await.map_err(|e| e.to_string())?;
|
||||
let Some(line) = line else {
|
||||
std::process::exit(0);
|
||||
// Parent closed stdin (exit or crash). Idle workers checkpoint and
|
||||
// remove their WAL on the normal return path; a worker still running
|
||||
// a query must not wait for it (nor drop a poisoned connection),
|
||||
// so it keeps the legacy immediate exit and the WAL replays on the
|
||||
// next open.
|
||||
if !runtime.close_session_for_shutdown() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -193,6 +193,127 @@ fn parse_identifier_after_as(input: &str) -> Option<String> {
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// Works around a DuckDB binder bug: `CURRENT_DATE - 4` (a bare numeric literal directly
|
||||
/// against the `CURRENT_DATE` keyword node) fails with "No function matches ... (DATE,
|
||||
/// INTEGER_LITERAL)", while the functionally identical `current_date() - 4` binds fine.
|
||||
/// Rewrites only that narrow pattern (`CURRENT_DATE` immediately followed by `+`/`-` and a raw
|
||||
/// numeric literal) to the function-call form, so plain `CURRENT_DATE`, qualified references
|
||||
/// (`t.current_date`), aliases (`AS current_date`), and already-cast literals are left untouched.
|
||||
pub fn rewrite_duckdb_current_date_literal_arithmetic(sql: &str) -> std::borrow::Cow<'_, str> {
|
||||
const KEYWORD: &str = "CURRENT_DATE";
|
||||
let bytes = sql.as_bytes();
|
||||
let mut rewritten: Option<String> = None;
|
||||
let mut copied_to = 0usize;
|
||||
let mut index = 0usize;
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'\'' if !in_double => {
|
||||
if in_single && bytes.get(index + 1) == Some(&b'\'') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
in_single = !in_single;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
b'"' if !in_single => {
|
||||
if in_double && bytes.get(index + 1) == Some(&b'"') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
in_double = !in_double;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
b'-' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'-') => {
|
||||
index += 2;
|
||||
while index < bytes.len() && bytes[index] != b'\n' {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
b'/' if !in_single && !in_double && bytes.get(index + 1) == Some(&b'*') => {
|
||||
index += 2;
|
||||
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
|
||||
index += 1;
|
||||
}
|
||||
index = (index + 2).min(bytes.len());
|
||||
continue;
|
||||
}
|
||||
b'$' if !in_single && !in_double => {
|
||||
if let Some(end) = dollar_quote_end(bytes, index) {
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
_ if !in_single && !in_double && matches_keyword_at(bytes, index, KEYWORD) => {
|
||||
let word_end = index + KEYWORD.len();
|
||||
let preceded_by_dot = index > 0 && bytes[index - 1] == b'.';
|
||||
let preceded_by_ident = index > 0 && is_ident_byte(bytes[index - 1]);
|
||||
let followed_by_ident = bytes.get(word_end).copied().is_some_and(is_ident_byte);
|
||||
if !preceded_by_dot
|
||||
&& !preceded_by_ident
|
||||
&& !followed_by_ident
|
||||
&& has_raw_numeric_arithmetic_after(bytes, word_end)
|
||||
{
|
||||
let buf = rewritten.get_or_insert_with(String::new);
|
||||
buf.push_str(&sql[copied_to..index]);
|
||||
buf.push_str("current_date()");
|
||||
copied_to = word_end;
|
||||
}
|
||||
index = word_end;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match rewritten {
|
||||
Some(mut buf) => {
|
||||
buf.push_str(&sql[copied_to..]);
|
||||
std::borrow::Cow::Owned(buf)
|
||||
}
|
||||
None => std::borrow::Cow::Borrowed(sql),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_keyword_at(bytes: &[u8], index: usize, keyword: &str) -> bool {
|
||||
let keyword_bytes = keyword.as_bytes();
|
||||
bytes.len() >= index + keyword_bytes.len()
|
||||
&& bytes[index..index + keyword_bytes.len()].eq_ignore_ascii_case(keyword_bytes)
|
||||
}
|
||||
|
||||
fn is_ident_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
|
||||
/// After the `CURRENT_DATE` token: skip whitespace, require a `+`/`-` that isn't the start of a
|
||||
/// `--` comment, skip whitespace again, and require a raw numeric literal (not `CAST(`/`::`).
|
||||
fn has_raw_numeric_arithmetic_after(bytes: &[u8], from: usize) -> bool {
|
||||
let mut index = from;
|
||||
while bytes.get(index).copied().is_some_and(|b| b.is_ascii_whitespace()) {
|
||||
index += 1;
|
||||
}
|
||||
let Some(&op) = bytes.get(index) else { return false };
|
||||
if op != b'+' && op != b'-' {
|
||||
return false;
|
||||
}
|
||||
if op == b'-' && bytes.get(index + 1) == Some(&b'-') {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
while bytes.get(index).copied().is_some_and(|b| b.is_ascii_whitespace()) {
|
||||
index += 1;
|
||||
}
|
||||
bytes.get(index).copied().is_some_and(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn starts_with_duckdb_result_sql_keyword(sql: &str) -> bool {
|
||||
let Some(token) = first_executable_sql_token(sql) else {
|
||||
return false;
|
||||
@@ -329,4 +450,71 @@ mod tests {
|
||||
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items(note) VALUES ('RETURNING')"));
|
||||
assert!(!starts_with_duckdb_result_sql_keyword("INSERT INTO items VALUES (1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_current_date_literal_arithmetic() {
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4"),
|
||||
"SELECT current_date() - 4"
|
||||
);
|
||||
assert_eq!(rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE-4"), "SELECT current_date()-4");
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic("SELECT current_date + 1.5 AS d"),
|
||||
"SELECT current_date() + 1.5 AS d"
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4, CURRENT_DATE - 1"),
|
||||
"SELECT current_date() - 4, current_date() - 1"
|
||||
);
|
||||
// Already-cast literals still match the narrow "digit right after the operator" check;
|
||||
// rewriting them too is harmless since current_date() binds fine against any numeric form.
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic("SELECT CURRENT_DATE - 4::INTEGER"),
|
||||
"SELECT current_date() - 4::INTEGER"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_literal_current_date_usage_untouched() {
|
||||
for sql in [
|
||||
"SELECT CURRENT_DATE",
|
||||
"SELECT CURRENT_DATE::VARCHAR",
|
||||
"SELECT CURRENT_DATE - CAST(4 AS INTEGER)",
|
||||
"SELECT t.current_date - 4 FROM t",
|
||||
"SELECT 1 AS current_date",
|
||||
"CREATE TABLE cd_test (current_date INTEGER)",
|
||||
"SELECT 4 - CURRENT_DATE",
|
||||
"SELECT current_date() - 4",
|
||||
"-- CURRENT_DATE - 4\nSELECT 1",
|
||||
"SELECT '-- CURRENT_DATE - 4'",
|
||||
] {
|
||||
assert_eq!(rewrite_duckdb_current_date_literal_arithmetic(sql), sql, "should not rewrite: {sql}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_dollar_quoted_current_date_arithmetic_untouched() {
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic(
|
||||
"SELECT $$CURRENT_DATE - 4$$ AS source, CURRENT_DATE - 1 AS actual"
|
||||
),
|
||||
"SELECT $$CURRENT_DATE - 4$$ AS source, current_date() - 1 AS actual"
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic(
|
||||
"SELECT $body_1$CURRENT_DATE + 2$body_1$ AS source, CURRENT_DATE + 3 AS actual"
|
||||
),
|
||||
"SELECT $body_1$CURRENT_DATE + 2$body_1$ AS source, current_date() + 3 AS actual"
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic(
|
||||
"SELECT $outer$CURRENT_DATE - 4 $inner$ CURRENT_DATE + 2 $inner$$outer$ AS source"
|
||||
),
|
||||
"SELECT $outer$CURRENT_DATE - 4 $inner$ CURRENT_DATE + 2 $inner$$outer$ AS source"
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_duckdb_current_date_literal_arithmetic("SELECT $body$CURRENT_DATE - 4"),
|
||||
"SELECT $body$CURRENT_DATE - 4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ pub enum CompletionAssistantObjectKind {
|
||||
Procedure,
|
||||
Function,
|
||||
Column,
|
||||
Sequence,
|
||||
}
|
||||
|
||||
impl CompletionAssistantObjectKind {
|
||||
@@ -78,6 +79,7 @@ pub enum CompletionAssistantCandidateKind {
|
||||
Procedure,
|
||||
Function,
|
||||
Column,
|
||||
Sequence,
|
||||
Object,
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +499,52 @@ async fn worker_process_retries_connect_after_transient_file_lock() {
|
||||
assert_eq!(result.rows, vec![vec![serde_json::json!(42)]]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn worker_shutdown_checkpoints_and_removes_wal() {
|
||||
let _guard = duckdb_worker_process_test_guard().await;
|
||||
let executable = PathBuf::from(env!("CARGO_BIN_EXE_dbx-duckdb-driver"));
|
||||
let db_path = temp_duckdb_path();
|
||||
let wal_path = wal_path_for(&db_path);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
let _ = std::fs::remove_file(&wal_path);
|
||||
|
||||
let client =
|
||||
DuckDbWorkerClient::open_with_executable(executable.clone(), db_path.to_string_lossy().to_string(), Vec::new(), None)
|
||||
.await
|
||||
.expect("worker process connects");
|
||||
client
|
||||
.execute(
|
||||
None,
|
||||
"CREATE TABLE events (id INTEGER); INSERT INTO events VALUES (7);".to_string(),
|
||||
Some(10),
|
||||
None,
|
||||
Some(Duration::from_secs(5)),
|
||||
)
|
||||
.await
|
||||
.expect("write events");
|
||||
|
||||
// Committed writes sit in the WAL while the connection is open.
|
||||
assert!(wal_path.exists(), "committed writes should leave a WAL file next to the database");
|
||||
|
||||
client.shutdown().await;
|
||||
|
||||
assert!(!wal_path.exists(), "shutdown should checkpoint and remove the WAL file");
|
||||
|
||||
// The checkpointed data survives a fresh open of the database file.
|
||||
let client =
|
||||
DuckDbWorkerClient::open_with_executable(executable, db_path.to_string_lossy().to_string(), Vec::new(), None)
|
||||
.await
|
||||
.expect("reopen after shutdown");
|
||||
let result = client
|
||||
.execute(None, "SELECT id FROM events".to_string(), Some(10), None, Some(Duration::from_secs(5)))
|
||||
.await
|
||||
.expect("read events back");
|
||||
assert_eq!(result.rows, vec![vec![serde_json::json!(7)]]);
|
||||
client.shutdown().await;
|
||||
assert!(!wal_path.exists(), "second shutdown should also remove the WAL file");
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
async fn write_worker_request(stdin: &mut tokio::process::ChildStdin, request: DuckDbWorkerRequest) {
|
||||
let line = serde_json::to_string(&request).expect("request json");
|
||||
stdin.write_all(line.as_bytes()).await.expect("write request");
|
||||
@@ -517,6 +563,10 @@ async fn read_worker_response(
|
||||
serde_json::from_str(&line).expect("parse worker response")
|
||||
}
|
||||
|
||||
fn wal_path_for(db_path: &PathBuf) -> PathBuf {
|
||||
PathBuf::from(format!("{}.wal", db_path.display()))
|
||||
}
|
||||
|
||||
fn temp_duckdb_path() -> PathBuf {
|
||||
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let pid = std::process::id();
|
||||
|
||||
@@ -3,8 +3,59 @@ package com.dbx.agent.firebird;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
static final String PROCEDURE_SOURCE_SQL = """
|
||||
SELECT
|
||||
CASE WHEN METADATA_ROW = 1 THEN PROCEDURE_SOURCE ELSE NULL END AS PROCEDURE_SOURCE,
|
||||
PARAMETER_NAME,
|
||||
PARAMETER_TYPE,
|
||||
PARAMETER_NUMBER,
|
||||
FIELD_SOURCE,
|
||||
PARAMETER_DEFAULT,
|
||||
PARAMETER_NULL_FLAG,
|
||||
FIELD_TYPE,
|
||||
FIELD_SUB_TYPE,
|
||||
FIELD_PRECISION,
|
||||
FIELD_SCALE,
|
||||
FIELD_LENGTH,
|
||||
CHAR_COUNT
|
||||
FROM (
|
||||
SELECT
|
||||
P.RDB$PROCEDURE_SOURCE AS PROCEDURE_SOURCE,
|
||||
TRIM(PP.RDB$PARAMETER_NAME) AS PARAMETER_NAME,
|
||||
PP.RDB$PARAMETER_TYPE AS PARAMETER_TYPE,
|
||||
PP.RDB$PARAMETER_NUMBER AS PARAMETER_NUMBER,
|
||||
TRIM(PP.RDB$FIELD_SOURCE) AS FIELD_SOURCE,
|
||||
PP.RDB$DEFAULT_SOURCE AS PARAMETER_DEFAULT,
|
||||
PP.RDB$NULL_FLAG AS PARAMETER_NULL_FLAG,
|
||||
F.RDB$FIELD_TYPE AS FIELD_TYPE,
|
||||
F.RDB$FIELD_SUB_TYPE AS FIELD_SUB_TYPE,
|
||||
F.RDB$FIELD_PRECISION AS FIELD_PRECISION,
|
||||
F.RDB$FIELD_SCALE AS FIELD_SCALE,
|
||||
F.RDB$FIELD_LENGTH AS FIELD_LENGTH,
|
||||
F.RDB$CHARACTER_LENGTH AS CHAR_COUNT,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY PP.RDB$PARAMETER_TYPE, PP.RDB$PARAMETER_NUMBER
|
||||
) AS METADATA_ROW
|
||||
FROM RDB$PROCEDURES P
|
||||
LEFT JOIN RDB$PROCEDURE_PARAMETERS PP
|
||||
ON PP.RDB$PROCEDURE_NAME = P.RDB$PROCEDURE_NAME
|
||||
AND PP.RDB$PACKAGE_NAME IS NOT DISTINCT FROM P.RDB$PACKAGE_NAME
|
||||
LEFT JOIN RDB$FIELDS F
|
||||
ON F.RDB$FIELD_NAME = PP.RDB$FIELD_SOURCE
|
||||
WHERE P.RDB$PROCEDURE_NAME = ?
|
||||
AND P.RDB$PACKAGE_NAME IS NULL
|
||||
) PROCEDURE_METADATA
|
||||
ORDER BY PARAMETER_TYPE, PARAMETER_NUMBER
|
||||
""".stripIndent().trim();
|
||||
|
||||
public static final JdbcAgentProfile FIREBIRD_PROFILE = new JdbcAgentProfile(
|
||||
"org.firebirdsql.jdbc.FBDriver",
|
||||
"jdbc:firebirdsql://{host}:{port}/{database}",
|
||||
@@ -16,6 +67,228 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
super(FIREBIRD_PROFILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
String normalizedType = normalizeObjectSourceType(objectType);
|
||||
return unchecked(() -> {
|
||||
String body = null;
|
||||
boolean found = false;
|
||||
List<ProcedureParameter> inputs = new ArrayList<>();
|
||||
List<ProcedureParameter> outputs = new ArrayList<>();
|
||||
try (PreparedStatement statement = requireConnection().prepareStatement(PROCEDURE_SOURCE_SQL)) {
|
||||
statement.setString(1, name);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
found = true;
|
||||
if (body == null) {
|
||||
body = resultSet.getString("PROCEDURE_SOURCE");
|
||||
}
|
||||
String parameterName = trimToNull(resultSet.getString("PARAMETER_NAME"));
|
||||
if (parameterName == null) {
|
||||
continue;
|
||||
}
|
||||
ProcedureParameter parameter = new ProcedureParameter(
|
||||
parameterName,
|
||||
trimToNull(resultSet.getString("FIELD_SOURCE")),
|
||||
trimToNull(resultSet.getString("PARAMETER_DEFAULT")),
|
||||
nullableInt(resultSet, "PARAMETER_NULL_FLAG"),
|
||||
nullableInt(resultSet, "FIELD_TYPE"),
|
||||
nullableInt(resultSet, "FIELD_SUB_TYPE"),
|
||||
nullableInt(resultSet, "FIELD_PRECISION"),
|
||||
nullableInt(resultSet, "FIELD_SCALE"),
|
||||
nullableInt(resultSet, "FIELD_LENGTH"),
|
||||
nullableInt(resultSet, "CHAR_COUNT")
|
||||
);
|
||||
int parameterType = resultSet.getInt("PARAMETER_TYPE");
|
||||
if (parameterType == 0) {
|
||||
inputs.add(parameter);
|
||||
} else if (parameterType == 1) {
|
||||
outputs.add(parameter);
|
||||
} else {
|
||||
throw new IllegalStateException("Unsupported Firebird parameter type: " + parameterType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String source = !found || body == null || body.isBlank()
|
||||
? ""
|
||||
: buildProcedureDdl(name, inputs, outputs, body);
|
||||
return new ObjectSource(name, normalizedType, schema, source, false);
|
||||
});
|
||||
}
|
||||
|
||||
static String normalizeObjectSourceType(String objectType) {
|
||||
if (objectType == null) {
|
||||
throw new IllegalArgumentException("Unsupported object type: null");
|
||||
}
|
||||
String normalized = objectType.trim().toUpperCase(Locale.ROOT);
|
||||
if (!"PROCEDURE".equals(normalized)) {
|
||||
throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String buildProcedureDdl(
|
||||
String name,
|
||||
List<ProcedureParameter> inputs,
|
||||
List<ProcedureParameter> outputs,
|
||||
String body
|
||||
) {
|
||||
StringBuilder ddl = new StringBuilder("CREATE OR ALTER PROCEDURE ")
|
||||
.append(quoteIdentifier(name));
|
||||
appendParameterBlock(ddl, inputs, " (");
|
||||
if (inputs.isEmpty()) {
|
||||
ddl.append('\n');
|
||||
}
|
||||
if (!outputs.isEmpty()) {
|
||||
appendParameterBlock(ddl, outputs, "RETURNS (");
|
||||
}
|
||||
ddl.append("AS\n").append(body);
|
||||
if (!body.endsWith("\n")) {
|
||||
ddl.append('\n');
|
||||
}
|
||||
return ddl.toString();
|
||||
}
|
||||
|
||||
private static void appendParameterBlock(
|
||||
StringBuilder ddl,
|
||||
List<ProcedureParameter> parameters,
|
||||
String prefix
|
||||
) {
|
||||
if (parameters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ddl.append(prefix).append('\n');
|
||||
for (int index = 0; index < parameters.size(); index++) {
|
||||
ddl.append(" ").append(parameterDeclaration(parameters.get(index)));
|
||||
if (index + 1 < parameters.size()) {
|
||||
ddl.append(',');
|
||||
}
|
||||
ddl.append('\n');
|
||||
}
|
||||
ddl.append(")\n");
|
||||
}
|
||||
|
||||
private static String parameterDeclaration(ProcedureParameter parameter) {
|
||||
StringBuilder declaration = new StringBuilder(quoteIdentifier(parameter.name()))
|
||||
.append(' ')
|
||||
.append(parameterType(parameter));
|
||||
if (Integer.valueOf(1).equals(parameter.notNull())) {
|
||||
declaration.append(" NOT NULL");
|
||||
}
|
||||
if (parameter.defaultSource() != null) {
|
||||
declaration.append(' ').append(parameter.defaultSource());
|
||||
}
|
||||
return declaration.toString();
|
||||
}
|
||||
|
||||
private static String parameterType(ProcedureParameter parameter) {
|
||||
if (parameter.fieldSource() != null && !parameter.fieldSource().startsWith("RDB$")) {
|
||||
return quoteIdentifier(parameter.fieldSource());
|
||||
}
|
||||
if (parameter.fieldType() == null) {
|
||||
throw new IllegalStateException("Missing Firebird field type for parameter " + parameter.name());
|
||||
}
|
||||
int fieldType = parameter.fieldType();
|
||||
int subType = valueOrZero(parameter.fieldSubType());
|
||||
return switch (fieldType) {
|
||||
case 7 -> numericType("SMALLINT", subType, parameter);
|
||||
case 8 -> numericType("INTEGER", subType, parameter);
|
||||
case 10 -> "FLOAT";
|
||||
case 11, 27 -> "DOUBLE PRECISION";
|
||||
case 12 -> "DATE";
|
||||
case 13 -> "TIME";
|
||||
case 14 -> characterType("CHAR", "BINARY", subType, parameter);
|
||||
case 16 -> numericType("BIGINT", subType, parameter);
|
||||
case 23 -> "BOOLEAN";
|
||||
case 24 -> "DECFLOAT(16)";
|
||||
case 25 -> "DECFLOAT(34)";
|
||||
case 26 -> numericType("INT128", subType, parameter);
|
||||
case 28 -> "TIME WITH TIME ZONE";
|
||||
case 29 -> "TIMESTAMP WITH TIME ZONE";
|
||||
case 35 -> "TIMESTAMP";
|
||||
case 37 -> characterType("VARCHAR", "VARBINARY", subType, parameter);
|
||||
case 40 -> sizedType("CSTRING", characterLength(parameter));
|
||||
case 261 -> blobType(subType);
|
||||
default -> throw new IllegalStateException("Unsupported Firebird field type: " + fieldType);
|
||||
};
|
||||
}
|
||||
|
||||
private static String numericType(String integerType, int subType, ProcedureParameter parameter) {
|
||||
int scale = valueOrZero(parameter.scale());
|
||||
if (subType != 1 && subType != 2 && scale >= 0) {
|
||||
return integerType;
|
||||
}
|
||||
String exactType = subType == 2 ? "DECIMAL" : "NUMERIC";
|
||||
int precision = valueOrZero(parameter.precision());
|
||||
if (precision <= 0) {
|
||||
return exactType;
|
||||
}
|
||||
return exactType + "(" + precision + "," + Math.abs(scale) + ")";
|
||||
}
|
||||
|
||||
private static String characterType(
|
||||
String characterType,
|
||||
String binaryType,
|
||||
int subType,
|
||||
ProcedureParameter parameter
|
||||
) {
|
||||
return sizedType(subType == 1 ? binaryType : characterType, characterLength(parameter));
|
||||
}
|
||||
|
||||
private static String sizedType(String type, Integer size) {
|
||||
return size == null || size <= 0 ? type : type + "(" + size + ")";
|
||||
}
|
||||
|
||||
private static int characterLength(ProcedureParameter parameter) {
|
||||
Integer charCount = parameter.charCount();
|
||||
return charCount != null && charCount > 0
|
||||
? charCount
|
||||
: valueOrZero(parameter.fieldLength());
|
||||
}
|
||||
|
||||
private static String blobType(int subType) {
|
||||
if (subType == 0) {
|
||||
return "BLOB";
|
||||
}
|
||||
return subType == 1 ? "BLOB SUB_TYPE TEXT" : "BLOB SUB_TYPE " + subType;
|
||||
}
|
||||
|
||||
private static String quoteIdentifier(String identifier) {
|
||||
return "\"" + identifier.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static Integer nullableInt(ResultSet resultSet, String column) throws Exception {
|
||||
int value = resultSet.getInt(column);
|
||||
return resultSet.wasNull() ? null : value;
|
||||
}
|
||||
|
||||
private static int valueOrZero(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private record ProcedureParameter(
|
||||
String name,
|
||||
String fieldSource,
|
||||
String defaultSource,
|
||||
Integer notNull,
|
||||
Integer fieldType,
|
||||
Integer fieldSubType,
|
||||
Integer precision,
|
||||
Integer scale,
|
||||
Integer fieldLength,
|
||||
Integer charCount
|
||||
) {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(FirebirdAgent::new).run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FirebirdAgentTest {
|
||||
private static final String EXPECTED_PROCEDURE_SOURCE_SQL = """
|
||||
SELECT
|
||||
CASE WHEN METADATA_ROW = 1 THEN PROCEDURE_SOURCE ELSE NULL END AS PROCEDURE_SOURCE,
|
||||
PARAMETER_NAME,
|
||||
PARAMETER_TYPE,
|
||||
PARAMETER_NUMBER,
|
||||
FIELD_SOURCE,
|
||||
PARAMETER_DEFAULT,
|
||||
PARAMETER_NULL_FLAG,
|
||||
FIELD_TYPE,
|
||||
FIELD_SUB_TYPE,
|
||||
FIELD_PRECISION,
|
||||
FIELD_SCALE,
|
||||
FIELD_LENGTH,
|
||||
CHAR_COUNT
|
||||
FROM (
|
||||
SELECT
|
||||
P.RDB$PROCEDURE_SOURCE AS PROCEDURE_SOURCE,
|
||||
TRIM(PP.RDB$PARAMETER_NAME) AS PARAMETER_NAME,
|
||||
PP.RDB$PARAMETER_TYPE AS PARAMETER_TYPE,
|
||||
PP.RDB$PARAMETER_NUMBER AS PARAMETER_NUMBER,
|
||||
TRIM(PP.RDB$FIELD_SOURCE) AS FIELD_SOURCE,
|
||||
PP.RDB$DEFAULT_SOURCE AS PARAMETER_DEFAULT,
|
||||
PP.RDB$NULL_FLAG AS PARAMETER_NULL_FLAG,
|
||||
F.RDB$FIELD_TYPE AS FIELD_TYPE,
|
||||
F.RDB$FIELD_SUB_TYPE AS FIELD_SUB_TYPE,
|
||||
F.RDB$FIELD_PRECISION AS FIELD_PRECISION,
|
||||
F.RDB$FIELD_SCALE AS FIELD_SCALE,
|
||||
F.RDB$FIELD_LENGTH AS FIELD_LENGTH,
|
||||
F.RDB$CHARACTER_LENGTH AS CHAR_COUNT,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY PP.RDB$PARAMETER_TYPE, PP.RDB$PARAMETER_NUMBER
|
||||
) AS METADATA_ROW
|
||||
FROM RDB$PROCEDURES P
|
||||
LEFT JOIN RDB$PROCEDURE_PARAMETERS PP
|
||||
ON PP.RDB$PROCEDURE_NAME = P.RDB$PROCEDURE_NAME
|
||||
AND PP.RDB$PACKAGE_NAME IS NOT DISTINCT FROM P.RDB$PACKAGE_NAME
|
||||
LEFT JOIN RDB$FIELDS F
|
||||
ON F.RDB$FIELD_NAME = PP.RDB$FIELD_SOURCE
|
||||
WHERE P.RDB$PROCEDURE_NAME = ?
|
||||
AND P.RDB$PACKAGE_NAME IS NULL
|
||||
) PROCEDURE_METADATA
|
||||
ORDER BY PARAMETER_TYPE, PARAMETER_NUMBER
|
||||
""".stripIndent().trim();
|
||||
|
||||
@Test
|
||||
void returnsCompleteProcedureDdlFromOneMetadataQuery() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> parameters = new ArrayList<>();
|
||||
FirebirdAgent agent = agentWithRows(
|
||||
sql,
|
||||
parameters,
|
||||
row(
|
||||
"PROCEDURE_SOURCE", procedureBody(),
|
||||
"PARAMETER_NAME", "P_ID",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"PARAMETER_NUMBER", 0,
|
||||
"FIELD_SOURCE", "RDB$1",
|
||||
"FIELD_TYPE", 8,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_PRECISION", 0,
|
||||
"FIELD_SCALE", 0,
|
||||
"FIELD_LENGTH", 4
|
||||
),
|
||||
row(
|
||||
"PROCEDURE_SOURCE", null,
|
||||
"PARAMETER_NAME", "P_LABEL",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"PARAMETER_NUMBER", 1,
|
||||
"FIELD_SOURCE", "RDB$2",
|
||||
"PARAMETER_DEFAULT", "DEFAULT 'fallback'",
|
||||
"FIELD_TYPE", 37,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_SCALE", 0,
|
||||
"FIELD_LENGTH", 80,
|
||||
"CHAR_COUNT", 20
|
||||
),
|
||||
row(
|
||||
"PROCEDURE_SOURCE", null,
|
||||
"PARAMETER_NAME", "P_DOM",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"PARAMETER_NUMBER", 2,
|
||||
"FIELD_SOURCE", "D_6141",
|
||||
"PARAMETER_DEFAULT", "DEFAULT 'domain'",
|
||||
"FIELD_TYPE", 37,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_SCALE", 0,
|
||||
"FIELD_LENGTH", 48,
|
||||
"CHAR_COUNT", 12
|
||||
),
|
||||
row(
|
||||
"PROCEDURE_SOURCE", null,
|
||||
"PARAMETER_NAME", "O_ID",
|
||||
"PARAMETER_TYPE", 1,
|
||||
"PARAMETER_NUMBER", 0,
|
||||
"FIELD_SOURCE", "RDB$3",
|
||||
"FIELD_TYPE", 16,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_PRECISION", 0,
|
||||
"FIELD_SCALE", 0,
|
||||
"FIELD_LENGTH", 8
|
||||
),
|
||||
row(
|
||||
"PROCEDURE_SOURCE", null,
|
||||
"PARAMETER_NAME", "O_TEXT",
|
||||
"PARAMETER_TYPE", 1,
|
||||
"PARAMETER_NUMBER", 1,
|
||||
"FIELD_SOURCE", "RDB$4",
|
||||
"FIELD_TYPE", 37,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_SCALE", 0,
|
||||
"FIELD_LENGTH", 160,
|
||||
"CHAR_COUNT", 40
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource(null, "P_6141", " procedure ");
|
||||
|
||||
Assertions.assertEquals(List.of(EXPECTED_PROCEDURE_SOURCE_SQL), sql);
|
||||
Assertions.assertEquals(List.of("1=P_6141"), parameters);
|
||||
Assertions.assertEquals("P_6141", source.getName());
|
||||
Assertions.assertEquals("PROCEDURE", source.getObject_type());
|
||||
Assertions.assertNull(source.getSchema());
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
Assertions.assertEquals(
|
||||
"""
|
||||
CREATE OR ALTER PROCEDURE "P_6141" (
|
||||
"P_ID" INTEGER,
|
||||
"P_LABEL" VARCHAR(20) DEFAULT 'fallback',
|
||||
"P_DOM" "D_6141" DEFAULT 'domain'
|
||||
)
|
||||
RETURNS (
|
||||
"O_ID" BIGINT,
|
||||
"O_TEXT" VARCHAR(40)
|
||||
)
|
||||
AS
|
||||
DECLARE VARIABLE V_NOTE VARCHAR(20);
|
||||
BEGIN
|
||||
O_ID = P_ID;
|
||||
O_TEXT = P_LABEL || ':' || P_DOM;
|
||||
SUSPEND;
|
||||
END
|
||||
""",
|
||||
source.getSource()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void quotesMixedCaseIdentifiersAndBindsRoutineName() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> parameters = new ArrayList<>();
|
||||
FirebirdAgent agent = agentWithRows(
|
||||
sql,
|
||||
parameters,
|
||||
row(
|
||||
"PROCEDURE_SOURCE", "BEGIN\nEND",
|
||||
"PARAMETER_NAME", "Arg\"Name",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"PARAMETER_NUMBER", 0,
|
||||
"FIELD_SOURCE", "RDB$10",
|
||||
"FIELD_TYPE", 7,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_SCALE", 0,
|
||||
"FIELD_LENGTH", 2
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource("ignored-schema", "Mix\"Case", "PROCEDURE");
|
||||
|
||||
Assertions.assertEquals(List.of(EXPECTED_PROCEDURE_SOURCE_SQL), sql);
|
||||
Assertions.assertEquals(List.of("1=Mix\"Case"), parameters);
|
||||
Assertions.assertFalse(EXPECTED_PROCEDURE_SOURCE_SQL.contains("Mix\"Case"));
|
||||
Assertions.assertEquals(
|
||||
"CREATE OR ALTER PROCEDURE \"Mix\"\"Case\" (\n"
|
||||
+ " \"Arg\"\"Name\" SMALLINT\n"
|
||||
+ ")\n"
|
||||
+ "AS\n"
|
||||
+ "BEGIN\nEND\n",
|
||||
source.getSource()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsExactNumericLegacyAndFirebirdFourTypes() {
|
||||
FirebirdAgent agent = agentWithRows(
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>(),
|
||||
row(
|
||||
"PROCEDURE_SOURCE", "BEGIN\nEND",
|
||||
"PARAMETER_NAME", "N_SMALL",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$11",
|
||||
"FIELD_TYPE", 7,
|
||||
"FIELD_SUB_TYPE", 1,
|
||||
"FIELD_PRECISION", 4,
|
||||
"FIELD_SCALE", -2,
|
||||
"FIELD_LENGTH", 2
|
||||
),
|
||||
row(
|
||||
"PARAMETER_NAME", "N_LEGACY",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$12",
|
||||
"FIELD_TYPE", 8,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_PRECISION", 9,
|
||||
"FIELD_SCALE", -2,
|
||||
"FIELD_LENGTH", 4
|
||||
),
|
||||
row(
|
||||
"PARAMETER_NAME", "D_FLOAT_VALUE",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$13",
|
||||
"FIELD_TYPE", 11,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_LENGTH", 8
|
||||
),
|
||||
row(
|
||||
"PARAMETER_NAME", "DEC16_VALUE",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$14",
|
||||
"FIELD_TYPE", 24,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_LENGTH", 8
|
||||
),
|
||||
row(
|
||||
"PARAMETER_NAME", "DEC34_VALUE",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$15",
|
||||
"FIELD_TYPE", 25,
|
||||
"FIELD_SUB_TYPE", 0,
|
||||
"FIELD_LENGTH", 16
|
||||
),
|
||||
row(
|
||||
"PARAMETER_NAME", "BINARY_VALUE",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$16",
|
||||
"FIELD_TYPE", 14,
|
||||
"FIELD_SUB_TYPE", 1,
|
||||
"FIELD_LENGTH", 4
|
||||
),
|
||||
row(
|
||||
"PARAMETER_NAME", "VARBINARY_VALUE",
|
||||
"PARAMETER_TYPE", 0,
|
||||
"FIELD_SOURCE", "RDB$17",
|
||||
"FIELD_TYPE", 37,
|
||||
"FIELD_SUB_TYPE", 1,
|
||||
"FIELD_LENGTH", 8
|
||||
)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource(null, "P_TYPES", "PROCEDURE");
|
||||
|
||||
Assertions.assertEquals(
|
||||
"CREATE OR ALTER PROCEDURE \"P_TYPES\" (\n"
|
||||
+ " \"N_SMALL\" NUMERIC(4,2),\n"
|
||||
+ " \"N_LEGACY\" NUMERIC(9,2),\n"
|
||||
+ " \"D_FLOAT_VALUE\" DOUBLE PRECISION,\n"
|
||||
+ " \"DEC16_VALUE\" DECFLOAT(16),\n"
|
||||
+ " \"DEC34_VALUE\" DECFLOAT(34),\n"
|
||||
+ " \"BINARY_VALUE\" BINARY(4),\n"
|
||||
+ " \"VARBINARY_VALUE\" VARBINARY(8)\n"
|
||||
+ ")\n"
|
||||
+ "AS\n"
|
||||
+ "BEGIN\nEND\n",
|
||||
source.getSource()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptySourceWhenRoutineIsMissing() {
|
||||
FirebirdAgent agent = agentWithRows(new ArrayList<>(), new ArrayList<>());
|
||||
|
||||
ObjectSource source = agent.getObjectSource(null, "MISSING_PROCEDURE", "PROCEDURE");
|
||||
|
||||
Assertions.assertEquals("", source.getSource());
|
||||
Assertions.assertFalse(source.isEditable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptySourceWhenStoredBodyIsNull() {
|
||||
FirebirdAgent agent = agentWithRows(
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>(),
|
||||
row("PROCEDURE_SOURCE", null, "PARAMETER_NAME", null)
|
||||
);
|
||||
|
||||
ObjectSource source = agent.getObjectSource(null, "EXTERNAL_PROCEDURE", "PROCEDURE");
|
||||
|
||||
Assertions.assertEquals("", source.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesMetadataQueryErrors() {
|
||||
FirebirdAgent agent = new FirebirdAgent();
|
||||
SQLException failure = new SQLException("metadata unavailable", "HY000");
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
throw failure;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.getObjectSource(null, "P_6141", "PROCEDURE")
|
||||
);
|
||||
|
||||
Assertions.assertSame(failure, error.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedObjectTypesBeforeQuerying() {
|
||||
FirebirdAgent agent = new FirebirdAgent();
|
||||
|
||||
IllegalArgumentException error = Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> agent.getObjectSource(null, "P_6141", "VIEW")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("Unsupported object type: VIEW", error.getMessage());
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static FirebirdAgent agentWithRows(
|
||||
List<String> sql,
|
||||
List<String> parameters,
|
||||
Map<String, Object>... rows
|
||||
) {
|
||||
ResultSet resultSet = resultSet(List.of(rows));
|
||||
PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("setString".equals(method.getName())) {
|
||||
parameters.add(args[0] + "=" + args[1]);
|
||||
return null;
|
||||
}
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
return resultSet;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
Connection connection = proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
sql.add(String.valueOf(args[0]));
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
FirebirdAgent agent = new FirebirdAgent();
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static ResultSet resultSet(List<Map<String, Object>> rows) {
|
||||
int[] index = {-1};
|
||||
boolean[] wasNull = {false};
|
||||
return proxy(ResultSet.class, (method, args) -> {
|
||||
if ("next".equals(method.getName())) {
|
||||
index[0] += 1;
|
||||
return index[0] < rows.size();
|
||||
}
|
||||
if ("getString".equals(method.getName())) {
|
||||
Object value = rows.get(index[0]).get(String.valueOf(args[0]));
|
||||
wasNull[0] = value == null;
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
if ("getInt".equals(method.getName())) {
|
||||
Object value = rows.get(index[0]).get(String.valueOf(args[0]));
|
||||
wasNull[0] = value == null;
|
||||
return value == null ? 0 : ((Number) value).intValue();
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return wasNull[0];
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(Object... values) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
for (int index = 0; index < values.length; index += 2) {
|
||||
row.put((String) values[index], values[index + 1]);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String procedureBody() {
|
||||
return """
|
||||
DECLARE VARIABLE V_NOTE VARCHAR(20);
|
||||
BEGIN
|
||||
O_ID = P_ID;
|
||||
O_TEXT = P_LABEL || ':' || P_DOM;
|
||||
SUSPEND;
|
||||
END""";
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, MethodHandler handler) {
|
||||
InvocationHandler invocationHandler = new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
return handler.handle(method, args == null ? new Object[0] : args);
|
||||
}
|
||||
};
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == Boolean.TYPE) return false;
|
||||
if (type == Byte.TYPE) return (byte) 0;
|
||||
if (type == Short.TYPE) return (short) 0;
|
||||
if (type == Integer.TYPE) return 0;
|
||||
if (type == Long.TYPE) return 0L;
|
||||
if (type == Float.TYPE) return 0f;
|
||||
if (type == Double.TYPE) return 0d;
|
||||
if (type == Character.TYPE) return (char) 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface MethodHandler {
|
||||
Object handle(Method method, Object[] args) throws Throwable;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,31 @@ sourceSets {
|
||||
main.java.srcDirs = ['src/main/java', '../h2/src/main/java']
|
||||
}
|
||||
|
||||
configurations {
|
||||
h2V1
|
||||
h2V2
|
||||
h2V3
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.h2database:h2:2.1.214'
|
||||
h2V1 'com.h2database:h2:1.4.200'
|
||||
h2V2 'com.h2database:h2:2.1.214'
|
||||
h2V3 'com.h2database:h2:2.4.240'
|
||||
}
|
||||
|
||||
tasks.named('processResources') {
|
||||
from(configurations.h2V1) {
|
||||
into 'drivers'
|
||||
rename { 'h2-1.4.200.jar' }
|
||||
}
|
||||
from(configurations.h2V2) {
|
||||
into 'drivers'
|
||||
rename { 'h2-2.1.214.jar' }
|
||||
}
|
||||
from(configurations.h2V3) {
|
||||
into 'drivers'
|
||||
rename { 'h2-2.4.240.jar' }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
configurations {
|
||||
h2V1
|
||||
h2V2
|
||||
h2V3
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.h2database:h2:2.3.232'
|
||||
h2V1 'com.h2database:h2:1.4.200'
|
||||
h2V2 'com.h2database:h2:2.1.214'
|
||||
h2V3 'com.h2database:h2:2.4.240'
|
||||
testImplementation 'com.google.code.gson:gson:2.12.1'
|
||||
}
|
||||
|
||||
tasks.named('processResources') {
|
||||
from(configurations.h2V1) {
|
||||
into 'drivers'
|
||||
rename { 'h2-1.4.200.jar' }
|
||||
}
|
||||
from(configurations.h2V2) {
|
||||
into 'drivers'
|
||||
rename { 'h2-2.1.214.jar' }
|
||||
}
|
||||
from(configurations.h2V3) {
|
||||
into 'drivers'
|
||||
rename { 'h2-2.4.240.jar' }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
@@ -8,3 +31,10 @@ tasks.named('shadowJar') {
|
||||
attributes('Agent-Label': 'H2', 'Main-Class': 'com.dbx.agent.h2.H2Agent')
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
dependsOn tasks.named('shadowJar')
|
||||
doFirst {
|
||||
systemProperty 'dbx.h2.agent.jar', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.dbx.agent.TriggerInfo;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -30,6 +31,9 @@ import java.util.Set;
|
||||
|
||||
public class H2Agent extends AbstractJdbcAgent {
|
||||
private String databaseName = "";
|
||||
private H2DriverLoader.LoadedDriver loadedDriver;
|
||||
private H2DriverVersion driverVersion = H2DriverVersion.V3;
|
||||
private int databaseMajorVersion = 2;
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
@@ -41,9 +45,62 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void loadDriver(ConnectParams params) throws Exception {
|
||||
H2DriverVersion selected = H2DriverVersion.select(params);
|
||||
if (selected == H2DriverVersion.CUSTOM) {
|
||||
H2DriverLoader.LoadedDriver external = H2DriverLoader.loadExternal(
|
||||
params.getJdbc_driver_paths(),
|
||||
params.getJdbc_driver_class()
|
||||
);
|
||||
if (loadedDriver == null || !loadedDriver.identity().equals(external.identity())) {
|
||||
replaceLoadedDriver(external);
|
||||
} else {
|
||||
external.classLoader().close();
|
||||
}
|
||||
} else if (loadedDriver == null || loadedDriver.version() != selected) {
|
||||
replaceLoadedDriver(H2DriverLoader.load(selected));
|
||||
}
|
||||
driverVersion = selected;
|
||||
}
|
||||
|
||||
private void replaceLoadedDriver(H2DriverLoader.LoadedDriver replacement) throws Exception {
|
||||
H2DriverLoader.LoadedDriver previous = loadedDriver;
|
||||
if (previous != null) {
|
||||
try {
|
||||
previous.classLoader().close();
|
||||
} catch (Exception error) {
|
||||
replacement.classLoader().close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
loadedDriver = replacement;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Connection openConnection(ConnectParams params) throws Exception {
|
||||
if (loadedDriver == null) {
|
||||
throw new IllegalStateException("H2 JDBC driver was not loaded");
|
||||
}
|
||||
Connection opened = loadedDriver.driver().connect(buildJdbcUrl(params), buildConnectionProperties(params));
|
||||
if (opened == null) {
|
||||
throw new SQLException("H2 JDBC driver rejected URL: " + buildJdbcUrl(params));
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
databaseName = params.getDatabase();
|
||||
databaseMajorVersion = unchecked(() -> connection.getMetaData().getDatabaseMajorVersion());
|
||||
}
|
||||
|
||||
H2DriverVersion driverVersion() {
|
||||
return driverVersion;
|
||||
}
|
||||
|
||||
boolean isVersion2OrLater() {
|
||||
return databaseMajorVersion >= 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -118,7 +175,7 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
List<Object> args = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?");
|
||||
args.add(effectiveSchema);
|
||||
appendH2TableTypePredicate(sql, args, constraints);
|
||||
appendH2TableTypePredicate(sql, args, constraints, isVersion2OrLater());
|
||||
MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints);
|
||||
sql.append(" ORDER BY TABLE_NAME");
|
||||
MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints);
|
||||
@@ -143,9 +200,10 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment()));
|
||||
}
|
||||
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME"
|
||||
)) {
|
||||
String sql = isVersion2OrLater()
|
||||
? "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME"
|
||||
: "SELECT ALIAS_NAME, CASE WHEN RETURNS_RESULT = 2 THEN 'FUNCTION' ELSE 'PROCEDURE' END FROM INFORMATION_SCHEMA.FUNCTION_ALIASES WHERE ALIAS_SCHEMA = ? ORDER BY ALIAS_NAME";
|
||||
try (var stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, effectiveSchema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
@@ -174,6 +232,9 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
|
||||
if (!isVersion2OrLater()) {
|
||||
return constraints.filterObjects(listObjects(schema));
|
||||
}
|
||||
return unchecked(() -> {
|
||||
String effectiveSchema = resolveSchema(schema);
|
||||
List<ObjectInfo> result = new ArrayList<>();
|
||||
@@ -182,7 +243,7 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
if (constraints.includesTableLikeTypes()) {
|
||||
StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?");
|
||||
args.add(effectiveSchema);
|
||||
appendH2TableTypePredicate(tableSql, args, constraints);
|
||||
appendH2TableTypePredicate(tableSql, args, constraints, true);
|
||||
MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints);
|
||||
branches.add(tableSql.toString());
|
||||
}
|
||||
@@ -218,7 +279,9 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
String effectiveSchema = resolveSchema(schema);
|
||||
String sql = switch (objectType.toUpperCase(Locale.ROOT)) {
|
||||
case "VIEW" -> "SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?";
|
||||
case "FUNCTION", "PROCEDURE" -> "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?";
|
||||
case "FUNCTION", "PROCEDURE" -> isVersion2OrLater()
|
||||
? "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?"
|
||||
: "SELECT SOURCE FROM INFORMATION_SCHEMA.FUNCTION_ALIASES WHERE ALIAS_SCHEMA = ? AND ALIAS_NAME = ?";
|
||||
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
};
|
||||
|
||||
@@ -242,16 +305,17 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
return unchecked(() -> {
|
||||
String effectiveSchema = resolveSchema(schema);
|
||||
Set<String> primaryKeys = new HashSet<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT ic.COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
|
||||
JOIN INFORMATION_SCHEMA.INDEXES i
|
||||
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
|
||||
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
|
||||
AND i.INDEX_TYPE_NAME = 'PRIMARY KEY'
|
||||
"""
|
||||
)) {
|
||||
String primaryKeySql = isVersion2OrLater()
|
||||
? """
|
||||
SELECT ic.COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
|
||||
JOIN INFORMATION_SCHEMA.INDEXES i
|
||||
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
|
||||
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
|
||||
AND i.INDEX_TYPE_NAME = 'PRIMARY KEY'
|
||||
"""
|
||||
: "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.INDEXES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND PRIMARY_KEY = TRUE";
|
||||
try (var stmt = requireConnected().prepareStatement(primaryKeySql)) {
|
||||
stmt.setString(1, effectiveSchema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
@@ -262,15 +326,11 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT,
|
||||
NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
"""
|
||||
)) {
|
||||
String typeColumn = isVersion2OrLater() ? "DATA_TYPE" : "TYPE_NAME";
|
||||
String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
|
||||
+ "NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, REMARKS "
|
||||
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION";
|
||||
try (var stmt = requireConnected().prepareStatement(columnSql)) {
|
||||
stmt.setString(1, effectiveSchema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
@@ -278,11 +338,11 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
String columnName = rs.getString("COLUMN_NAME");
|
||||
result.add(new ColumnInfo(
|
||||
columnName,
|
||||
rs.getString("DATA_TYPE"),
|
||||
rs.getString("DBX_DATA_TYPE"),
|
||||
"YES".equals(rs.getString("IS_NULLABLE")),
|
||||
rs.getString("COLUMN_DEFAULT"),
|
||||
primaryKeys.contains(columnName),
|
||||
null,
|
||||
rs.getString("REMARKS"),
|
||||
null,
|
||||
intOrNull(rs, "NUMERIC_PRECISION"),
|
||||
intOrNull(rs, "NUMERIC_SCALE"),
|
||||
@@ -304,16 +364,24 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
Map<String, Boolean> primaryMap = new HashMap<>();
|
||||
Map<String, String> typeMap = new HashMap<>();
|
||||
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME
|
||||
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
|
||||
JOIN INFORMATION_SCHEMA.INDEXES i
|
||||
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
|
||||
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
|
||||
ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION
|
||||
"""
|
||||
)) {
|
||||
String indexSql = isVersion2OrLater()
|
||||
? """
|
||||
SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME,
|
||||
CASE WHEN i.INDEX_TYPE_NAME = 'PRIMARY KEY' THEN TRUE ELSE FALSE END AS IS_PRIMARY
|
||||
FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic
|
||||
JOIN INFORMATION_SCHEMA.INDEXES i
|
||||
ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME
|
||||
WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ?
|
||||
ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION
|
||||
"""
|
||||
: """
|
||||
SELECT INDEX_NAME, COLUMN_NAME, CASE WHEN NON_UNIQUE THEN FALSE ELSE TRUE END AS IS_UNIQUE,
|
||||
INDEX_TYPE_NAME, PRIMARY_KEY AS IS_PRIMARY
|
||||
FROM INFORMATION_SCHEMA.INDEXES
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY INDEX_NAME, ORDINAL_POSITION
|
||||
""";
|
||||
try (var stmt = requireConnected().prepareStatement(indexSql)) {
|
||||
stmt.setString(1, effectiveSchema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
@@ -324,7 +392,7 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
|
||||
indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>()).add(columnName);
|
||||
uniqueMap.put(indexName, rs.getBoolean("IS_UNIQUE"));
|
||||
primaryMap.put(indexName, "PRIMARY KEY".equals(indexType));
|
||||
primaryMap.put(indexName, rs.getBoolean("IS_PRIMARY"));
|
||||
typeMap.put(indexName, indexType == null ? "" : indexType);
|
||||
}
|
||||
}
|
||||
@@ -353,27 +421,17 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
return unchecked(() -> {
|
||||
String effectiveSchema = resolveSchema(schema);
|
||||
List<ForeignKeyInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT FK_NAME, FKCOLUMN_NAME, PKTABLE_NAME, PKCOLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.CROSS_REFERENCES
|
||||
WHERE FKTABLE_SCHEMA = ? AND FKTABLE_NAME = ?
|
||||
ORDER BY FK_NAME
|
||||
"""
|
||||
)) {
|
||||
stmt.setString(1, effectiveSchema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new ForeignKeyInfo(
|
||||
rs.getString("FK_NAME"),
|
||||
rs.getString("FKCOLUMN_NAME"),
|
||||
rs.getString("PKTABLE_NAME"),
|
||||
rs.getString("PKCOLUMN_NAME")
|
||||
));
|
||||
}
|
||||
try (ResultSet rs = requireConnected().getMetaData().getImportedKeys(null, effectiveSchema, table)) {
|
||||
while (rs.next()) {
|
||||
result.add(new ForeignKeyInfo(
|
||||
rs.getString("FK_NAME"),
|
||||
rs.getString("FKCOLUMN_NAME"),
|
||||
rs.getString("PKTABLE_NAME"),
|
||||
rs.getString("PKCOLUMN_NAME")
|
||||
));
|
||||
}
|
||||
}
|
||||
result.sort(java.util.Comparator.comparing(ForeignKeyInfo::getName, java.util.Comparator.nullsLast(String::compareTo)));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -383,14 +441,21 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
return unchecked(() -> {
|
||||
String effectiveSchema = resolveSchema(schema);
|
||||
List<TriggerInfo> result = new ArrayList<>();
|
||||
try (var stmt = requireConnected().prepareStatement(
|
||||
"""
|
||||
SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING
|
||||
FROM INFORMATION_SCHEMA.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ?
|
||||
ORDER BY TRIGGER_NAME
|
||||
"""
|
||||
)) {
|
||||
String triggerSql = isVersion2OrLater()
|
||||
? """
|
||||
SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING
|
||||
FROM INFORMATION_SCHEMA.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ?
|
||||
ORDER BY TRIGGER_NAME
|
||||
"""
|
||||
: """
|
||||
SELECT TRIGGER_NAME, TRIGGER_TYPE AS EVENT_MANIPULATION,
|
||||
CASE WHEN BEFORE THEN 'BEFORE' ELSE 'AFTER' END AS ACTION_TIMING
|
||||
FROM INFORMATION_SCHEMA.TRIGGERS
|
||||
WHERE TRIGGER_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY TRIGGER_NAME
|
||||
""";
|
||||
try (var stmt = requireConnected().prepareStatement(triggerSql)) {
|
||||
stmt.setString(1, effectiveSchema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
@@ -452,13 +517,18 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
|| constraints.objectTypeAllowed("FUNCTION");
|
||||
}
|
||||
|
||||
private static void appendH2TableTypePredicate(StringBuilder sql, List<Object> args, MetadataListConstraints constraints) {
|
||||
private static void appendH2TableTypePredicate(
|
||||
StringBuilder sql,
|
||||
List<Object> args,
|
||||
MetadataListConstraints constraints,
|
||||
boolean version2OrLater
|
||||
) {
|
||||
if (!constraints.hasObjectTypes()) {
|
||||
return;
|
||||
}
|
||||
List<String> types = new ArrayList<>();
|
||||
if (constraints.tableTypeAllowed("TABLE")) {
|
||||
types.add("BASE TABLE");
|
||||
types.add(version2OrLater ? "BASE TABLE" : "TABLE");
|
||||
}
|
||||
if (constraints.tableTypeAllowed("VIEW")) {
|
||||
types.add("VIEW");
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.dbx.agent.h2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.Driver;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
final class H2DriverLoader {
|
||||
private static final Object EXTRACTION_LOCK = new Object();
|
||||
private static final Path CACHE_ROOT = Path.of(System.getProperty("java.io.tmpdir"), "dbx-h2-drivers");
|
||||
|
||||
private H2DriverLoader() {
|
||||
}
|
||||
|
||||
static LoadedDriver load(H2DriverVersion version) throws Exception {
|
||||
Path jar = extract(version);
|
||||
URLClassLoader classLoader = new URLClassLoader(
|
||||
new URL[]{jar.toUri().toURL()},
|
||||
H2DriverLoader.class.getClassLoader()
|
||||
);
|
||||
try {
|
||||
Driver driver = (Driver) Class.forName("org.h2.Driver", true, classLoader).getDeclaredConstructor().newInstance();
|
||||
return new LoadedDriver(version, version.version(), driver, classLoader);
|
||||
} catch (Exception error) {
|
||||
closeAfterFailure(classLoader, error);
|
||||
throw error;
|
||||
} catch (LinkageError error) {
|
||||
closeAfterFailure(classLoader, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static LoadedDriver loadExternal(List<String> driverPaths, String driverClass) throws Exception {
|
||||
if (driverPaths == null || driverPaths.isEmpty()) {
|
||||
throw new IllegalArgumentException("Custom H2 driver profile requires at least one JDBC JAR path");
|
||||
}
|
||||
List<URL> urls = new ArrayList<>();
|
||||
List<String> identities = new ArrayList<>();
|
||||
for (String driverPath : driverPaths) {
|
||||
Path path = Path.of(driverPath).toAbsolutePath().normalize();
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw new IOException("Custom H2 JDBC JAR does not exist: " + path);
|
||||
}
|
||||
urls.add(path.toUri().toURL());
|
||||
identities.add(path + ":" + sha256(path));
|
||||
}
|
||||
String effectiveDriverClass = driverClass == null || driverClass.isBlank() ? "org.h2.Driver" : driverClass.trim();
|
||||
URLClassLoader classLoader = new URLClassLoader(
|
||||
urls.toArray(new URL[0]),
|
||||
H2DriverLoader.class.getClassLoader()
|
||||
);
|
||||
try {
|
||||
Driver driver = (Driver) Class.forName(effectiveDriverClass, true, classLoader).getDeclaredConstructor().newInstance();
|
||||
return new LoadedDriver(H2DriverVersion.CUSTOM, effectiveDriverClass + "|" + String.join("|", identities), driver, classLoader);
|
||||
} catch (Exception error) {
|
||||
closeAfterFailure(classLoader, error);
|
||||
throw error;
|
||||
} catch (LinkageError error) {
|
||||
closeAfterFailure(classLoader, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path extract(H2DriverVersion version) throws Exception {
|
||||
byte[] bytes;
|
||||
try (InputStream input = H2DriverLoader.class.getResourceAsStream(version.resourcePath())) {
|
||||
if (input == null) {
|
||||
throw new IOException("Bundled H2 driver is missing: " + version.resourcePath());
|
||||
}
|
||||
bytes = input.readAllBytes();
|
||||
}
|
||||
String digest = sha256(bytes);
|
||||
Path directory = CACHE_ROOT.resolve(version.version() + "-" + digest.substring(0, 16));
|
||||
Path target = directory.resolve("h2.jar");
|
||||
synchronized (EXTRACTION_LOCK) {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isRegularFile(target) || Files.size(target) != bytes.length || !digest.equals(sha256(target))) {
|
||||
Path temporary = directory.resolve("h2.jar.tmp-" + UUID.randomUUID());
|
||||
Files.write(temporary, bytes);
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static String sha256(Path path) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (read > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static String sha256(byte[] bytes) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
}
|
||||
|
||||
private static void closeAfterFailure(URLClassLoader classLoader, Throwable error) {
|
||||
try {
|
||||
classLoader.close();
|
||||
} catch (IOException closeError) {
|
||||
error.addSuppressed(closeError);
|
||||
}
|
||||
}
|
||||
|
||||
record LoadedDriver(H2DriverVersion version, String identity, Driver driver, URLClassLoader classLoader) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.dbx.agent.h2;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
enum H2DriverVersion {
|
||||
V1("h2-v1", "1.4.200", "/drivers/h2-1.4.200.jar", 1),
|
||||
V2("h2-v2", "2.1.214", "/drivers/h2-2.1.214.jar", 2),
|
||||
V3("h2-v3", "2.4.240", "/drivers/h2-2.4.240.jar", 3),
|
||||
CUSTOM("h2-custom", "custom", "", 0);
|
||||
|
||||
private final String profile;
|
||||
private final String version;
|
||||
private final String resourcePath;
|
||||
private final int storageFormat;
|
||||
|
||||
H2DriverVersion(String profile, String version, String resourcePath, int storageFormat) {
|
||||
this.profile = profile;
|
||||
this.version = version;
|
||||
this.resourcePath = resourcePath;
|
||||
this.storageFormat = storageFormat;
|
||||
}
|
||||
|
||||
String profile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
String version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
String resourcePath() {
|
||||
return resourcePath;
|
||||
}
|
||||
|
||||
int storageFormat() {
|
||||
return storageFormat;
|
||||
}
|
||||
|
||||
static H2DriverVersion select(ConnectParams params) throws Exception {
|
||||
String profile = params.getDriver_profile();
|
||||
String normalized = profile == null ? "" : profile.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "h2-v1" -> V1;
|
||||
case "h2-v2", "h2-legacy" -> V2;
|
||||
case "h2-v3" -> V3;
|
||||
case "h2-custom" -> CUSTOM;
|
||||
case "", "h2", "h2-auto", "h2_embedded", "h2_server" -> {
|
||||
java.util.OptionalInt detected = H2FileFormatDetector.detect(H2Agent.buildUrl(params));
|
||||
yield detected.isPresent() ? fromStorageFormat(detected.getAsInt()) : V3;
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unsupported H2 driver profile: " + profile);
|
||||
};
|
||||
}
|
||||
|
||||
private static H2DriverVersion fromStorageFormat(int format) {
|
||||
return switch (format) {
|
||||
case 1 -> V1;
|
||||
case 2 -> V2;
|
||||
case 3 -> V3;
|
||||
default -> throw new IllegalArgumentException("Unsupported H2 database file format: " + format);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.dbx.agent.h2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
final class H2FileFormatDetector {
|
||||
private static final int BLOCK_SIZE = 4096;
|
||||
|
||||
private H2FileFormatDetector() {
|
||||
}
|
||||
|
||||
static OptionalInt detect(String jdbcUrl) throws IOException {
|
||||
Path base = localDatabaseBasePath(jdbcUrl);
|
||||
if (base == null) {
|
||||
return OptionalInt.empty();
|
||||
}
|
||||
Path pageStore = withSuffix(base, ".h2.db");
|
||||
Path mvStore = withSuffix(base, ".mv.db");
|
||||
boolean pageStoreExists = Files.isRegularFile(pageStore);
|
||||
boolean mvStoreExists = Files.isRegularFile(mvStore);
|
||||
if (pageStoreExists && mvStoreExists) {
|
||||
throw new IOException("Both H2 PageStore and MVStore files exist for " + base + "; choose an explicit H2 driver profile");
|
||||
}
|
||||
if (pageStoreExists) {
|
||||
return OptionalInt.of(1);
|
||||
}
|
||||
if (!mvStoreExists) {
|
||||
return OptionalInt.empty();
|
||||
}
|
||||
return OptionalInt.of(readMvStoreFormat(mvStore));
|
||||
}
|
||||
|
||||
private static int readMvStoreFormat(Path file) throws IOException {
|
||||
byte[] bytes;
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
bytes = input.readNBytes(BLOCK_SIZE * 2);
|
||||
}
|
||||
if (bytes.length < BLOCK_SIZE * 2) {
|
||||
throw new IOException("H2 MVStore file is too small to contain valid headers: " + file);
|
||||
}
|
||||
List<StoreHeader> headers = new ArrayList<>();
|
||||
for (int block = 0; block < 2; block++) {
|
||||
StoreHeader header = parseHeader(bytes, block * BLOCK_SIZE);
|
||||
if (header != null) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
return headers.stream()
|
||||
.max(Comparator.comparingLong(StoreHeader::version))
|
||||
.orElseThrow(() -> new IOException("Cannot determine the H2 MVStore format for " + file + "; choose an explicit H2 driver profile"))
|
||||
.format();
|
||||
}
|
||||
|
||||
private static StoreHeader parseHeader(byte[] fileBytes, int offset) {
|
||||
byte[] block = java.util.Arrays.copyOfRange(fileBytes, offset, offset + BLOCK_SIZE);
|
||||
int start = 0;
|
||||
int end = block.length;
|
||||
while (start < end && block[start] <= ' ') {
|
||||
start++;
|
||||
}
|
||||
while (start < end && block[end - 1] <= ' ') {
|
||||
end--;
|
||||
}
|
||||
if (start >= end) {
|
||||
return null;
|
||||
}
|
||||
String text = new String(block, start, end - start, StandardCharsets.ISO_8859_1);
|
||||
int fletcherIndex = text.indexOf("fletcher:");
|
||||
if (fletcherIndex <= 0 || text.charAt(fletcherIndex - 1) != ',') {
|
||||
return null;
|
||||
}
|
||||
int checksumEnd = text.indexOf(',', fletcherIndex);
|
||||
if (checksumEnd < 0) {
|
||||
checksumEnd = text.length();
|
||||
}
|
||||
try {
|
||||
long expected = Long.parseUnsignedLong(text.substring(fletcherIndex + "fletcher:".length(), checksumEnd), 16);
|
||||
int actual = fletcher32(block, start, fletcherIndex - 1);
|
||||
if (Integer.toUnsignedLong(actual) != expected) {
|
||||
return null;
|
||||
}
|
||||
long version = readHexValue(text, "version", 0);
|
||||
int format = Math.toIntExact(readHexValue(text, "format", 1));
|
||||
return new StoreHeader(version, format);
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static long readHexValue(String text, String key, long defaultValue) {
|
||||
String marker = key + ':';
|
||||
int index = text.indexOf(marker);
|
||||
if (index < 0 || (index > 0 && text.charAt(index - 1) != ',')) {
|
||||
return defaultValue;
|
||||
}
|
||||
int start = index + marker.length();
|
||||
int end = text.indexOf(',', start);
|
||||
if (end < 0) {
|
||||
end = text.length();
|
||||
}
|
||||
return Long.parseUnsignedLong(text.substring(start, end), 16);
|
||||
}
|
||||
|
||||
private static int fletcher32(byte[] bytes, int offset, int length) {
|
||||
int s1 = 0xffff;
|
||||
int s2 = 0xffff;
|
||||
int index = offset;
|
||||
int evenEnd = offset + (length & ~1);
|
||||
while (index < evenEnd) {
|
||||
int batchEnd = Math.min(index + 720, evenEnd);
|
||||
while (index < batchEnd) {
|
||||
int value = ((bytes[index++] & 0xff) << 8) | (bytes[index++] & 0xff);
|
||||
s2 += s1 += value;
|
||||
}
|
||||
s1 = (s1 & 0xffff) + (s1 >>> 16);
|
||||
s2 = (s2 & 0xffff) + (s2 >>> 16);
|
||||
}
|
||||
if ((length & 1) != 0) {
|
||||
int value = (bytes[index] & 0xff) << 8;
|
||||
s2 += s1 += value;
|
||||
}
|
||||
s1 = (s1 & 0xffff) + (s1 >>> 16);
|
||||
s2 = (s2 & 0xffff) + (s2 >>> 16);
|
||||
return (s2 << 16) | s1;
|
||||
}
|
||||
|
||||
private static Path localDatabaseBasePath(String jdbcUrl) {
|
||||
String prefix = "jdbc:h2:";
|
||||
if (jdbcUrl == null || !jdbcUrl.regionMatches(true, 0, prefix, 0, prefix.length())) {
|
||||
return null;
|
||||
}
|
||||
String target = jdbcUrl.substring(prefix.length());
|
||||
String lower = target.toLowerCase(java.util.Locale.ROOT);
|
||||
if (lower.startsWith("tcp:") || lower.startsWith("ssl:") || lower.startsWith("mem:")
|
||||
|| lower.startsWith("zip:") || lower.startsWith("split:")) {
|
||||
return null;
|
||||
}
|
||||
if (lower.startsWith("file:")) {
|
||||
target = target.substring("file:".length());
|
||||
}
|
||||
target = beforeOptions(target);
|
||||
if (target.startsWith("~" + java.io.File.separator) || target.startsWith("~/") || target.startsWith("~\\")) {
|
||||
target = System.getProperty("user.home") + target.substring(1);
|
||||
}
|
||||
if (target.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Path path = Path.of(target).toAbsolutePath().normalize();
|
||||
String value = path.toString().toLowerCase(java.util.Locale.ROOT);
|
||||
if (value.endsWith(".mv.db")) {
|
||||
return Path.of(path.toString().substring(0, path.toString().length() - ".mv.db".length()));
|
||||
}
|
||||
if (value.endsWith(".h2.db")) {
|
||||
return Path.of(path.toString().substring(0, path.toString().length() - ".h2.db".length()));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static String beforeOptions(String value) {
|
||||
boolean escaped = false;
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char current = value.charAt(index);
|
||||
if (current == ';' && !escaped) {
|
||||
return value.substring(0, index);
|
||||
}
|
||||
escaped = current == '\\' && !escaped;
|
||||
if (current != '\\') {
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Path withSuffix(Path base, String suffix) {
|
||||
return Path.of(base.toString() + suffix);
|
||||
}
|
||||
|
||||
private record StoreHeader(long version, int format) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.dbx.agent.h2;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
class H2AgentProcessTest {
|
||||
@Test
|
||||
@Timeout(45)
|
||||
void shadedAgentSupportsIsolatedMultiVersionSessions() throws Exception {
|
||||
Path agentJar = Path.of(System.getProperty("dbx.h2.agent.jar"));
|
||||
Assertions.assertTrue(Files.isRegularFile(agentJar), () -> "Missing shaded H2 Agent: " + agentJar);
|
||||
|
||||
try (RpcProcess rpc = new RpcProcess(agentJar)) {
|
||||
JsonObject handshake = rpc.result(rpc.request("handshake", new JsonObject()));
|
||||
Assertions.assertEquals(2, handshake.get("protocolVersion").getAsInt());
|
||||
|
||||
Map<String, String> versions = new LinkedHashMap<>();
|
||||
versions.put("h2-v1", "1.4.200");
|
||||
versions.put("h2-v2", "2.1.214");
|
||||
versions.put("h2-v3", "2.4.240");
|
||||
|
||||
int index = 0;
|
||||
for (Map.Entry<String, String> entry : versions.entrySet()) {
|
||||
String sessionId = "session-" + entry.getKey();
|
||||
JsonObject connected = rpc.result(rpc.request("open_session", connectParams(sessionId, entry.getKey())));
|
||||
Assertions.assertTrue(connected.get("ok").getAsBoolean(), entry.getKey());
|
||||
|
||||
JsonObject info = rpc.result(rpc.request("connection_info", sessionParams(sessionId)));
|
||||
String driverVersion = info.getAsJsonObject("databaseInfo").get("driverVersion").getAsString();
|
||||
Assertions.assertTrue(driverVersion.startsWith(entry.getValue()), entry.getKey() + ": " + driverVersion);
|
||||
|
||||
String table = "RPC_TABLE_" + (++index);
|
||||
rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE TABLE " + table + " (ID INT PRIMARY KEY, TEXT_VALUE VARCHAR(32))")));
|
||||
rpc.result(rpc.request("execute_query", queryParams(sessionId, "INSERT INTO " + table + " VALUES (1, '" + entry.getKey() + "')")));
|
||||
JsonArray tables = rpc.resultArray(rpc.request("list_tables", metadataParams(sessionId, "PUBLIC")));
|
||||
Assertions.assertTrue(tables.asList().stream().anyMatch(value -> table.equals(value.getAsJsonObject().get("name").getAsString())), entry.getKey());
|
||||
Assertions.assertEquals(entry.getKey(), firstCell(rpc.result(rpc.request("execute_query", queryParams(sessionId, "SELECT TEXT_VALUE FROM " + table)))));
|
||||
}
|
||||
|
||||
JsonObject transaction = sessionParams("session-h2-v2");
|
||||
JsonArray statements = new JsonArray();
|
||||
statements.add("CREATE TABLE TX_PROBE (ID INT PRIMARY KEY)");
|
||||
statements.add("INSERT INTO TX_PROBE VALUES (1)");
|
||||
statements.add("INSERT INTO TX_PROBE VALUES (2)");
|
||||
transaction.add("statements", statements);
|
||||
Assertions.assertEquals(2, rpc.result(rpc.request("execute_transaction", transaction)).get("affected_rows").getAsLong());
|
||||
Assertions.assertEquals("2", firstCell(rpc.result(rpc.request("execute_query", queryParams("session-h2-v2", "SELECT COUNT(*) FROM TX_PROBE")))));
|
||||
|
||||
JsonObject firstPageParams = queryParams("session-h2-v3", "SELECT X FROM SYSTEM_RANGE(1, 5)");
|
||||
firstPageParams.addProperty("pageSize", 2);
|
||||
JsonObject firstPage = rpc.result(rpc.request("execute_query_page", firstPageParams));
|
||||
Assertions.assertEquals(2, firstPage.getAsJsonArray("rows").size());
|
||||
Assertions.assertTrue(firstPage.get("has_more").getAsBoolean());
|
||||
String querySessionId = firstPage.get("session_id").getAsString();
|
||||
|
||||
JsonObject fetchParams = sessionParams("session-h2-v3");
|
||||
fetchParams.addProperty("sessionId", querySessionId);
|
||||
fetchParams.addProperty("pageSize", 2);
|
||||
JsonObject secondPage = rpc.result(rpc.request("fetch_query_page", fetchParams));
|
||||
Assertions.assertEquals(2, secondPage.getAsJsonArray("rows").size());
|
||||
Assertions.assertTrue(secondPage.get("has_more").getAsBoolean());
|
||||
JsonObject finalPage = rpc.result(rpc.request("fetch_query_page", fetchParams));
|
||||
Assertions.assertEquals(1, finalPage.getAsJsonArray("rows").size());
|
||||
Assertions.assertFalse(finalPage.get("has_more").getAsBoolean());
|
||||
|
||||
int slowQueryId = rpc.send("execute_query", queryParams("session-h2-v3", "SELECT SUM(SIN(X)) FROM SYSTEM_RANGE(1, 1000000000)"));
|
||||
Thread.sleep(100);
|
||||
JsonObject cancelled = rpc.result(rpc.request("cancel_session", sessionParams("session-h2-v3")));
|
||||
Assertions.assertTrue(cancelled.get("ok").getAsBoolean());
|
||||
JsonObject slowResponse = rpc.await(slowQueryId, Duration.ofSeconds(15));
|
||||
Assertions.assertTrue(slowResponse.has("error"), () -> "Expected cancelled query error, got " + slowResponse);
|
||||
Assertions.assertTrue(rpc.result(rpc.request("validate_session", sessionParams("session-h2-v3"))).get("ok").getAsBoolean());
|
||||
|
||||
for (String profile : versions.keySet()) {
|
||||
String sessionId = "session-" + profile;
|
||||
Assertions.assertTrue(rpc.result(rpc.request("close_session", sessionParams(sessionId))).get("ok").getAsBoolean());
|
||||
Assertions.assertTrue(rpc.request("validate_session", sessionParams(sessionId)).has("error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject connectParams(String sessionId, String profile) {
|
||||
JsonObject params = sessionParams(sessionId);
|
||||
params.addProperty("host", "");
|
||||
params.addProperty("port", 0);
|
||||
params.addProperty("database", "mem:rpc-" + profile + ";DB_CLOSE_DELAY=-1");
|
||||
params.addProperty("username", "sa");
|
||||
params.addProperty("password", "");
|
||||
params.addProperty("connection_string", "");
|
||||
params.addProperty("driver_profile", profile);
|
||||
params.addProperty("ssl", false);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static JsonObject sessionParams(String sessionId) {
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("agentSessionId", sessionId);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static JsonObject queryParams(String sessionId, String sql) {
|
||||
JsonObject params = sessionParams(sessionId);
|
||||
params.addProperty("sql", sql);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static JsonObject metadataParams(String sessionId, String schema) {
|
||||
JsonObject params = sessionParams(sessionId);
|
||||
params.addProperty("schema", schema);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static String firstCell(JsonObject result) {
|
||||
return result.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString();
|
||||
}
|
||||
|
||||
private static final class RpcProcess implements AutoCloseable {
|
||||
private final Process process;
|
||||
private final BufferedWriter input;
|
||||
private final BlockingQueue<JsonObject> responses = new LinkedBlockingQueue<>();
|
||||
private final Map<Integer, JsonObject> pending = new ConcurrentHashMap<>();
|
||||
private final CompletableFuture<Void> ready = new CompletableFuture<>();
|
||||
private final AtomicInteger requestIds = new AtomicInteger();
|
||||
private final StringBuilder stderr = new StringBuilder();
|
||||
|
||||
private RpcProcess(Path agentJar) throws Exception {
|
||||
Path java = Path.of(System.getProperty("java.home"), "bin", "java");
|
||||
process = new ProcessBuilder(java.toString(), "-jar", agentJar.toString()).start();
|
||||
input = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
|
||||
startStdoutReader();
|
||||
startStderrReader();
|
||||
ready.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void startStdoutReader() {
|
||||
Thread thread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JsonObject message = JsonParser.parseString(line).getAsJsonObject();
|
||||
if (message.has("ready")) {
|
||||
ready.complete(null);
|
||||
} else {
|
||||
responses.add(message);
|
||||
}
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
ready.completeExceptionally(error);
|
||||
}
|
||||
}, "h2-agent-rpc-stdout");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private void startStderrReader() {
|
||||
Thread thread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
synchronized (stderr) {
|
||||
stderr.append(line).append('\n');
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}, "h2-agent-rpc-stderr");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private JsonObject request(String method, JsonObject params) throws Exception {
|
||||
return await(send(method, params), Duration.ofSeconds(15));
|
||||
}
|
||||
|
||||
private int send(String method, JsonObject params) throws Exception {
|
||||
int id = requestIds.incrementAndGet();
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("jsonrpc", "2.0");
|
||||
request.addProperty("id", id);
|
||||
request.addProperty("method", method);
|
||||
request.add("params", params);
|
||||
synchronized (input) {
|
||||
input.write(request.toString());
|
||||
input.newLine();
|
||||
input.flush();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private JsonObject await(int id, Duration timeout) throws Exception {
|
||||
JsonObject buffered = pending.remove(id);
|
||||
if (buffered != null) {
|
||||
return buffered;
|
||||
}
|
||||
long deadline = System.nanoTime() + timeout.toNanos();
|
||||
while (System.nanoTime() < deadline) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
JsonObject response = responses.poll(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining)), TimeUnit.MILLISECONDS);
|
||||
if (response == null) {
|
||||
break;
|
||||
}
|
||||
int responseId = response.get("id").getAsInt();
|
||||
if (responseId == id) {
|
||||
return response;
|
||||
}
|
||||
pending.put(responseId, response);
|
||||
}
|
||||
throw new AssertionError("Timed out waiting for H2 Agent response " + id + "; stderr=" + stderrText());
|
||||
}
|
||||
|
||||
private JsonObject result(JsonObject response) {
|
||||
Assertions.assertFalse(response.has("error"), () -> response + "\nstderr=" + stderrText());
|
||||
return response.getAsJsonObject("result");
|
||||
}
|
||||
|
||||
private JsonArray resultArray(JsonObject response) {
|
||||
Assertions.assertFalse(response.has("error"), () -> response + "\nstderr=" + stderrText());
|
||||
return response.getAsJsonArray("result");
|
||||
}
|
||||
|
||||
private String stderrText() {
|
||||
synchronized (stderr) {
|
||||
return stderr.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
if (process.isAlive()) {
|
||||
try {
|
||||
result(request("shutdown", new JsonObject()));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
input.close();
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
process.waitFor(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,34 @@
|
||||
package com.dbx.agent.h2;
|
||||
|
||||
import com.dbx.agent.BaseDatabaseAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.test.JdbcExecutionBehaviorTest;
|
||||
import com.dbx.agent.test.JdbcMetadataBehaviorTest;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class H2AgentMigrationTest {
|
||||
@TempDir
|
||||
Path tempDirectory;
|
||||
|
||||
@Test
|
||||
void agentExtendsBaseDatabaseAgent() {
|
||||
Assertions.assertTrue(BaseDatabaseAgent.class.isAssignableFrom(H2Agent.class));
|
||||
@@ -62,6 +76,273 @@ class H2AgentMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsEveryBundledH2Driver() {
|
||||
assertBundledDriver("h2-v1", H2DriverVersion.V1, "1.4.200");
|
||||
assertBundledDriver("h2-v2", H2DriverVersion.V2, "2.1.214");
|
||||
assertBundledDriver("h2-v3", H2DriverVersion.V3, "2.4.240");
|
||||
assertBundledDriver("h2-legacy", H2DriverVersion.V2, "2.1.214");
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoSelectsBundledDriverFromMvStoreFormat() {
|
||||
assertAutoDetectedFileDriver(H2DriverVersion.V1);
|
||||
assertAutoDetectedFileDriver(H2DriverVersion.V2);
|
||||
assertAutoDetectedFileDriver(H2DriverVersion.V3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoSelectsH2V1ForPageStoreDatabase() {
|
||||
Path base = tempDirectory.resolve("pagestore");
|
||||
H2Agent creator = new H2Agent();
|
||||
creator.connect(profileParams("h2-v1", "file:" + base + ";MV_STORE=FALSE"));
|
||||
try {
|
||||
creator.executeQuery("CREATE TABLE PAGESTORE_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
|
||||
} finally {
|
||||
creator.disconnect();
|
||||
}
|
||||
Assertions.assertTrue(Files.isRegularFile(Path.of(base + ".h2.db")));
|
||||
|
||||
H2Agent detected = new H2Agent();
|
||||
detected.connect(profileParams("h2", "file:" + base + ";MV_STORE=FALSE;IFEXISTS=TRUE"));
|
||||
try {
|
||||
Assertions.assertEquals(H2DriverVersion.V1, detected.driverVersion());
|
||||
Assertions.assertEquals(List.of("PAGESTORE_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
|
||||
} finally {
|
||||
detected.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoUsesLatestDriverForNewLocalDatabase() throws Exception {
|
||||
Path base = tempDirectory.resolve("new-auto");
|
||||
H2Agent agent = new H2Agent();
|
||||
agent.connect(profileParams("h2", "file:" + base));
|
||||
try {
|
||||
Assertions.assertEquals(H2DriverVersion.V3, agent.driverVersion());
|
||||
agent.executeQuery("CREATE TABLE LATEST_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
Assertions.assertEquals(3, H2FileFormatDetector.detect("jdbc:h2:file:" + base).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoRejectsCorruptMvStoreWithoutModifyingIt() throws Exception {
|
||||
Path base = tempDirectory.resolve("corrupt");
|
||||
Path file = Path.of(base + ".mv.db");
|
||||
byte[] corrupt = new byte[8192];
|
||||
java.util.Arrays.fill(corrupt, (byte) 0x5a);
|
||||
Files.write(file, corrupt);
|
||||
FileTime modified = Files.getLastModifiedTime(file);
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"))
|
||||
);
|
||||
|
||||
Assertions.assertTrue(hasCauseMessage(error, "Cannot determine the H2 MVStore format"));
|
||||
Assertions.assertArrayEquals(corrupt, Files.readAllBytes(file));
|
||||
Assertions.assertEquals(modified, Files.getLastModifiedTime(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoRejectsAmbiguousPageStoreAndMvStoreFiles() throws Exception {
|
||||
Path base = tempDirectory.resolve("ambiguous");
|
||||
Files.write(Path.of(base + ".h2.db"), new byte[]{1});
|
||||
Files.write(Path.of(base + ".mv.db"), new byte[8192]);
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"))
|
||||
);
|
||||
|
||||
Assertions.assertTrue(hasCauseMessage(error, "Both H2 PageStore and MVStore files exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoHonorsIfExistsWithoutCreatingMissingDatabase() {
|
||||
Path base = tempDirectory.resolve("missing-ifexists");
|
||||
|
||||
Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"))
|
||||
);
|
||||
|
||||
Assertions.assertFalse(Files.exists(Path.of(base + ".mv.db")));
|
||||
Assertions.assertFalse(Files.exists(Path.of(base + ".h2.db")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoOpensExistingMvStoreReadOnlyWithoutChangingFile() throws Exception {
|
||||
Path base = tempDirectory.resolve("readonly");
|
||||
H2Agent creator = new H2Agent();
|
||||
creator.connect(profileParams("h2-v2", "file:" + base));
|
||||
try {
|
||||
creator.executeQuery("CREATE TABLE READONLY_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
|
||||
} finally {
|
||||
creator.disconnect();
|
||||
}
|
||||
Path file = Path.of(base + ".mv.db");
|
||||
byte[] before = Files.readAllBytes(file);
|
||||
|
||||
H2Agent reader = new H2Agent();
|
||||
reader.connect(profileParams("h2", "file:" + base + ";ACCESS_MODE_DATA=r;IFEXISTS=TRUE"));
|
||||
try {
|
||||
Assertions.assertEquals(H2DriverVersion.V2, reader.driverVersion());
|
||||
Assertions.assertEquals(0, reader.executeQuery("SELECT * FROM READONLY_PROBE", null, new ExecuteQueryOptions()).getRows().size());
|
||||
Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> reader.executeQuery("INSERT INTO READONLY_PROBE VALUES (1)", null, new ExecuteQueryOptions())
|
||||
);
|
||||
} finally {
|
||||
reader.disconnect();
|
||||
}
|
||||
Assertions.assertArrayEquals(before, Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptedMvStoreRequiresExplicitDriverProfile() {
|
||||
Path base = tempDirectory.resolve("encrypted");
|
||||
ConnectParams createParams = profileParams("h2-v3", "file:" + base + ";CIPHER=AES");
|
||||
createParams.setPassword("file-secret user-secret");
|
||||
H2Agent creator = new H2Agent();
|
||||
creator.connect(createParams);
|
||||
try {
|
||||
creator.executeQuery("CREATE TABLE ENCRYPTED_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
|
||||
} finally {
|
||||
creator.disconnect();
|
||||
}
|
||||
|
||||
ConnectParams autoParams = profileParams("h2", "file:" + base + ";CIPHER=AES;IFEXISTS=TRUE");
|
||||
autoParams.setPassword("file-secret user-secret");
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(autoParams));
|
||||
Assertions.assertTrue(hasCauseMessage(error, "choose an explicit H2 driver profile"));
|
||||
|
||||
ConnectParams explicitParams = profileParams("h2-v3", "file:" + base + ";CIPHER=AES;IFEXISTS=TRUE");
|
||||
explicitParams.setPassword("file-secret user-secret");
|
||||
H2Agent detected = new H2Agent();
|
||||
detected.connect(explicitParams);
|
||||
try {
|
||||
Assertions.assertEquals(H2DriverVersion.V3, detected.driverVersion());
|
||||
Assertions.assertEquals(List.of("ENCRYPTED_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
|
||||
} finally {
|
||||
detected.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsCustomH2DriverFromExternalClasspath() throws Exception {
|
||||
Path helperJar = tempDirectory.resolve("helper.jar");
|
||||
try (JarOutputStream ignored = new JarOutputStream(Files.newOutputStream(helperJar))) {
|
||||
}
|
||||
Path driverJar = copyBundledDriver("h2-2.1.214.jar");
|
||||
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom;DB_CLOSE_DELAY=-1");
|
||||
params.setJdbc_driver_paths(List.of(helperJar.toString(), driverJar.toString()));
|
||||
params.setJdbc_driver_class("org.h2.Driver");
|
||||
|
||||
H2Agent agent = new H2Agent();
|
||||
agent.connect(params);
|
||||
try {
|
||||
Assertions.assertEquals(H2DriverVersion.CUSTOM, agent.driverVersion());
|
||||
Assertions.assertTrue(agent.getDatabaseInfo().get("driverVersion").startsWith("2.1.214"));
|
||||
agent.executeQuery("CREATE TABLE CUSTOM_DRIVER_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
|
||||
Assertions.assertEquals(List.of("CUSTOM_DRIVER_PROBE"), agent.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCustomH2DriverWithoutClasspath() {
|
||||
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-missing;DB_CLOSE_DELAY=-1");
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params));
|
||||
|
||||
Assertions.assertTrue(hasCauseMessage(error, "requires at least one JDBC JAR path"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingCustomH2DriverJar() {
|
||||
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-missing-file;DB_CLOSE_DELAY=-1");
|
||||
params.setJdbc_driver_paths(List.of(tempDirectory.resolve("missing-h2.jar").toString()));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params));
|
||||
|
||||
Assertions.assertTrue(hasCauseMessage(error, "Custom H2 JDBC JAR does not exist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCustomH2DriverClass() throws Exception {
|
||||
ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-invalid-class;DB_CLOSE_DELAY=-1");
|
||||
params.setJdbc_driver_paths(List.of(copyBundledDriver("h2-2.4.240.jar").toString()));
|
||||
params.setJdbc_driver_class("example.MissingH2Driver");
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params));
|
||||
|
||||
Assertions.assertTrue(hasCauseMessage(error, "example.MissingH2Driver"));
|
||||
}
|
||||
|
||||
private void assertBundledDriver(String profile, H2DriverVersion expected, String expectedVersion) {
|
||||
ConnectParams params = profileParams(profile, "mem:dbx-" + profile + ";DB_CLOSE_DELAY=-1");
|
||||
H2Agent agent = new H2Agent();
|
||||
agent.connect(params);
|
||||
try {
|
||||
Assertions.assertEquals(expected, agent.driverVersion());
|
||||
Assertions.assertTrue(agent.getDatabaseInfo().get("driverVersion").startsWith(expectedVersion));
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertAutoDetectedFileDriver(H2DriverVersion expected) {
|
||||
Path base = tempDirectory.resolve("format-" + expected.storageFormat());
|
||||
ConnectParams createParams = profileParams(expected.profile(), "file:" + base);
|
||||
H2Agent creator = new H2Agent();
|
||||
creator.connect(createParams);
|
||||
try {
|
||||
creator.executeQuery("CREATE TABLE VERSION_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions());
|
||||
} finally {
|
||||
creator.disconnect();
|
||||
}
|
||||
|
||||
ConnectParams autoParams = profileParams("h2", "file:" + base + ";IFEXISTS=TRUE");
|
||||
H2Agent detected = new H2Agent();
|
||||
detected.connect(autoParams);
|
||||
try {
|
||||
Assertions.assertEquals(expected, detected.driverVersion());
|
||||
Assertions.assertEquals(List.of("VERSION_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList());
|
||||
} finally {
|
||||
detected.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static ConnectParams profileParams(String profile, String database) {
|
||||
ConnectParams params = new ConnectParams("", 0, database, "sa", "", "", "", false);
|
||||
params.setDriver_profile(profile);
|
||||
return params;
|
||||
}
|
||||
|
||||
private Path copyBundledDriver(String name) throws Exception {
|
||||
Path target = tempDirectory.resolve(name);
|
||||
try (InputStream input = H2AgentMigrationTest.class.getResourceAsStream("/drivers/" + name)) {
|
||||
Assertions.assertNotNull(input, "Missing bundled test driver " + name);
|
||||
Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static boolean hasCauseMessage(Throwable error, String expected) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
if (current.getMessage() != null && current.getMessage().contains(expected)) {
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void assertH2DatabaseInfo(Map<String, String> info) {
|
||||
Assertions.assertEquals("H2", info.get("productName"));
|
||||
Assertions.assertFalse(info.get("productVersion").isEmpty());
|
||||
@@ -73,6 +354,60 @@ class H2AgentMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
class H2VersionMatrixTest {
|
||||
@Test
|
||||
void metadataAndExecutionWorkAcrossBundledVersions() {
|
||||
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
|
||||
H2Agent agent = connect(profile);
|
||||
try {
|
||||
agent.executeQuery("CREATE TABLE PARENT_TABLE (ID INT PRIMARY KEY, NAME VARCHAR(64))", null, new ExecuteQueryOptions());
|
||||
agent.executeQuery(
|
||||
"CREATE TABLE CHILD_TABLE (ID INT PRIMARY KEY, PARENT_ID INT, AMOUNT DECIMAL(12, 2), "
|
||||
+ "CONSTRAINT FK_CHILD_PARENT FOREIGN KEY (PARENT_ID) REFERENCES PARENT_TABLE(ID))",
|
||||
null,
|
||||
new ExecuteQueryOptions()
|
||||
);
|
||||
agent.executeQuery("CREATE INDEX IDX_CHILD_AMOUNT ON CHILD_TABLE(AMOUNT)", null, new ExecuteQueryOptions());
|
||||
agent.executeQuery("CREATE VIEW CHILD_VIEW AS SELECT ID, AMOUNT FROM CHILD_TABLE", null, new ExecuteQueryOptions());
|
||||
agent.executeQuery("CREATE ALIAS DBX_REVERSE FOR \"java.lang.Integer.reverse\"", null, new ExecuteQueryOptions());
|
||||
agent.executeQuery("INSERT INTO PARENT_TABLE VALUES (1, 'parent')", null, new ExecuteQueryOptions());
|
||||
agent.executeQuery("INSERT INTO CHILD_TABLE VALUES (1, 1, 12.34)", null, new ExecuteQueryOptions());
|
||||
|
||||
Assertions.assertTrue(agent.listSchemas().contains("PUBLIC"), profile);
|
||||
Assertions.assertTrue(agent.listTables("PUBLIC").stream().map(TableInfo::getName).toList().containsAll(List.of("PARENT_TABLE", "CHILD_TABLE", "CHILD_VIEW")), profile);
|
||||
|
||||
List<ObjectInfo> objects = agent.listObjects("PUBLIC");
|
||||
Assertions.assertTrue(objects.stream().anyMatch(object -> object.getName().equals("DBX_REVERSE")), profile);
|
||||
Assertions.assertTrue(agent.getObjectSource("PUBLIC", "CHILD_VIEW", "VIEW").getSource().contains("CHILD_TABLE"), profile);
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("PUBLIC", "CHILD_TABLE");
|
||||
Assertions.assertEquals(List.of("ID", "PARENT_ID", "AMOUNT"), columns.stream().map(ColumnInfo::getName).toList(), profile);
|
||||
Assertions.assertTrue(columns.stream().filter(column -> column.getName().equals("ID")).findFirst().orElseThrow().getIs_primary_key(), profile);
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("PUBLIC", "CHILD_TABLE");
|
||||
Assertions.assertTrue(indexes.stream().anyMatch(index -> index.getName().equals("IDX_CHILD_AMOUNT")), profile);
|
||||
Assertions.assertTrue(indexes.stream().anyMatch(IndexInfo::getIs_primary), profile);
|
||||
|
||||
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("PUBLIC", "CHILD_TABLE");
|
||||
Assertions.assertTrue(foreignKeys.stream().anyMatch(key -> key.getName().equals("FK_CHILD_PARENT")), profile);
|
||||
|
||||
QueryResult result = agent.executeQuery("SELECT DBX_REVERSE(1), AMOUNT FROM CHILD_TABLE", null, new ExecuteQueryOptions());
|
||||
Assertions.assertEquals(1, result.getRows().size(), profile);
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static H2Agent connect(String profile) {
|
||||
ConnectParams params = new ConnectParams("", 0, "mem:matrix-" + profile + ";DB_CLOSE_DELAY=-1", "sa", "", "", "", false);
|
||||
params.setDriver_profile(profile);
|
||||
H2Agent agent = new H2Agent();
|
||||
agent.connect(params);
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
|
||||
class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest {
|
||||
@Override
|
||||
protected DatabaseAgent createConnectedAgent(String databaseName) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hive JDBC to Go migration parity
|
||||
|
||||
Status date: 2026-08-11.
|
||||
Status date: 2026-08-12.
|
||||
|
||||
The migration is not complete until a capability is implemented, covered by
|
||||
automated tests, validated against a real compatible server, and included in
|
||||
@@ -14,8 +14,10 @@ production parity.
|
||||
JDBC profiles. The Go migration must not infer Hive 2 support from Hive 3/4.
|
||||
- DBX protocol baseline: the Go Agent implements the same stdin/stdout JSON-RPC
|
||||
methods used by the Java Agent.
|
||||
- HS2 client protocol: Go requests `HIVE_CLI_SERVICE_PROTOCOL_V10`; Hive 3.1.3
|
||||
and Hive 4.2.0 accepted it in live validation.
|
||||
- HS2 client protocol: Go requests `HIVE_CLI_SERVICE_PROTOCOL_V6`, matching the
|
||||
upstream GoHive compatibility baseline. Hive 3.1.3 and Hive 4.2.0 accepted
|
||||
the newer V10 request in earlier live validation, but older Hive-compatible
|
||||
servers can reject that unknown enum value during `OpenSession` decoding.
|
||||
- The current completion pass validates the Go Agent only. JDBC is retained as
|
||||
a historical behavior reference, not as a candidate in the secure discovery
|
||||
or Kerberos validation path.
|
||||
|
||||
@@ -20,15 +20,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHivePort = 10000
|
||||
defaultHiveDatabase = "default"
|
||||
defaultHiveHTTPPath = "cliservice"
|
||||
defaultHiveService = "hive"
|
||||
defaultZooKeeperNamespace = "hiveserver2"
|
||||
defaultConnectTimeout = 15 * time.Second
|
||||
defaultRetryInterval = time.Second
|
||||
defaultBrowserSSOTimeout = 120 * time.Second
|
||||
defaultCookieName = "hive.server2.auth"
|
||||
defaultHivePort = 10000
|
||||
defaultHiveDatabase = "default"
|
||||
defaultHiveHTTPPath = "cliservice"
|
||||
defaultHiveService = "hive"
|
||||
defaultImpalaService = "impala"
|
||||
defaultZooKeeperNamespace = "hiveserver2"
|
||||
resultSetUniqueColumnNames = "hive.resultset.use.unique.column.names"
|
||||
defaultConnectTimeout = 15 * time.Second
|
||||
defaultRetryInterval = time.Second
|
||||
defaultBrowserSSOTimeout = 120 * time.Second
|
||||
defaultCookieName = "hive.server2.auth"
|
||||
)
|
||||
|
||||
type connectParams struct {
|
||||
@@ -46,6 +48,7 @@ type connectParams struct {
|
||||
ConnectTimeout int `json:"connect_timeout_secs"`
|
||||
AgentJavaOptions []string `json:"agent_java_options"`
|
||||
SessionRole string `json:"sessionRole"`
|
||||
DatabaseType string `json:"database_type"`
|
||||
}
|
||||
|
||||
type endpoint struct {
|
||||
@@ -94,6 +97,7 @@ type zooKeeperKerberosConfig struct {
|
||||
}
|
||||
|
||||
type connectionConfig struct {
|
||||
DatabaseType string
|
||||
Endpoints []endpoint
|
||||
Database string
|
||||
Username string
|
||||
@@ -136,7 +140,9 @@ type connectionConfig struct {
|
||||
}
|
||||
|
||||
func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
hasStructuredEndpoint := strings.TrimSpace(params.Host) != ""
|
||||
config := connectionConfig{
|
||||
DatabaseType: strings.ToLower(strings.TrimSpace(params.DatabaseType)),
|
||||
Database: strings.TrimSpace(params.Database),
|
||||
Username: params.Username,
|
||||
Password: params.Password,
|
||||
@@ -164,8 +170,9 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
CanonicalHostname: true,
|
||||
},
|
||||
}
|
||||
if config.Database == "" {
|
||||
config.Database = defaultHiveDatabase
|
||||
if strings.EqualFold(params.DatabaseType, "impala") {
|
||||
config.Auth = "NOSASL"
|
||||
config.Kerberos.Service = defaultImpalaService
|
||||
}
|
||||
if params.ConnectTimeout > 0 {
|
||||
config.ConnectTimeout = time.Duration(params.ConnectTimeout) * time.Second
|
||||
@@ -175,45 +182,60 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
if err != nil {
|
||||
return connectionConfig{}, err
|
||||
}
|
||||
if len(parsed.endpoints) > 0 {
|
||||
config.Endpoints = parsed.endpoints
|
||||
if !hasStructuredEndpoint {
|
||||
if config.Database == "" && parsed.database != "" {
|
||||
config.Database = parsed.database
|
||||
}
|
||||
if config.Username == "" && parsed.username != "" {
|
||||
config.Username = parsed.username
|
||||
}
|
||||
if config.Password == "" && parsed.password != "" {
|
||||
config.Password = parsed.password
|
||||
}
|
||||
}
|
||||
if config.Database == "" {
|
||||
config.Database = defaultHiveDatabase
|
||||
}
|
||||
|
||||
urlSections := parseHiveParameterSections(params.URLParams)
|
||||
values := urlSections.session
|
||||
hiveConfs := urlSections.hiveConfs
|
||||
hiveVars := urlSections.hiveVars
|
||||
if hasStructuredEndpoint {
|
||||
deleteHiveParameters(values, "user", "username", "password", "ssl")
|
||||
} else {
|
||||
values = mergeHiveParameters(parsed.parameters, values)
|
||||
hiveConfs = mergeHiveConfAssignments(parsed.hiveConfs, hiveConfs)
|
||||
hiveVars = mergeHiveAssignments(parsed.hiveVars, hiveVars)
|
||||
if value, exists := firstParameter(values, "user", "username"); exists {
|
||||
config.Username = value
|
||||
}
|
||||
if value, exists := firstParameter(values, "password"); exists {
|
||||
config.Password = value
|
||||
}
|
||||
}
|
||||
if err := applyHiveParameters(&config, values, hiveConfs); err != nil {
|
||||
return connectionConfig{}, err
|
||||
}
|
||||
if isZooKeeperDiscovery(config.ServiceDiscoveryMode) && len(parsed.endpoints) > 0 {
|
||||
// ZooKeeper discovery needs the complete endpoint list from the JDBC URL.
|
||||
config.Endpoints = parsed.endpoints
|
||||
} else if host := strings.TrimSpace(params.Host); host != "" {
|
||||
// DBX resolves edits and transport layers before invoking the Agent. For
|
||||
// direct connections that resolved endpoint must win over the persisted URL.
|
||||
port := params.Port
|
||||
if port <= 0 {
|
||||
port = defaultHivePort
|
||||
}
|
||||
if host := strings.TrimSpace(params.Host); host != "" {
|
||||
for _, value := range splitEndpoints(host) {
|
||||
parsedEndpoint, endpointErr := parseEndpoint(value, port)
|
||||
if endpointErr != nil {
|
||||
return connectionConfig{}, endpointErr
|
||||
}
|
||||
config.Endpoints = append(config.Endpoints, parsedEndpoint)
|
||||
for _, value := range splitEndpoints(host) {
|
||||
parsedEndpoint, endpointErr := parseEndpoint(value, port)
|
||||
if endpointErr != nil {
|
||||
return connectionConfig{}, endpointErr
|
||||
}
|
||||
config.Endpoints = append(config.Endpoints, parsedEndpoint)
|
||||
}
|
||||
}
|
||||
if parsed.database != "" {
|
||||
config.Database = parsed.database
|
||||
}
|
||||
if parsed.username != "" {
|
||||
config.Username = parsed.username
|
||||
}
|
||||
if parsed.password != "" {
|
||||
config.Password = parsed.password
|
||||
}
|
||||
|
||||
urlSections := parseHiveParameterSections(params.URLParams)
|
||||
values := mergeHiveParameters(parsed.parameters, urlSections.session)
|
||||
hiveConfs := mergeHiveAssignments(parsed.hiveConfs, urlSections.hiveConfs)
|
||||
hiveVars := mergeHiveAssignments(parsed.hiveVars, urlSections.hiveVars)
|
||||
if value, exists := firstParameter(values, "user", "username"); exists {
|
||||
config.Username = value
|
||||
}
|
||||
if value, exists := firstParameter(values, "password"); exists {
|
||||
config.Password = value
|
||||
}
|
||||
if err := applyHiveParameters(&config, values, hiveConfs); err != nil {
|
||||
return connectionConfig{}, err
|
||||
} else {
|
||||
config.Endpoints = parsed.endpoints
|
||||
}
|
||||
applyOpenSessionVariables(&config, values, hiveConfs, hiveVars)
|
||||
if err := applyDelegationToken(&config, values); err != nil {
|
||||
@@ -242,6 +264,10 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func isZooKeeperDiscovery(mode string) bool {
|
||||
return strings.EqualFold(mode, "zookeeper") || strings.EqualFold(mode, "zookeeperha")
|
||||
}
|
||||
|
||||
type parsedHiveConnection struct {
|
||||
endpoints []endpoint
|
||||
database string
|
||||
@@ -465,6 +491,17 @@ func setCaseInsensitive(values map[string]string, key, value string) {
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
func deleteHiveParameters(values map[string]string, keys ...string) {
|
||||
for existing := range values {
|
||||
for _, key := range keys {
|
||||
if strings.EqualFold(existing, key) {
|
||||
delete(values, existing)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHiveAssignments(first, second map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(first)+len(second))
|
||||
for key, value := range first {
|
||||
@@ -476,6 +513,17 @@ func mergeHiveAssignments(first, second map[string]string) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeHiveConfAssignments(first, second map[string]string) map[string]string {
|
||||
result := make(map[string]string, len(first)+len(second))
|
||||
for key, value := range first {
|
||||
result[canonicalHiveConfKey(key)] = value
|
||||
}
|
||||
for key, value := range second {
|
||||
result[canonicalHiveConfKey(key)] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]string) error {
|
||||
if value := parameter(values, "auth"); value != "" {
|
||||
config.Auth = strings.ToUpper(value)
|
||||
@@ -584,7 +632,7 @@ func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]
|
||||
parameter(values, "clientprincipal"),
|
||||
parameter(values, "userprincipal"),
|
||||
)
|
||||
kerberos.Service = firstNonEmpty(parameter(values, "service"), serviceFromPrincipal(kerberos.ServerPrincipal), defaultHiveService)
|
||||
kerberos.Service = firstNonEmpty(parameter(values, "service"), serviceFromPrincipal(kerberos.ServerPrincipal), kerberos.Service)
|
||||
kerberos.ServerName = parameter(values, "servername")
|
||||
kerberos.Realm = firstNonEmpty(parameter(values, "realm"), realmFromPrincipal(kerberos.ClientPrincipal))
|
||||
kerberos.ConfigPath = firstNonEmpty(parameter(values, "krb5conf"), parameter(values, "kerberosconfig"))
|
||||
@@ -635,11 +683,12 @@ func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]
|
||||
}
|
||||
|
||||
func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hiveVars map[string]string) {
|
||||
config.HiveConfiguration["set:hiveconf:"+resultSetUniqueColumnNames] = "false"
|
||||
for key, value := range values {
|
||||
lowerKey := strings.ToLower(key)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerKey, "hiveconf:"):
|
||||
config.HiveConfiguration["set:hiveconf:"+key[len("hiveconf:"):]] = value
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key[len("hiveconf:"):])] = value
|
||||
case strings.HasPrefix(lowerKey, "hivevar:"):
|
||||
config.HiveConfiguration["set:hivevar:"+key[len("hivevar:"):]] = value
|
||||
}
|
||||
@@ -648,7 +697,7 @@ func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hive
|
||||
if strings.EqualFold(key, "hive.server2.transport.mode") || strings.EqualFold(key, "hive.server2.thrift.http.path") {
|
||||
continue
|
||||
}
|
||||
config.HiveConfiguration["set:hiveconf:"+key] = value
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key)] = value
|
||||
}
|
||||
for key, value := range hiveVars {
|
||||
config.HiveConfiguration["set:hivevar:"+key] = value
|
||||
@@ -667,6 +716,13 @@ func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hive
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalHiveConfKey(key string) string {
|
||||
if strings.EqualFold(key, resultSetUniqueColumnNames) {
|
||||
return resultSetUniqueColumnNames
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func hiveAssignmentValue(values map[string]string, key string) string {
|
||||
for candidate, value := range values {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), key) {
|
||||
|
||||
@@ -176,6 +176,166 @@ func TestURLParamsOverrideConnectionString(t *testing.T) {
|
||||
if config.TransportMode != "http" || config.HTTPPath != "proxy" {
|
||||
t.Fatalf("URL params did not override connection string: %#v", config)
|
||||
}
|
||||
if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "hive.example.com", Port: 10000}) {
|
||||
t.Fatalf("resolved form endpoint did not override connection string: %#v", config.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedDirectEndpointAndDatabasePreserveJDBCParameterSections(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
Host: "127.0.0.1",
|
||||
Port: 18080,
|
||||
Database: "analytics",
|
||||
ConnectionString: "jdbc:hive2://old.example.com:10000/default;transportMode=http;httpPath=gateway?hive.exec.dynamic.partition=true#SourceTable=events",
|
||||
URLParams: "transportMode=http;httpPath=gateway?hive.exec.dynamic.partition=true#SourceTable=events",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "127.0.0.1", Port: 18080}) {
|
||||
t.Fatalf("unexpected resolved endpoint: %#v", config.Endpoints)
|
||||
}
|
||||
if config.Database != "analytics" {
|
||||
t.Fatalf("resolved database = %q, want analytics", config.Database)
|
||||
}
|
||||
if config.TransportMode != "http" || config.HTTPPath != "gateway" {
|
||||
t.Fatalf("JDBC session parameters were not preserved: %#v", config)
|
||||
}
|
||||
if config.HiveConfiguration["set:hiveconf:hive.exec.dynamic.partition"] != "true" {
|
||||
t.Fatalf("JDBC hiveConfs were not preserved: %#v", config.HiveConfiguration)
|
||||
}
|
||||
if config.HiveConfiguration["set:hivevar:SourceTable"] != "events" {
|
||||
t.Fatalf("JDBC hiveVars were not preserved: %#v", config.HiveConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredFieldsOverridePersistedJDBCValues(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
Host: "127.0.0.1",
|
||||
Port: 18080,
|
||||
Username: "edited-user",
|
||||
Password: "edited-password",
|
||||
ConnectionString: "jdbc:hive2://old-user:old-password@old.example.com:10000/old_database;transportMode=http;auth=LDAP;ssl=true;serviceDiscoveryMode=zooKeeper?hive.exec.dynamic.partition=true#SourceTable=events",
|
||||
URLParams: "user=url-user;password=url-password;ssl=true?hive.exec.dynamic.partition=true#SourceTable=events",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "127.0.0.1", Port: 18080}) {
|
||||
t.Fatalf("unexpected resolved endpoint: %#v", config.Endpoints)
|
||||
}
|
||||
if config.Database != defaultHiveDatabase {
|
||||
t.Fatalf("database = %q, want %q", config.Database, defaultHiveDatabase)
|
||||
}
|
||||
if config.Username != "edited-user" || config.Password != "edited-password" {
|
||||
t.Fatalf("structured credentials were overwritten: username=%q password=%q", config.Username, config.Password)
|
||||
}
|
||||
if config.TransportMode != "binary" || config.Auth != "NONE" || config.ServiceDiscoveryMode != "" {
|
||||
t.Fatalf("removed JDBC parameters remained active: %#v", config)
|
||||
}
|
||||
if config.TLSConfig != nil {
|
||||
t.Fatal("disabled structured SSL was re-enabled by persisted JDBC parameters")
|
||||
}
|
||||
if config.HiveConfiguration["set:hiveconf:hive.exec.dynamic.partition"] != "true" {
|
||||
t.Fatalf("current hiveConfs were not preserved: %#v", config.HiveConfiguration)
|
||||
}
|
||||
if config.HiveConfiguration["set:hivevar:SourceTable"] != "events" {
|
||||
t.Fatalf("current hiveVars were not preserved: %#v", config.HiveConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionStringOnlyKeepsJDBCValues(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
ConnectionString: "jdbc:hive2://raw-user:raw-password@hive.example.com:10001/analytics;transportMode=http;ssl=true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Database != "analytics" || config.Username != "raw-user" || config.Password != "raw-password" {
|
||||
t.Fatalf("connection-string-only fields were not preserved: %#v", config)
|
||||
}
|
||||
if config.TransportMode != "http" || config.TLSConfig == nil {
|
||||
t.Fatalf("connection-string-only parameters were not preserved: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZooKeeperDiscoveryKeepsAllJDBCEndpoints(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
Host: "127.0.0.1",
|
||||
Port: 12181,
|
||||
Database: "analytics",
|
||||
ConnectionString: "jdbc:hive2://zk1.example.com:2181,zk2.example.com:2181/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2",
|
||||
URLParams: "serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Endpoints) != 2 || config.Endpoints[0].Host != "zk1.example.com" || config.Endpoints[1].Host != "zk2.example.com" {
|
||||
t.Fatalf("ZooKeeper discovery endpoints were not preserved: %#v", config.Endpoints)
|
||||
}
|
||||
if config.Database != "analytics" {
|
||||
t.Fatalf("resolved database = %q, want analytics", config.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImpalaDefaultsToNoSASL(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
DatabaseType: "impala",
|
||||
Host: "impala.example.com",
|
||||
Port: 21050,
|
||||
Database: "analytics",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Auth != "NOSASL" {
|
||||
t.Fatalf("unexpected Impala auth mode: %q", config.Auth)
|
||||
}
|
||||
if config.Kerberos.Service != "impala" {
|
||||
t.Fatalf("unexpected Impala Kerberos service: %q", config.Kerberos.Service)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImpalaExplicitAuthenticationOverridesDefaults(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
DatabaseType: "impala",
|
||||
Host: "impala.example.com",
|
||||
Port: 21050,
|
||||
URLParams: "auth=NONE;service=custom",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Auth != "NONE" || config.Kerberos.Service != "custom" {
|
||||
t.Fatalf("explicit Impala authentication was not preserved: %#v", config.Kerberos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImpalaLDAPHTTPSSLConfiguration(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
DatabaseType: "impala",
|
||||
Host: "impala.example.com",
|
||||
Port: 21050,
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
URLParams: "auth=LDAP;transportMode=http;httpPath=cliservice",
|
||||
SSL: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Auth != "LDAP" || config.TransportMode != "http" || config.HTTPPath != "cliservice" {
|
||||
t.Fatalf("unexpected Impala LDAP transport config: %#v", config)
|
||||
}
|
||||
if config.Username != "alice" || config.Password != "secret" {
|
||||
t.Fatalf("Impala LDAP credentials were not preserved: %q / %q", config.Username, config.Password)
|
||||
}
|
||||
if config.TLSConfig == nil || config.TLSConfig.ServerName != "impala.example.com" {
|
||||
t.Fatalf("unexpected Impala LDAP TLS config: %#v", config.TLSConfig)
|
||||
}
|
||||
if config.Kerberos.Service != defaultImpalaService {
|
||||
t.Fatalf("unexpected Impala service default: %q", config.Kerberos.Service)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandardJDBCURLSectionsAndCredentials(t *testing.T) {
|
||||
@@ -193,8 +353,9 @@ func TestParseStandardJDBCURLSectionsAndCredentials(t *testing.T) {
|
||||
t.Fatalf("deprecated Hive conf transport settings were not applied: %#v", config)
|
||||
}
|
||||
want := map[string]string{
|
||||
"set:hiveconf:hive.exec.dynamic.partition": "false",
|
||||
"set:hivevar:SourceTable": "override",
|
||||
"set:hiveconf:hive.resultset.use.unique.column.names": "false",
|
||||
"set:hiveconf:hive.exec.dynamic.partition": "false",
|
||||
"set:hivevar:SourceTable": "override",
|
||||
}
|
||||
if !reflect.DeepEqual(config.HiveConfiguration, want) {
|
||||
t.Fatalf("unexpected OpenSession configuration: %#v", config.HiveConfiguration)
|
||||
@@ -210,17 +371,95 @@ func TestOpenSessionCompatibilityVariablesFromSessionParams(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"hive.server2.proxy.user": "alice",
|
||||
"set:hiveconf:hive.create.as.external.legacy": "true",
|
||||
"set:hiveconf:hive.exec.compress.output": "true",
|
||||
"set:hivevar:source": "events",
|
||||
"set:hivevar:wmpool": "etl",
|
||||
"hive.server2.proxy.user": "alice",
|
||||
"set:hiveconf:hive.create.as.external.legacy": "true",
|
||||
"set:hiveconf:hive.exec.compress.output": "true",
|
||||
"set:hiveconf:hive.resultset.use.unique.column.names": "false",
|
||||
"set:hivevar:source": "events",
|
||||
"set:hivevar:wmpool": "etl",
|
||||
}
|
||||
if !reflect.DeepEqual(config.HiveConfiguration, want) {
|
||||
t.Fatalf("unexpected OpenSession compatibility variables: %#v", config.HiveConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSessionUsesLeafResultLabelsUnlessExplicitlyOverridden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connection string
|
||||
urlParams string
|
||||
want string
|
||||
}{
|
||||
{name: "default", want: "false"},
|
||||
{
|
||||
name: "JDBC hiveconf override",
|
||||
connection: "jdbc:hive2://hs2.example.com:10000/default?hive.resultset.use.unique.column.names=true",
|
||||
want: "true",
|
||||
},
|
||||
{
|
||||
name: "URL hiveconf override",
|
||||
urlParams: "?HIVE.RESULTSET.USE.UNIQUE.COLUMN.NAMES=true",
|
||||
want: "true",
|
||||
},
|
||||
{
|
||||
name: "URL hiveconf wins over JDBC hiveconf",
|
||||
connection: "jdbc:hive2://hs2.example.com:10000/default?hive.resultset.use.unique.column.names=true",
|
||||
urlParams: "?hive.resultset.use.unique.column.names=false",
|
||||
want: "false",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
params := connectParams{Host: "hs2.example.com", ConnectionString: test.connection, URLParams: test.urlParams}
|
||||
if test.connection != "" {
|
||||
params.Host = ""
|
||||
}
|
||||
config, err := parseConnectionConfig(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "set:hiveconf:hive.resultset.use.unique.column.names"
|
||||
if got := config.HiveConfiguration[key]; got != test.want {
|
||||
t.Fatalf("%s = %q, want %q; config=%#v", key, got, test.want, config.HiveConfiguration)
|
||||
}
|
||||
matches := 0
|
||||
for candidate := range config.HiveConfiguration {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches != 1 {
|
||||
t.Fatalf("result label hiveconf must appear exactly once: %#v", config.HiveConfiguration)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiveAssignmentMergeCanonicalizesOnlyResultLabelSetting(t *testing.T) {
|
||||
first := map[string]string{
|
||||
"CaseSensitive": "first",
|
||||
"HIVE.RESULTSET.USE.UNIQUE.COLUMN.NAMES": "true",
|
||||
}
|
||||
second := map[string]string{
|
||||
"casesensitive": "second",
|
||||
"hive.resultset.use.unique.column.names": "false",
|
||||
}
|
||||
|
||||
hiveConfs := mergeHiveConfAssignments(first, second)
|
||||
if got := hiveConfs[resultSetUniqueColumnNames]; got != "false" {
|
||||
t.Fatalf("result-label setting override = %q, want false; values=%#v", got, hiveConfs)
|
||||
}
|
||||
if got := len(hiveConfs); got != 3 {
|
||||
t.Fatalf("unrelated case-distinct Hive confs were collapsed: %#v", hiveConfs)
|
||||
}
|
||||
|
||||
hiveVars := mergeHiveAssignments(first, second)
|
||||
if got := len(hiveVars); got != 4 {
|
||||
t.Fatalf("case-distinct Hive variables were collapsed: %#v", hiveVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHiveJDBCClientCompatibilityOptions(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
initPath := filepath.Join(directory, "hive-init.sql")
|
||||
|
||||
@@ -89,6 +89,7 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
|
||||
BrowserResponsePort: config.BrowserResponsePort,
|
||||
BrowserResponseTimeout: config.BrowserResponseTimeout,
|
||||
BrowserDisableSSLCheck: config.BrowserDisableSSLCheck,
|
||||
WaitForNonQueryCompletion: waitsForNonQueryCompletion(config.DatabaseType),
|
||||
})
|
||||
},
|
||||
driver: &gohive.Driver{},
|
||||
@@ -97,6 +98,10 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
|
||||
}
|
||||
}
|
||||
|
||||
func waitsForNonQueryCompletion(databaseType string) bool {
|
||||
return strings.EqualFold(databaseType, "impala") || strings.EqualFold(databaseType, "kyuubi")
|
||||
}
|
||||
|
||||
func gssapiOptionsFromKerberos(config kerberosConfig) gosasl.GSSAPIOptions {
|
||||
return gosasl.GSSAPIOptions{
|
||||
ConfigPath: config.ConfigPath,
|
||||
|
||||
@@ -114,6 +114,15 @@ func TestNormalizeHiveAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitsForKyuubiAndImpalaNonQueryCompletion(t *testing.T) {
|
||||
if !waitsForNonQueryCompletion("kyuubi") || !waitsForNonQueryCompletion("KYUUBI") || !waitsForNonQueryCompletion("impala") {
|
||||
t.Fatal("Kyuubi and Impala must wait for asynchronous non-query operations")
|
||||
}
|
||||
if waitsForNonQueryCompletion("hive") {
|
||||
t.Fatal("Hive must retain its existing non-query behavior")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKerberosServiceForEndpoint(t *testing.T) {
|
||||
target := endpoint{Host: "hs2.example.com", Port: 10000, Principal: "hive/hs2.example.com@EXAMPLE.COM"}
|
||||
discovered := connectionConfig{Kerberos: kerberosConfig{Service: "hive"}}
|
||||
|
||||
@@ -431,7 +431,11 @@ func (server *server) dispatch(method string, params map[string]json.RawMessage)
|
||||
case "list_constraints", "list_partitions", "list_subpartitions":
|
||||
return []any{}, false, nil
|
||||
case "get_object_source":
|
||||
result, err := server.getTableDDL(stringParam(params, "schema"), firstNonEmpty(stringParam(params, "name"), stringParam(params, "table")))
|
||||
result, err := server.getObjectSource(
|
||||
stringParam(params, "schema"),
|
||||
firstNonEmpty(stringParam(params, "name"), stringParam(params, "table")),
|
||||
stringParam(params, "object_type"),
|
||||
)
|
||||
return result, false, err
|
||||
case "get_table_ddl":
|
||||
result, err := server.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
|
||||
|
||||
@@ -40,6 +40,13 @@ type objectInfo struct {
|
||||
Valid *bool `json:"valid,omitempty"`
|
||||
}
|
||||
|
||||
type objectSource struct {
|
||||
Name string `json:"name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
Schema *string `json:"schema"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type columnInfo struct {
|
||||
Name string `json:"name"`
|
||||
DataType string `json:"data_type"`
|
||||
@@ -197,6 +204,18 @@ func (server *server) connectionInfo() (map[string]any, error) {
|
||||
username = current
|
||||
}
|
||||
}
|
||||
productName := "Apache Hive"
|
||||
compatibilityMode := "hive"
|
||||
driverName := "DBX Hive Go Agent"
|
||||
if strings.EqualFold(server.params.DatabaseType, "kyuubi") {
|
||||
productName = "Apache Kyuubi"
|
||||
compatibilityMode = "kyuubi"
|
||||
driverName = "DBX Kyuubi Go Agent"
|
||||
} else if strings.EqualFold(server.params.DatabaseType, "impala") || strings.Contains(strings.ToLower(version), "impalad version") {
|
||||
productName = "Apache Impala"
|
||||
compatibilityMode = "impala"
|
||||
driverName = "DBX Impala Go Agent"
|
||||
}
|
||||
return map[string]any{
|
||||
"database": server.config.Database,
|
||||
"schema": server.config.Database,
|
||||
@@ -204,13 +223,13 @@ func (server *server) connectionInfo() (map[string]any, error) {
|
||||
"version": version,
|
||||
"sqlDialect": "HIVE",
|
||||
"identifierQuote": "`",
|
||||
"compatibilityMode": "hive",
|
||||
"compatibilityMode": compatibilityMode,
|
||||
"databaseInfo": map[string]string{
|
||||
"productName": "Apache Hive",
|
||||
"productName": productName,
|
||||
"productVersion": version,
|
||||
"unquotedIdentifierCase": "mixed",
|
||||
"quotedIdentifierCase": "mixed",
|
||||
"driverName": "DBX Hive Go Agent",
|
||||
"driverName": driverName,
|
||||
"driverVersion": "gohive-v2.1.0",
|
||||
},
|
||||
}, nil
|
||||
@@ -305,29 +324,119 @@ func (server *server) listTables(schema string, constraints metadataListConstrai
|
||||
sort.Slice(values, func(first, second int) bool { return values[first].Name < values[second].Name })
|
||||
return applyMetadataWindow(values, constraints.Offset, constraints.Limit), nil
|
||||
}
|
||||
fallbackStatement := "SHOW TABLES IN " + quoteHiveIdentifier(schema)
|
||||
if len(requestedTypes) > 0 && !containsString(requestedTypes, "TABLE") {
|
||||
fallbackStatement = "SHOW VIEWS IN " + quoteHiveIdentifier(schema)
|
||||
type fallbackQuery struct {
|
||||
operation string
|
||||
statement string
|
||||
objectType string
|
||||
}
|
||||
result, err := server.executeQuery(queryOptions{
|
||||
SQL: fallbackStatement,
|
||||
MaxRows: metadataQueryLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HiveServer2 metadata failed (%v); SHOW TABLES fallback failed: %w", metadataErr, err)
|
||||
fallbackQueries := make([]fallbackQuery, 0, 2)
|
||||
if containsString(requestedTypes, "TABLE") {
|
||||
fallbackQueries = append(fallbackQueries, fallbackQuery{
|
||||
operation: "SHOW TABLES",
|
||||
statement: "SHOW TABLES IN " + quoteHiveIdentifier(schema),
|
||||
objectType: "TABLE",
|
||||
})
|
||||
}
|
||||
values := make([]tableInfo, 0, len(result.Rows))
|
||||
for _, row := range result.Rows {
|
||||
name := showTablesRowName(result.Columns, row)
|
||||
if name == "" || !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
if containsString(requestedTypes, "VIEW") || containsString(requestedTypes, "MATERIALIZED VIEW") {
|
||||
fallbackQueries = append(fallbackQueries, fallbackQuery{
|
||||
operation: "SHOW VIEWS",
|
||||
statement: "SHOW VIEWS IN " + quoteHiveIdentifier(schema),
|
||||
objectType: "VIEW",
|
||||
})
|
||||
}
|
||||
objectsByName := make(map[string]tableInfo)
|
||||
tableFallbackSucceeded := false
|
||||
for _, fallback := range fallbackQueries {
|
||||
result, err := server.executeQuery(queryOptions{SQL: fallback.statement, MaxRows: metadataQueryLimit})
|
||||
if err != nil {
|
||||
// Older Hive and Impala versions can list tables but do not support SHOW VIEWS.
|
||||
// Keep the usable table result for mixed requests; explicit view requests still fail.
|
||||
if fallback.objectType == "VIEW" && tableFallbackSucceeded && showViewsUnsupported(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("HiveServer2 metadata failed (%v); %s fallback failed: %w", metadataErr, fallback.operation, err)
|
||||
}
|
||||
values = append(values, tableInfo{Name: name, TableType: "TABLE", Comment: nil})
|
||||
if fallback.objectType == "TABLE" {
|
||||
tableFallbackSucceeded = true
|
||||
}
|
||||
for _, row := range result.Rows {
|
||||
name := showTablesRowName(result.Columns, row)
|
||||
if name == "" || !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
}
|
||||
candidate := tableInfo{Name: name, TableType: fallback.objectType, Comment: nil}
|
||||
if existing, ok := objectsByName[name]; ok && existing.TableType == "VIEW" && candidate.TableType != "VIEW" {
|
||||
continue
|
||||
}
|
||||
objectsByName[name] = candidate
|
||||
}
|
||||
}
|
||||
values := make([]tableInfo, 0, len(objectsByName))
|
||||
for _, value := range objectsByName {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Slice(values, func(first, second int) bool { return values[first].Name < values[second].Name })
|
||||
return applyMetadataWindow(values, constraints.Offset, constraints.Limit), nil
|
||||
}
|
||||
|
||||
func showViewsUnsupported(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
operationalMarkers := []string{
|
||||
"permission",
|
||||
"access denied",
|
||||
"not authorized",
|
||||
"unauthorized",
|
||||
"authentication",
|
||||
"authorization",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"deadline exceeded",
|
||||
"cancelled",
|
||||
"canceled",
|
||||
"transport",
|
||||
"connection",
|
||||
"broken pipe",
|
||||
"network",
|
||||
}
|
||||
for _, marker := range operationalMarkers {
|
||||
if strings.Contains(message, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
explicitMarkers := []string{
|
||||
"unsupported",
|
||||
"not supported",
|
||||
"not implemented",
|
||||
"unknown statement",
|
||||
"unrecognized statement",
|
||||
}
|
||||
for _, marker := range explicitMarkers {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parseMarkers := []string{
|
||||
"parseexception",
|
||||
"parse error",
|
||||
"syntax error",
|
||||
"mismatched input",
|
||||
"cannot recognize input",
|
||||
"no viable alternative",
|
||||
}
|
||||
for _, marker := range parseMarkers {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (server *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
|
||||
if !acceptsHiveTable(constraints.ObjectTypes) {
|
||||
return []objectInfo{}, nil
|
||||
@@ -475,6 +584,19 @@ func (server *server) getTableDDL(schema, table string) (string, error) {
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
}
|
||||
|
||||
func (server *server) getObjectSource(schema, name, objectType string) (objectSource, error) {
|
||||
source, err := server.getTableDDL(schema, name)
|
||||
if err != nil {
|
||||
return objectSource{}, err
|
||||
}
|
||||
return objectSource{
|
||||
Name: name,
|
||||
ObjectType: strings.ToUpper(objectType),
|
||||
Schema: optionalString(schema),
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (server *server) getExplainInfo(sqlText string) (string, error) {
|
||||
sqlText = trimStatementSQL(sqlText)
|
||||
if sqlText == "" {
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/t8y2/dbx/agents/go-common/gohive"
|
||||
@@ -19,6 +21,77 @@ func TestShowTablesRowName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKyuubiConnectionInfoReportsNativeIdentity(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
switch query {
|
||||
case "SELECT VERSION()":
|
||||
return newScriptedRows(ctx, []string{"version"}, []string{"STRING"}, [][]driver.Value{{"3.5.8"}}), nil
|
||||
case "SELECT CURRENT_USER()":
|
||||
return newScriptedRows(ctx, []string{"current_user"}, []string{"STRING"}, [][]driver.Value{{"dbx"}}), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
server.params.DatabaseType = "kyuubi"
|
||||
server.config.Username = "fallback"
|
||||
|
||||
info, err := server.connectionInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info["compatibilityMode"] != "kyuubi" || info["username"] != "dbx" || info["version"] != "3.5.8" {
|
||||
t.Fatalf("unexpected Kyuubi connection info: %#v", info)
|
||||
}
|
||||
databaseInfo, ok := info["databaseInfo"].(map[string]string)
|
||||
if !ok || databaseInfo["productName"] != "Apache Kyuubi" || databaseInfo["driverName"] != "DBX Kyuubi Go Agent" {
|
||||
t.Fatalf("unexpected Kyuubi database identity: %#v", info["databaseInfo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectSourceReturnsProtocolObject(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
if query != "SHOW CREATE TABLE `dbx_kyuubi_demo`.`high_value_orders`" {
|
||||
t.Fatalf("unexpected query: %q", query)
|
||||
}
|
||||
return newScriptedRows(
|
||||
ctx,
|
||||
[]string{"createtab_stmt"},
|
||||
[]string{"STRING"},
|
||||
[][]driver.Value{
|
||||
{"CREATE VIEW dbx_kyuubi_demo.high_value_orders"},
|
||||
{"AS SELECT id, customer, amount FROM dbx_kyuubi_demo.orders WHERE amount >= 50"},
|
||||
},
|
||||
), nil
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
|
||||
result, _, err := server.dispatch("get_object_source", map[string]json.RawMessage{
|
||||
"schema": json.RawMessage(`"dbx_kyuubi_demo"`),
|
||||
"name": json.RawMessage(`"high_value_orders"`),
|
||||
"object_type": json.RawMessage(`"VIEW"`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source, ok := result.(objectSource)
|
||||
if !ok {
|
||||
t.Fatalf("get_object_source returned %T instead of objectSource", result)
|
||||
}
|
||||
if source.Name != "high_value_orders" || source.ObjectType != "VIEW" || source.Schema == nil || *source.Schema != "dbx_kyuubi_demo" {
|
||||
t.Fatalf("unexpected object source metadata: %#v", source)
|
||||
}
|
||||
expected := "CREATE VIEW dbx_kyuubi_demo.high_value_orders\nAS SELECT id, customer, amount FROM dbx_kyuubi_demo.orders WHERE amount >= 50\n"
|
||||
if source.Source != expected {
|
||||
t.Fatalf("unexpected object source DDL: %q", source.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDatabasesUsesShowDatabasesBeforeHiveServerMetadata(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
query: func(ctx context.Context, sql string) (driver.Rows, error) {
|
||||
@@ -135,7 +208,7 @@ func TestGetColumnsUsesHiveServerMetadataFields(t *testing.T) {
|
||||
}
|
||||
return metadataResult(
|
||||
[]string{"COLUMN_NAME", "TYPE_NAME", "COLUMN_SIZE", "DECIMAL_DIGITS", "NULLABLE", "REMARKS", "COLUMN_DEF"},
|
||||
[]driver.Value{"name", "string", int64(255), nil, int64(1), "display name", "unknown"},
|
||||
[]driver.Value{"name", "string", int64(255), nil, int64(1), "显示名称", "unknown"},
|
||||
[]driver.Value{"amount", "decimal(18,2)", int64(18), int64(2), int64(0), nil, nil},
|
||||
), nil
|
||||
},
|
||||
@@ -149,7 +222,7 @@ func TestGetColumnsUsesHiveServerMetadataFields(t *testing.T) {
|
||||
if len(values) != 2 {
|
||||
t.Fatalf("unexpected columns: %#v", values)
|
||||
}
|
||||
if values[0].Name != "name" || !values[0].IsNullable || values[0].CharacterMaximumLength == nil || *values[0].CharacterMaximumLength != 255 || values[0].ColumnDefault == nil || *values[0].ColumnDefault != "unknown" {
|
||||
if values[0].Name != "name" || !values[0].IsNullable || values[0].CharacterMaximumLength == nil || *values[0].CharacterMaximumLength != 255 || values[0].ColumnDefault == nil || *values[0].ColumnDefault != "unknown" || values[0].Comment == nil || *values[0].Comment != "显示名称" {
|
||||
t.Fatalf("unexpected string column: %#v", values[0])
|
||||
}
|
||||
if values[1].Name != "amount" || values[1].IsNullable || values[1].NumericPrecision == nil || *values[1].NumericPrecision != 18 || values[1].NumericScale == nil || *values[1].NumericScale != 2 || values[1].CharacterMaximumLength != nil {
|
||||
@@ -157,6 +230,33 @@ func TestGetColumnsUsesHiveServerMetadataFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetColumnsPreservesChineseDescribeFallbackComments(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getColumns: func(context.Context, string, string, string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unavailable")
|
||||
},
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
if query != "DESCRIBE `analytics`.`events`" {
|
||||
t.Fatalf("unexpected DESCRIBE query: %q", query)
|
||||
}
|
||||
return newScriptedRows(
|
||||
ctx,
|
||||
[]string{"col_name", "data_type", "comment"},
|
||||
[]string{"STRING", "STRING", "STRING"},
|
||||
[][]driver.Value{{"name", "string", "显示名称"}, {"amount", "decimal(18,2)", "含税金额"}},
|
||||
), nil
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
values, err := server.getColumns("analytics", "events")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(values) != 2 || values[0].Comment == nil || *values[0].Comment != "显示名称" || values[1].Comment == nil || *values[1].Comment != "含税金额" {
|
||||
t.Fatalf("DESCRIBE comments changed: %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableCommentAndTypeInfoUseHiveServerMetadata(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(_ context.Context, schema, table string, tableTypes []string) (gohive.MetadataResult, error) {
|
||||
@@ -184,7 +284,131 @@ func TestTableCommentAndTypeInfoUseHiveServerMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTablesFallsBackToShowTables(t *testing.T) {
|
||||
func TestListTablesFallsBackToShowTablesAndViews(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unsupported")
|
||||
},
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
switch query {
|
||||
case "SHOW TABLES IN `analytics`":
|
||||
return newScriptedRows(
|
||||
ctx,
|
||||
[]string{"tab_name"},
|
||||
[]string{"STRING"},
|
||||
[][]driver.Value{{"events"}, {"shared_name"}},
|
||||
), nil
|
||||
case "SHOW VIEWS IN `analytics`":
|
||||
return newScriptedRows(
|
||||
ctx,
|
||||
[]string{"view_name"},
|
||||
[]string{"STRING"},
|
||||
[][]driver.Value{{"events_view"}, {"shared_name"}},
|
||||
), nil
|
||||
default:
|
||||
t.Fatalf("unexpected fallback query: %q", query)
|
||||
return nil, errors.New("unexpected fallback query")
|
||||
}
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
values, err := server.listTables("analytics", metadataListConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := []tableInfo{
|
||||
{Name: "events", TableType: "TABLE"},
|
||||
{Name: "events_view", TableType: "VIEW"},
|
||||
{Name: "shared_name", TableType: "VIEW"},
|
||||
}
|
||||
if !reflect.DeepEqual(values, expected) {
|
||||
t.Fatalf("unexpected fallback tables: %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTablesKeepsShowTablesResultsWhenShowViewsIsUnsupported(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unsupported")
|
||||
},
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
switch query {
|
||||
case "SHOW TABLES IN `analytics`":
|
||||
return newScriptedRows(ctx, []string{"tab_name"}, []string{"STRING"}, [][]driver.Value{{"events"}}), nil
|
||||
case "SHOW VIEWS IN `analytics`":
|
||||
return nil, errors.New("SHOW VIEWS is unsupported")
|
||||
default:
|
||||
t.Fatalf("unexpected fallback query: %q", query)
|
||||
return nil, errors.New("unexpected fallback query")
|
||||
}
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
|
||||
values, err := server.listTables("analytics", metadataListConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(values, []tableInfo{{Name: "events", TableType: "TABLE"}}) {
|
||||
t.Fatalf("unexpected fallback tables: %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTablesReturnsNonCapabilityShowViewsError(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unsupported")
|
||||
},
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
switch query {
|
||||
case "SHOW TABLES IN `analytics`":
|
||||
return newScriptedRows(ctx, []string{"tab_name"}, []string{"STRING"}, [][]driver.Value{{"events"}}), nil
|
||||
case "SHOW VIEWS IN `analytics`":
|
||||
return nil, errors.New("permission denied for SHOW VIEWS")
|
||||
default:
|
||||
t.Fatalf("unexpected fallback query: %q", query)
|
||||
return nil, errors.New("unexpected fallback query")
|
||||
}
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
|
||||
_, err := server.listTables("analytics", metadataListConstraints{})
|
||||
if err == nil || !strings.Contains(err.Error(), "SHOW VIEWS fallback failed: permission denied") {
|
||||
t.Fatalf("unexpected mixed fallback error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowViewsUnsupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
unsupported bool
|
||||
}{
|
||||
{name: "explicit unsupported", err: errors.New("SHOW VIEWS is unsupported"), unsupported: true},
|
||||
{name: "not supported", err: errors.New("SHOW VIEWS is not supported before Hive 2.2"), unsupported: true},
|
||||
{name: "old parser", err: errors.New("ParseException: syntax error at or near VIEWS"), unsupported: true},
|
||||
{name: "permission", err: errors.New("permission denied for SHOW VIEWS")},
|
||||
{name: "timeout", err: context.DeadlineExceeded},
|
||||
{name: "cancel", err: context.Canceled},
|
||||
{name: "authentication", err: errors.New("authentication failed")},
|
||||
{name: "unsupported authentication", err: errors.New("unsupported authentication mechanism")},
|
||||
{name: "transport", err: errors.New("transport is closed")},
|
||||
{name: "unsupported transport", err: errors.New("transport does not support SASL")},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if actual := showViewsUnsupported(test.err); actual != test.unsupported {
|
||||
t.Fatalf("showViewsUnsupported(%v) = %v, want %v", test.err, actual, test.unsupported)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTablesFallbackHonorsExplicitTableType(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unsupported")
|
||||
@@ -198,7 +422,8 @@ func TestListTablesFallsBackToShowTables(t *testing.T) {
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
values, err := server.listTables("analytics", metadataListConstraints{})
|
||||
|
||||
values, err := server.listTables("analytics", metadataListConstraints{ObjectTypes: []string{"TABLE"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,6 +432,50 @@ func TestListTablesFallsBackToShowTables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListViewsFallsBackToShowViews(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unsupported")
|
||||
},
|
||||
query: func(ctx context.Context, query string) (driver.Rows, error) {
|
||||
if query != "SHOW VIEWS IN `analytics`" {
|
||||
t.Fatalf("unexpected fallback query: %q", query)
|
||||
}
|
||||
return newScriptedRows(ctx, []string{"view_name"}, []string{"STRING"}, [][]driver.Value{{"events_view"}}), nil
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
values, err := server.listTables("analytics", metadataListConstraints{ObjectTypes: []string{"VIEW"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(values, []tableInfo{{Name: "events_view", TableType: "VIEW"}}) {
|
||||
t.Fatalf("unexpected fallback views: %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListViewsReturnsFallbackErrorWhenShowViewsIsUnsupported(t *testing.T) {
|
||||
behavior := &scriptedBehavior{
|
||||
getTables: func(context.Context, string, string, []string) (gohive.MetadataResult, error) {
|
||||
return gohive.MetadataResult{}, errors.New("metadata unsupported")
|
||||
},
|
||||
query: func(_ context.Context, query string) (driver.Rows, error) {
|
||||
if query != "SHOW VIEWS IN `analytics`" {
|
||||
t.Fatalf("unexpected fallback query: %q", query)
|
||||
}
|
||||
return nil, errors.New("SHOW VIEWS is unsupported")
|
||||
},
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
defer server.disconnect()
|
||||
|
||||
_, err := server.listTables("analytics", metadataListConstraints{ObjectTypes: []string{"VIEW"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "SHOW VIEWS fallback failed") {
|
||||
t.Fatalf("unexpected explicit view fallback error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func metadataResult(columns []string, rows ...[]driver.Value) gohive.MetadataResult {
|
||||
return gohive.MetadataResult{Columns: columns, Rows: rows}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,72 @@ func TestExecuteQueryUsesHiveServerResultSetSignal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryResultsPreserveHiveServerLabelsWithoutDotSplitting(t *testing.T) {
|
||||
columns := []string{"id", "customer.id", "total + 1", "id"}
|
||||
columnTypes := []string{"BIGINT", "BIGINT", "DOUBLE", "BIGINT"}
|
||||
behavior := &scriptedBehavior{}
|
||||
behavior.query = func(ctx context.Context, _ string) (driver.Rows, error) {
|
||||
return newScriptedRows(ctx, columns, columnTypes, [][]driver.Value{{int64(1), int64(2), float64(3), int64(4)}, {int64(5), int64(6), float64(7), int64(8)}}), nil
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
|
||||
ordinary, err := server.executeQuery(queryOptions{SQL: "SELECT * FROM labels"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(ordinary.Columns, columns) || !reflect.DeepEqual(ordinary.ColumnTypes, []string{"bigint", "bigint", "double", "bigint"}) {
|
||||
t.Fatalf("ordinary metadata changed: %#v", ordinary)
|
||||
}
|
||||
if len(ordinary.Rows) != 2 || ordinary.Rows[0][0] != "1" || ordinary.Rows[0][3] != "4" {
|
||||
t.Fatalf("ordinary values changed: %#v", ordinary.Rows)
|
||||
}
|
||||
|
||||
first, err := server.executeQueryPage(queryOptions{SQL: "SELECT * FROM labels", MaxRows: 2}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SessionID == nil || !first.HasMore || !reflect.DeepEqual(first.Columns, columns) {
|
||||
t.Fatalf("first page metadata changed: %#v", first)
|
||||
}
|
||||
second, err := server.fetchQueryPage(*first.SessionID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.HasMore || second.SessionID != nil || !reflect.DeepEqual(second.Columns, columns) {
|
||||
t.Fatalf("cached page metadata changed: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedQueryPreservesDuplicateLeadingValuesAcrossPages(t *testing.T) {
|
||||
behavior := &scriptedBehavior{}
|
||||
behavior.query = func(ctx context.Context, _ string) (driver.Rows, error) {
|
||||
return newScriptedRows(
|
||||
ctx,
|
||||
[]string{"group_id", "row_id"},
|
||||
[]string{"BIGINT", "BIGINT"},
|
||||
[][]driver.Value{{int64(1), int64(101)}, {int64(1), int64(102)}, {int64(1), int64(103)}},
|
||||
), nil
|
||||
}
|
||||
server := newScriptedServer(t, behavior)
|
||||
|
||||
first, err := server.executeQueryPage(queryOptions{SQL: "SELECT group_id, row_id FROM repeated_values", MaxRows: 3}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SessionID == nil || !first.HasMore {
|
||||
t.Fatalf("expected an open cursor after the first page: %#v", first)
|
||||
}
|
||||
second, err := server.fetchQueryPage(*first.SessionID, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allRows := append(append([][]any{}, first.Rows...), second.Rows...)
|
||||
expected := [][]any{{"1", "101"}, {"1", "102"}, {"1", "103"}}
|
||||
if !reflect.DeepEqual(allRows, expected) || second.HasMore {
|
||||
t.Fatalf("duplicate leading values changed across cursor pages: %#v", allRows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedQueryTruncatesAndPreservesLegacyJDBCValueSemantics(t *testing.T) {
|
||||
largeValue := strings.Repeat("x", 256*1024)
|
||||
createdAt := time.Date(2026, time.August, 11, 10, 11, 12, 345000000, time.UTC)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'org.apache.hive:hive-jdbc:4.0.1:standalone'
|
||||
implementation 'org.slf4j:slf4j-nop:1.7.30'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
manifest {
|
||||
attributes('Agent-Label': 'Apache Hive', 'Main-Class': 'com.dbx.agent.hive.HiveAgent')
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package com.dbx.agent.hive;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.QueryPageResult;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class HiveAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
return "org.apache.hive.jdbc.HiveDriver";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return unchecked(() -> {
|
||||
List<DatabaseInfo> result = new ArrayList<>();
|
||||
try (java.sql.Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SHOW DATABASES")) {
|
||||
while (rs.next()) {
|
||||
result.add(new DatabaseInfo(rs.getString(1)));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(DatabaseInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (DatabaseInfo database : listDatabases()) {
|
||||
result.add(database.getName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
useSchema(schema);
|
||||
try (java.sql.Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SHOW TABLES")) {
|
||||
while (rs.next()) {
|
||||
result.add(new TableInfo(rs.getString(1), "TABLE", null));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(TableInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
try {
|
||||
return getColumnsFromDescribe(schema, table);
|
||||
} catch (Exception ignored) {
|
||||
return getColumnsFromMetadata(requireConnected(), schema, table);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String qualifiedName = schema == null || schema.trim().isEmpty()
|
||||
? JdbcIdentifiers.INSTANCE.backtick(table)
|
||||
: JdbcIdentifiers.INSTANCE.backtick(schema) + "." + JdbcIdentifiers.INSTANCE.backtick(table);
|
||||
try (Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SHOW CREATE TABLE " + qualifiedName)) {
|
||||
StringBuilder ddl = new StringBuilder();
|
||||
while (rs.next()) {
|
||||
String line = rs.getString(1);
|
||||
if (line != null) {
|
||||
ddl.append(line).append('\n');
|
||||
}
|
||||
}
|
||||
return ddl.toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setSchemaSQL(String schema) {
|
||||
return "USE " + JdbcIdentifiers.INSTANCE.backtick(schema);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
// Hive JDBC fetches rows via Thrift FetchResults in batches controlled
|
||||
// by fetchSize. A large fetchSize can fail with "Error retrieving next
|
||||
// row" when the result contains large binary/struct columns. Use a
|
||||
// small fetchSize (50) so each Thrift batch stays within limits.
|
||||
return super.executeQuery(sql, schema, new ExecuteQueryOptions(
|
||||
options.getMaxRows(),
|
||||
50,
|
||||
options.getTimeoutSecs()
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
|
||||
return super.executeQueryPage(sql, schema, withSmallFetchSize(options));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
|
||||
return super.startTableRead(sql, schema, withSmallFetchSize(options));
|
||||
}
|
||||
|
||||
private static QueryPageOptions withSmallFetchSize(QueryPageOptions options) {
|
||||
return new QueryPageOptions(
|
||||
options.getPageSize(), 50, options.getMaxRows(), options.getTimeoutSecs()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet rs, int index, int sqlType) {
|
||||
return unchecked(() -> {
|
||||
Object value = rs.getObject(index);
|
||||
return rs.wasNull() ? null : value == null ? null : value.toString();
|
||||
});
|
||||
}
|
||||
|
||||
private void useSchema(String schema) throws Exception {
|
||||
applySchemaContext(requireConnected(), schema);
|
||||
}
|
||||
|
||||
private List<ColumnInfo> getColumnsFromDescribe(String schema, String table) throws Exception {
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
useSchema(schema);
|
||||
try (java.sql.Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("DESCRIBE " + JdbcIdentifiers.INSTANCE.backtick(table))) {
|
||||
while (rs.next()) {
|
||||
String colName = trimToNull(rs.getString(1));
|
||||
if (colName == null || colName.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
result.add(new ColumnInfo(
|
||||
colName,
|
||||
trimToEmpty(rs.getString(2)),
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
optionalComment(rs),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<ColumnInfo> getColumnsFromMetadata(Connection conn, String schema, String table) throws Exception {
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
try (ResultSet rs = meta.getColumns(null, trimToNull(schema), table, "%")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("COLUMN_NAME");
|
||||
if (trimToNull(name) == null) {
|
||||
continue;
|
||||
}
|
||||
result.add(new ColumnInfo(
|
||||
name,
|
||||
trimToEmpty(rs.getString("TYPE_NAME")),
|
||||
rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls,
|
||||
rs.getString("COLUMN_DEF"),
|
||||
false,
|
||||
null,
|
||||
rs.getString("REMARKS"),
|
||||
intOrNull(rs, "COLUMN_SIZE"),
|
||||
intOrNull(rs, "DECIMAL_DIGITS"),
|
||||
characterLength(rs)
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String buildUrl(ConnectParams params) {
|
||||
String connectionString = trimToEmpty(params.getConnection_string());
|
||||
if (!connectionString.isEmpty()) {
|
||||
return connectionString;
|
||||
}
|
||||
String url = "jdbc:hive2://" + params.getHost() + ":" + params.getPort() + "/" + params.getDatabase();
|
||||
return appendHiveUrlParams(url, params.getUrl_params());
|
||||
}
|
||||
|
||||
private static String appendHiveUrlParams(String url, String urlParams) {
|
||||
String params = trimHiveUrlParams(urlParams);
|
||||
if (params.isEmpty()) {
|
||||
return url;
|
||||
}
|
||||
// Hive JDBC authentication options use semicolon-separated URL properties.
|
||||
return url + (url.endsWith(";") ? "" : ";") + params;
|
||||
}
|
||||
|
||||
private static String trimHiveUrlParams(String urlParams) {
|
||||
String value = trimToEmpty(urlParams);
|
||||
while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) {
|
||||
value = value.substring(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private static String trimToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value instanceof Number ? ((Number) value).intValue() : null;
|
||||
}
|
||||
|
||||
private static Integer characterLength(ResultSet rs) throws Exception {
|
||||
String typeName = rs.getString("TYPE_NAME");
|
||||
if (typeName == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = typeName.toLowerCase(Locale.ROOT);
|
||||
if (!normalized.contains("char") && !normalized.contains("text") && !normalized.contains("string")) {
|
||||
return null;
|
||||
}
|
||||
return intOrNull(rs, "COLUMN_SIZE");
|
||||
}
|
||||
|
||||
private static String optionalComment(ResultSet rs) {
|
||||
return unchecked(() -> {
|
||||
try {
|
||||
String comment = trimToNull(rs.getString(3));
|
||||
return comment;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(HiveAgent::new).run();
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.dbx.agent.hive;
|
||||
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.test.JdbcAgentFake;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class HiveAgentExecutionTest extends JdbcFakeExecutionBehaviorTest {
|
||||
@Override
|
||||
protected DatabaseAgent createAgent() {
|
||||
return new HiveAgent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resultSetSql() {
|
||||
return "MSCK REPAIR TABLE sample";
|
||||
}
|
||||
|
||||
// Override: HiveAgent.executeQuery forces fetchSize=50 to keep Thrift
|
||||
// FetchResults batches small (prevents "Error retrieving next row" on
|
||||
// tables with large binary/struct columns).
|
||||
@Override
|
||||
@Test
|
||||
protected void executesNonSelectStatementsThatReturnResultSets() {
|
||||
DatabaseAgent agent = createAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
|
||||
|
||||
QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions());
|
||||
|
||||
assertEquals(Collections.singletonList("VALUE"), result.getColumns());
|
||||
assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows());
|
||||
assertEquals(Arrays.asList("setMaxRows:10001", "setFetchSize:50", "execute"), JdbcAgentFake.calls);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Test
|
||||
protected void appliesExecutionRowAndFetchLimitsToJdbcStatements() {
|
||||
DatabaseAgent agent = createAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
|
||||
|
||||
QueryResult result = agent.executeQuery(resultSetSql(), null, new ExecuteQueryOptions(1, 25));
|
||||
|
||||
assertEquals(Collections.singletonList(Collections.singletonList("row-value")), result.getRows());
|
||||
assertEquals(Arrays.asList("setMaxRows:2", "setFetchSize:50", "execute"), JdbcAgentFake.calls);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Test
|
||||
protected void pagedQueryExecutionPassesCallerFetchSizeToStatement() {
|
||||
DatabaseAgent agent = createAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
|
||||
|
||||
agent.executeQueryPage(resultSetSql(), null, new QueryPageOptions(100, 500, 1000));
|
||||
|
||||
assertEquals(Arrays.asList("setFetchSize:50", "execute"), JdbcAgentFake.calls);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Test
|
||||
protected void tableReadPassesCallerFetchSizeToStatement() {
|
||||
DatabaseAgent agent = createAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcAgentFake.connection());
|
||||
|
||||
agent.startTableRead(resultSetSql(), null, new QueryPageOptions(100, 500, 1000));
|
||||
|
||||
assertEquals(Arrays.asList("setFetchSize:50", "execute"), JdbcAgentFake.calls);
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package com.dbx.agent.hive;
|
||||
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.dbx.agent.test.JdbcMetadataSqlFake;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.util.List;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HiveAgentMetadataTest {
|
||||
@Test
|
||||
void quotesSchemaAndTableIdentifiersInMetadataSql() {
|
||||
HiveAgent agent = new HiveAgent();
|
||||
Connection fake = JdbcMetadataSqlFake.connection();
|
||||
TestSupport.setPrivateConnection(agent, fake);
|
||||
|
||||
agent.listTables("bad`schema");
|
||||
agent.getColumns("bad`schema", "bad`table");
|
||||
|
||||
Assertions.assertEquals(
|
||||
List.of(
|
||||
"USE `bad``schema`",
|
||||
"SHOW TABLES",
|
||||
"USE `bad``schema`",
|
||||
"DESCRIBE `bad``table`"
|
||||
),
|
||||
JdbcMetadataSqlFake.statements
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToJdbcMetadataWhenDescribeCannotCreateResultSet() {
|
||||
HiveAgent agent = new HiveAgent();
|
||||
Connection fake = describeFailingMetadataConnection();
|
||||
TestSupport.setPrivateConnection(agent, fake);
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("default", "events");
|
||||
|
||||
Assertions.assertEquals(2, columns.size());
|
||||
Assertions.assertEquals("id", columns.get(0).getName());
|
||||
Assertions.assertEquals("bigint", columns.get(0).getData_type());
|
||||
Assertions.assertFalse(columns.get(0).getIs_nullable());
|
||||
Assertions.assertEquals("name", columns.get(1).getName());
|
||||
Assertions.assertEquals("string", columns.get(1).getData_type());
|
||||
Assertions.assertEquals(Integer.valueOf(255), columns.get(1).getCharacter_maximum_length());
|
||||
}
|
||||
|
||||
private static Connection describeFailingMetadataConnection() {
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getColumns".equals(method.getName())) {
|
||||
return rows(
|
||||
row(
|
||||
"COLUMN_NAME", "id",
|
||||
"TYPE_NAME", "bigint",
|
||||
"NULLABLE", DatabaseMetaData.columnNoNulls,
|
||||
"COLUMN_DEF", null,
|
||||
"REMARKS", "identifier",
|
||||
"COLUMN_SIZE", 19,
|
||||
"DECIMAL_DIGITS", 0
|
||||
),
|
||||
row(
|
||||
"COLUMN_NAME", "name",
|
||||
"TYPE_NAME", "string",
|
||||
"NULLABLE", DatabaseMetaData.columnNullable,
|
||||
"COLUMN_DEF", null,
|
||||
"REMARKS", null,
|
||||
"COLUMN_SIZE", 255,
|
||||
"DECIMAL_DIGITS", 0
|
||||
)
|
||||
);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("createStatement".equals(name)) {
|
||||
return describeFailingStatement();
|
||||
}
|
||||
if ("getMetaData".equals(name)) {
|
||||
return meta;
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Statement describeFailingStatement() {
|
||||
return proxy(Statement.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("execute".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
if ("executeQuery".equals(name)) {
|
||||
throw new SQLException("Could not create ResultSet: Required field 'type' is unset! Struct:TPrimitiveTypeEntry(type:null)");
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static ResultSet rows(Map<String, Object>... rows) {
|
||||
return proxy(ResultSet.class, new MethodHandler() {
|
||||
private int index = -1;
|
||||
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
String name = method.getName();
|
||||
if ("next".equals(name)) {
|
||||
index += 1;
|
||||
return index < rows.length;
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
Object value = rows[index].get(args[0]);
|
||||
if ("getString".equals(name)) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
if ("getObject".equals(name)) {
|
||||
return value;
|
||||
}
|
||||
if ("getInt".equals(name)) {
|
||||
return value instanceof Number ? ((Number) value).intValue() : 0;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(Object... values) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for (int i = 0; i < values.length; i += 2) {
|
||||
row.put(String.valueOf(values[i]), values[i + 1]);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, MethodHandler handler) {
|
||||
InvocationHandler invocationHandler = (proxy, method, args) -> handler.handle(method, args);
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (Boolean.TYPE.equals(type)) {
|
||||
return false;
|
||||
}
|
||||
if (Byte.TYPE.equals(type)) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (Short.TYPE.equals(type)) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (Integer.TYPE.equals(type)) {
|
||||
return 0;
|
||||
}
|
||||
if (Long.TYPE.equals(type)) {
|
||||
return 0L;
|
||||
}
|
||||
if (Float.TYPE.equals(type)) {
|
||||
return 0f;
|
||||
}
|
||||
if (Double.TYPE.equals(type)) {
|
||||
return 0.0d;
|
||||
}
|
||||
if (Character.TYPE.equals(type)) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface MethodHandler {
|
||||
Object handle(Method method, Object[] args) throws Throwable;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package com.dbx.agent.hive;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class HiveAgentTest {
|
||||
@Test
|
||||
void hiveJdbcStandaloneRuntimeClassesAreAvailable() throws ClassNotFoundException {
|
||||
Class.forName("org.apache.hive.jdbc.HiveDriver");
|
||||
Class.forName("org.apache.hive.org.apache.thrift.protocol.TProtocol");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlAppendsKerberosUrlParams() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setHost("hive.example.com");
|
||||
params.setPort(10000);
|
||||
params.setDatabase("default");
|
||||
params.setUrl_params(";principal=hive/hive.example.com@EXAMPLE.COM;auth=kerberos");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:hive2://hive.example.com:10000/default;principal=hive/hive.example.com@EXAMPLE.COM;auth=kerberos",
|
||||
HiveAgent.buildUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlUsesCustomJdbcUrlAsIs() {
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setConnection_string(
|
||||
"jdbc:hive2://zk1.example.com,zk2.example.com/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2;principal=hive/_HOST@EXAMPLE.COM"
|
||||
);
|
||||
params.setUrl_params("principal=hive/ignored@EXAMPLE.COM");
|
||||
|
||||
assertEquals(
|
||||
"jdbc:hive2://zk1.example.com,zk2.example.com/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2;principal=hive/_HOST@EXAMPLE.COM",
|
||||
HiveAgent.buildUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTableDdlUsesHiveShowCreateTable() {
|
||||
HiveAgent agent = new HiveAgent();
|
||||
List<String> queries = new ArrayList<>();
|
||||
String expectedDdl = "CREATE TABLE `hive_test`.`cleaned_data_table`(col string)\n"
|
||||
+ "ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'\n"
|
||||
+ "LOCATION 'hdfs://warehouse/cleaned_data_table'";
|
||||
ResultSet resultSet = proxy(ResultSet.class, new InvocationHandler() {
|
||||
private int row = -1;
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||
if ("next".equals(method.getName())) {
|
||||
return ++row == 0;
|
||||
}
|
||||
if ("getString".equals(method.getName())) {
|
||||
return expectedDdl;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Statement statement = proxy(Statement.class, (proxy, method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
queries.add((String) args[0]);
|
||||
return resultSet;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
Connection connection = proxy(Connection.class, (proxy, method, args) -> {
|
||||
if ("createStatement".equals(method.getName())) {
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
|
||||
assertEquals(expectedDdl + "\n", agent.getTableDdl("hive_test", "cleaned_data_table"));
|
||||
assertEquals(List.of("SHOW CREATE TABLE `hive_test`.`cleaned_data_table`"), queries);
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'org.apache.ignite:ignite-core:2.18.0'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
manifest {
|
||||
attributes('Agent-Label': 'Apache Ignite', 'Main-Class': 'com.dbx.agent.ignite.IgniteAgent')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.dbx.agent.ignite;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class IgniteAgent extends AbstractJdbcAgent {
|
||||
|
||||
private String databaseName = "";
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
return "org.apache.ignite.IgniteJdbcThinDriver";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
// Ignite has no catalogs; name the virtual database after the connection's
|
||||
// real default schema (e.g. PUBLIC) so any database-as-schema context that
|
||||
// leaks into setSchema targets a schema that actually exists.
|
||||
try {
|
||||
String schema = connection.getSchema();
|
||||
databaseName = schema == null || schema.isBlank() ? params.getDatabase() : schema;
|
||||
} catch (Exception e) {
|
||||
databaseName = params.getDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
// Ignite has no catalogs; expose the connected schema group as a single
|
||||
// virtual database so the object browser stays schema-oriented like H2.
|
||||
return List.of(new DatabaseInfo(databaseName.isBlank() ? "default" : databaseName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
return unchecked(() -> {
|
||||
List<String> result = new ArrayList<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getSchemas()) {
|
||||
while (rs.next()) {
|
||||
result.add(rs.getString("TABLE_SCHEM"));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.naturalOrder());
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getTables(null, schema, null, null)) {
|
||||
while (rs.next()) {
|
||||
result.add(new TableInfo(
|
||||
rs.getString("TABLE_NAME"),
|
||||
normalizeTableType(rs.getString("TABLE_TYPE")),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(TableInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
Set<String> primaryKeys = new LinkedHashSet<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) {
|
||||
while (rs.next()) {
|
||||
primaryKeys.add(rs.getString("COLUMN_NAME"));
|
||||
}
|
||||
}
|
||||
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getColumns(null, schema, table, null)) {
|
||||
while (rs.next()) {
|
||||
String colName = rs.getString("COLUMN_NAME");
|
||||
result.add(new ColumnInfo(
|
||||
colName,
|
||||
rs.getString("TYPE_NAME"),
|
||||
"YES".equals(rs.getString("IS_NULLABLE")),
|
||||
rs.getString("COLUMN_DEF"),
|
||||
primaryKeys.contains(colName),
|
||||
null,
|
||||
emptyToNull(rs.getString("REMARKS")),
|
||||
intOrNull(rs, "COLUMN_SIZE"),
|
||||
intOrNull(rs, "DECIMAL_DIGITS"),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
DatabaseMetaData metadata = requireConnected().getMetaData();
|
||||
Set<String> primaryIndexNames = new LinkedHashSet<>();
|
||||
List<IndexColumn> primaryColumns = new ArrayList<>();
|
||||
try (ResultSet rs = metadata.getPrimaryKeys(null, schema, table)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("PK_NAME");
|
||||
if (name != null && !name.isBlank()) {
|
||||
primaryIndexNames.add(name);
|
||||
}
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
if (column != null && !column.isBlank()) {
|
||||
primaryColumns.add(new IndexColumn(rs.getShort("KEY_SEQ"), column));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<IndexRow> rows = new ArrayList<>();
|
||||
try (ResultSet rs = metadata.getIndexInfo(null, schema, table, false, false)) {
|
||||
while (rs.next()) {
|
||||
if (rs.getShort("TYPE") == DatabaseMetaData.tableIndexStatistic) {
|
||||
continue;
|
||||
}
|
||||
String name = rs.getString("INDEX_NAME");
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
if (name == null || name.isBlank() || column == null || column.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
rows.add(new IndexRow(
|
||||
name,
|
||||
column,
|
||||
rs.getShort("ORDINAL_POSITION"),
|
||||
!rs.getBoolean("NON_UNIQUE"),
|
||||
indexType(rs.getShort("TYPE"))
|
||||
));
|
||||
}
|
||||
}
|
||||
return assembleIndexes(rows, primaryIndexNames, primaryColumns);
|
||||
});
|
||||
}
|
||||
|
||||
static List<IndexInfo> assembleIndexes(
|
||||
List<IndexRow> rows,
|
||||
Set<String> primaryIndexNames,
|
||||
List<IndexColumn> primaryColumns
|
||||
) {
|
||||
Map<String, List<IndexRow>> grouped = new LinkedHashMap<>();
|
||||
for (IndexRow row : rows) {
|
||||
grouped.computeIfAbsent(row.name, ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
List<IndexColumn> orderedPrimary = new ArrayList<>(primaryColumns);
|
||||
orderedPrimary.sort(Comparator.comparingInt(column -> column.ordinal));
|
||||
List<String> orderedPrimaryColumns = new ArrayList<>();
|
||||
for (IndexColumn column : orderedPrimary) {
|
||||
orderedPrimaryColumns.add(column.name);
|
||||
}
|
||||
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
for (Map.Entry<String, List<IndexRow>> entry : grouped.entrySet()) {
|
||||
List<IndexRow> indexRows = entry.getValue();
|
||||
indexRows.sort(Comparator.comparingInt(row -> row.ordinal));
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (IndexRow row : indexRows) {
|
||||
columns.add(row.column);
|
||||
}
|
||||
IndexRow first = indexRows.get(0);
|
||||
boolean primary = primaryIndexNames.stream().anyMatch(name -> name.equalsIgnoreCase(entry.getKey()))
|
||||
|| sameColumns(columns, orderedPrimaryColumns);
|
||||
result.add(new IndexInfo(
|
||||
entry.getKey(),
|
||||
columns,
|
||||
first.unique,
|
||||
primary,
|
||||
null,
|
||||
first.indexType,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
result.sort(Comparator.comparing(IndexInfo::getName));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean sameColumns(List<String> left, List<String> right) {
|
||||
if (left.size() != right.size() || left.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < left.size(); index++) {
|
||||
if (!left.get(index).equalsIgnoreCase(right.get(index))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String indexType(short type) {
|
||||
if (type == DatabaseMetaData.tableIndexClustered) return "CLUSTERED";
|
||||
if (type == DatabaseMetaData.tableIndexHashed) return "HASHED";
|
||||
if (type == DatabaseMetaData.tableIndexOther) return "OTHER";
|
||||
return null;
|
||||
}
|
||||
|
||||
static final class IndexRow {
|
||||
final String name;
|
||||
final String column;
|
||||
final int ordinal;
|
||||
final boolean unique;
|
||||
final String indexType;
|
||||
|
||||
IndexRow(String name, String column, int ordinal, boolean unique, String indexType) {
|
||||
this.name = name;
|
||||
this.column = column;
|
||||
this.ordinal = ordinal;
|
||||
this.unique = unique;
|
||||
this.indexType = indexType;
|
||||
}
|
||||
}
|
||||
|
||||
static final class IndexColumn {
|
||||
final int ordinal;
|
||||
final String name;
|
||||
|
||||
IndexColumn(int ordinal, String name) {
|
||||
this.ordinal = ordinal;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setSchemaSQL(String schema) {
|
||||
// JdbcThinConnection implements Connection.setSchema, so no SQL switch is needed.
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String buildUrl(ConnectParams params) {
|
||||
String connectionString = params.getConnection_string();
|
||||
if (connectionString != null && !connectionString.trim().isEmpty()) {
|
||||
return connectionString.trim();
|
||||
}
|
||||
String base = "jdbc:ignite:thin://" + params.getHost() + ":" + params.getPort();
|
||||
String database = params.getDatabase();
|
||||
return database == null || database.isBlank() ? base : base + "/" + database;
|
||||
}
|
||||
|
||||
private static String normalizeTableType(String type) {
|
||||
if ("BASE TABLE".equals(type)) {
|
||||
return "TABLE";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value instanceof Number ? ((Number) value).intValue() : null;
|
||||
}
|
||||
|
||||
private static String emptyToNull(String value) {
|
||||
return value == null || value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(IgniteAgent::new).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.dbx.agent.ignite;
|
||||
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class IgniteAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
@Override
|
||||
protected DatabaseAgent createAgent() {
|
||||
return new IgniteAgent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resultSetSql() {
|
||||
return "SELECT 1";
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesOrdinalColumnsAndPrimaryStatus() {
|
||||
List<IndexInfo> indexes = IgniteAgent.assembleIndexes(
|
||||
List.of(
|
||||
new IgniteAgent.IndexRow("IDX_NAME", "last_name", 2, false, "OTHER"),
|
||||
new IgniteAgent.IndexRow("PK_PERSON", "tenant_id", 1, true, "HASHED"),
|
||||
new IgniteAgent.IndexRow("IDX_NAME", "first_name", 1, false, "OTHER"),
|
||||
new IgniteAgent.IndexRow("PK_PERSON", "person_id", 2, true, "HASHED")
|
||||
),
|
||||
Set.of("PK_PERSON"),
|
||||
List.of(
|
||||
new IgniteAgent.IndexColumn(2, "person_id"),
|
||||
new IgniteAgent.IndexColumn(1, "tenant_id")
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(2, indexes.size());
|
||||
IndexInfo secondary = indexes.get(0);
|
||||
assertEquals("IDX_NAME", secondary.getName());
|
||||
assertEquals(List.of("first_name", "last_name"), secondary.getColumns());
|
||||
assertFalse(secondary.getIs_unique());
|
||||
assertFalse(secondary.getIs_primary());
|
||||
|
||||
IndexInfo primary = indexes.get(1);
|
||||
assertEquals("PK_PERSON", primary.getName());
|
||||
assertEquals(List.of("tenant_id", "person_id"), primary.getColumns());
|
||||
assertTrue(primary.getIs_unique());
|
||||
assertTrue(primary.getIs_primary());
|
||||
assertEquals("HASHED", primary.getIndex_type());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
dependencies {
|
||||
implementation 'org.apache.ignite:ignite-jdbc:3.1.0'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
manifest {
|
||||
attributes('Agent-Label': 'Apache Ignite 3', 'Main-Class': 'com.dbx.agent.ignite3.Ignite3Agent')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.dbx.agent.ignite3;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.ForeignKeyInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
public final class Ignite3Agent extends AbstractJdbcAgent {
|
||||
|
||||
private static final String JDBC_USERNAME_PROPERTY = "ignite.jdbc.username";
|
||||
private static final String JDBC_PASSWORD_PROPERTY = "ignite.jdbc.password";
|
||||
|
||||
private String databaseName = "";
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
return "org.apache.ignite.jdbc.IgniteJdbcDriver";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Properties buildConnectionProperties(ConnectParams params) {
|
||||
Properties properties = new Properties();
|
||||
String username = params.getUsername();
|
||||
String password = params.getPassword();
|
||||
if ((username != null && !username.isEmpty()) || (password != null && !password.isEmpty())) {
|
||||
properties.setProperty(JDBC_USERNAME_PROPERTY, username == null ? "" : username);
|
||||
properties.setProperty(JDBC_PASSWORD_PROPERTY, password == null ? "" : password);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
// Ignite has no catalogs; name the virtual database after the connection's
|
||||
// real default schema (e.g. PUBLIC) so any database-as-schema context that
|
||||
// leaks into setSchema targets a schema that actually exists.
|
||||
try {
|
||||
String schema = connection.getSchema();
|
||||
databaseName = schema == null || schema.isBlank() ? params.getDatabase() : schema;
|
||||
} catch (Exception e) {
|
||||
databaseName = params.getDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
// Ignite has no catalogs; expose the connected schema group as a single
|
||||
// virtual database so the object browser stays schema-oriented like H2.
|
||||
return List.of(new DatabaseInfo(databaseName.isBlank() ? "default" : databaseName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
return unchecked(() -> {
|
||||
List<String> result = new ArrayList<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getSchemas()) {
|
||||
while (rs.next()) {
|
||||
result.add(rs.getString("TABLE_SCHEM"));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.naturalOrder());
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return unchecked(() -> {
|
||||
List<TableInfo> result = new ArrayList<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getTables(null, schema, null, null)) {
|
||||
while (rs.next()) {
|
||||
result.add(new TableInfo(
|
||||
rs.getString("TABLE_NAME"),
|
||||
normalizeTableType(rs.getString("TABLE_TYPE")),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(TableInfo::getName));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
Set<String> primaryKeys = new LinkedHashSet<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) {
|
||||
while (rs.next()) {
|
||||
primaryKeys.add(rs.getString("COLUMN_NAME"));
|
||||
}
|
||||
}
|
||||
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getColumns(null, schema, table, null)) {
|
||||
while (rs.next()) {
|
||||
String colName = rs.getString("COLUMN_NAME");
|
||||
result.add(new ColumnInfo(
|
||||
colName,
|
||||
rs.getString("TYPE_NAME"),
|
||||
"YES".equals(rs.getString("IS_NULLABLE")),
|
||||
rs.getString("COLUMN_DEF"),
|
||||
primaryKeys.contains(colName),
|
||||
null,
|
||||
emptyToNull(rs.getString("REMARKS")),
|
||||
intOrNull(rs, "COLUMN_SIZE"),
|
||||
intOrNull(rs, "DECIMAL_DIGITS"),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setSchemaSQL(String schema) {
|
||||
// JdbcConnection implements Connection.setSchema, so no SQL switch is needed.
|
||||
return "";
|
||||
}
|
||||
|
||||
static String buildUrl(ConnectParams params) {
|
||||
String connectionString = params.getConnection_string();
|
||||
if (connectionString != null && !connectionString.trim().isEmpty()) {
|
||||
return connectionString.trim();
|
||||
}
|
||||
String base = "jdbc:ignite:thin://" + params.getHost() + ":" + params.getPort();
|
||||
String database = params.getDatabase();
|
||||
String url = database == null || database.isBlank() ? base : base + "/" + database;
|
||||
// Reuse the shared JDBC URL parameter mapping so advanced options
|
||||
// (timeouts, SSL, authentication) configured in the UI reach the driver.
|
||||
return JdbcAgentProfile.appendUrlParams(url, params.getUrl_params());
|
||||
}
|
||||
|
||||
private static String normalizeTableType(String type) {
|
||||
if ("BASE TABLE".equals(type)) {
|
||||
return "TABLE";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value instanceof Number ? ((Number) value).intValue() : null;
|
||||
}
|
||||
|
||||
private static String emptyToNull(String value) {
|
||||
return value == null || value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(Ignite3Agent::new).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.dbx.agent.ignite3;
|
||||
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
|
||||
class Ignite3AgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
@Override
|
||||
protected DatabaseAgent createAgent() {
|
||||
return new Ignite3Agent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resultSetSql() {
|
||||
return "SELECT 1";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.dbx.agent.ignite3;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Driver;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.DriverPropertyInfo;
|
||||
import java.sql.SQLException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.logging.Logger;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Ignite3AgentUrlTest {
|
||||
|
||||
@Test
|
||||
void buildUrlUsesExplicitConnectionString() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", "", "", "",
|
||||
"jdbc:ignite:thin://10.0.0.1:10800/PUBLIC?connectionTimeout=5000", false
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:ignite:thin://10.0.0.1:10800/PUBLIC?connectionTimeout=5000",
|
||||
Ignite3Agent.buildUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlWithoutDatabaseOmitsSchemaSegment() {
|
||||
ConnectParams params = new ConnectParams("127.0.0.1", 10800, "", "", "", "", "", false);
|
||||
|
||||
Assertions.assertEquals("jdbc:ignite:thin://127.0.0.1:10800", Ignite3Agent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlWithoutUrlParamsStaysUnchanged() {
|
||||
ConnectParams params = new ConnectParams("127.0.0.1", 10800, "PUBLIC", "", "", null, "", false);
|
||||
|
||||
Assertions.assertEquals("jdbc:ignite:thin://127.0.0.1:10800/PUBLIC", Ignite3Agent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlAppendsAdvancedUrlParamsAfterSchema() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", "", "",
|
||||
"connectionTimeout=5000&queryTimeout=30", "", false
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:ignite:thin://127.0.0.1:10800/PUBLIC?connectionTimeout=5000&queryTimeout=30",
|
||||
Ignite3Agent.buildUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlPassesThroughUserParamStringVerbatim() {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", "", "",
|
||||
"sslEnabled=true;trustStorePath=/etc/ignite/trust.jks", "", false
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:ignite:thin://127.0.0.1:10800/PUBLIC?sslEnabled=true;trustStorePath=/etc/ignite/trust.jks",
|
||||
Ignite3Agent.buildUrl(params)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildUrlStripsLeadingParamSeparators() {
|
||||
ConnectParams params = new ConnectParams("127.0.0.1", 10800, "", "", "", "?sslEnabled=true", "", false);
|
||||
|
||||
Assertions.assertEquals("jdbc:ignite:thin://127.0.0.1:10800?sslEnabled=true", Ignite3Agent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesThePinnedIgniteJdbcDependency() throws Exception {
|
||||
Path driverJar = Path.of(
|
||||
org.apache.ignite.jdbc.IgniteJdbcDriver.class.getProtectionDomain().getCodeSource().getLocation().toURI()
|
||||
);
|
||||
|
||||
Assertions.assertEquals("ignite-jdbc-3.1.0.jar", driverJar.getFileName().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void separateCredentialsRoundTripThroughJdbcPropertiesAndIgniteParser() throws Exception {
|
||||
String username = "dbx-user";
|
||||
String password = "dbx-secret";
|
||||
String urlParams = "sslEnabled=true&connectionTimeout=5000&queryTimeout=30&connectionTimeZone=UTC";
|
||||
CapturingDriver capturingDriver = new CapturingDriver();
|
||||
DriverManager.registerDriver(capturingDriver);
|
||||
|
||||
try {
|
||||
ConnectParams captureParams = new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", username, password, urlParams,
|
||||
CapturingDriver.URL, false
|
||||
);
|
||||
Properties connectionProperties = new Ignite3Agent().buildConnectionProperties(captureParams);
|
||||
try (Connection ignored = DriverManager.getConnection(CapturingDriver.URL, connectionProperties)) {
|
||||
Assertions.assertEquals(CapturingDriver.URL, capturingDriver.url);
|
||||
}
|
||||
|
||||
Assertions.assertEquals(username, capturingDriver.properties.getProperty("ignite.jdbc.username"));
|
||||
Assertions.assertEquals(password, capturingDriver.properties.getProperty("ignite.jdbc.password"));
|
||||
Assertions.assertNull(capturingDriver.properties.getProperty("user"));
|
||||
Assertions.assertNull(capturingDriver.properties.getProperty("password"));
|
||||
|
||||
ConnectParams igniteParams = new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", username, password, urlParams, "", false
|
||||
);
|
||||
String igniteUrl = Ignite3Agent.buildUrl(igniteParams);
|
||||
Assertions.assertFalse(igniteUrl.contains(username));
|
||||
Assertions.assertFalse(igniteUrl.contains(password));
|
||||
|
||||
Map<String, String> parsed = parsedProperties(
|
||||
new org.apache.ignite.jdbc.IgniteJdbcDriver(),
|
||||
igniteUrl,
|
||||
capturingDriver.properties
|
||||
);
|
||||
Assertions.assertEquals(username, parsed.get("ignite.jdbc.username"));
|
||||
Assertions.assertEquals(password, parsed.get("ignite.jdbc.password"));
|
||||
Assertions.assertEquals("true", parsed.get("ignite.jdbc.sslEnabled"));
|
||||
Assertions.assertEquals("5000", parsed.get("ignite.jdbc.connectionTimeout"));
|
||||
Assertions.assertEquals("30", parsed.get("ignite.jdbc.queryTimeout"));
|
||||
Assertions.assertEquals("UTC", parsed.get("ignite.jdbc.connectionTimeZone"));
|
||||
} finally {
|
||||
DriverManager.deregisterDriver(capturingDriver);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankCredentialsDoNotEnableIgniteAuthentication() throws Exception {
|
||||
CapturingDriver capturingDriver = new CapturingDriver();
|
||||
DriverManager.registerDriver(capturingDriver);
|
||||
|
||||
try {
|
||||
ConnectParams params = new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", "", "", "", CapturingDriver.URL, false
|
||||
);
|
||||
Properties connectionProperties = new Ignite3Agent().buildConnectionProperties(params);
|
||||
try (Connection ignored = DriverManager.getConnection(CapturingDriver.URL, connectionProperties)) {
|
||||
Assertions.assertTrue(capturingDriver.properties.isEmpty());
|
||||
}
|
||||
} finally {
|
||||
DriverManager.deregisterDriver(capturingDriver);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void semicolonAdvancedParamsStayVerbatimAndAreNotSplitInQueryMode() throws Exception {
|
||||
String url = Ignite3Agent.buildUrl(new ConnectParams(
|
||||
"127.0.0.1", 10800, "PUBLIC", "", "",
|
||||
"sslEnabled=true;trustStorePath=/etc/ignite/trust.jks", "", false
|
||||
));
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:ignite:thin://127.0.0.1:10800/PUBLIC?sslEnabled=true;trustStorePath=/etc/ignite/trust.jks",
|
||||
url
|
||||
);
|
||||
|
||||
Map<String, String> parsed = parsedProperties(
|
||||
new org.apache.ignite.jdbc.IgniteJdbcDriver(),
|
||||
url,
|
||||
new Properties()
|
||||
);
|
||||
Assertions.assertEquals("false", parsed.get("ignite.jdbc.sslEnabled"));
|
||||
Assertions.assertNull(parsed.get("ignite.jdbc.trustStorePath"));
|
||||
}
|
||||
|
||||
private static Map<String, String> parsedProperties(
|
||||
Driver driver,
|
||||
String url,
|
||||
Properties properties
|
||||
) throws SQLException {
|
||||
Map<String, String> parsed = new LinkedHashMap<>();
|
||||
for (DriverPropertyInfo property : driver.getPropertyInfo(url, properties)) {
|
||||
parsed.put(property.name, property.value);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static final class CapturingDriver implements Driver {
|
||||
private static final String URL = "jdbc:dbx-ignite3-test:";
|
||||
|
||||
private String url;
|
||||
private Properties properties;
|
||||
|
||||
@Override
|
||||
public Connection connect(String url, Properties info) {
|
||||
if (!acceptsURL(url)) {
|
||||
return null;
|
||||
}
|
||||
this.url = url;
|
||||
properties = new Properties();
|
||||
properties.putAll(info);
|
||||
return (Connection) Proxy.newProxyInstance(
|
||||
Connection.class.getClassLoader(),
|
||||
new Class<?>[]{Connection.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean acceptsURL(String url) {
|
||||
return URL.equals(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) {
|
||||
return new DriverPropertyInfo[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMajorVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMinorVersion() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean jdbcCompliant() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Logger getParentLogger() {
|
||||
return Logger.getGlobal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ for TLS or mTLS connections.
|
||||
paging, and sequential batch/transaction execution.
|
||||
- Table sessions expose databases, tables, TIME/TAG/FIELD column categories,
|
||||
comments, queries, DDL, paging, and database switching.
|
||||
- Query results annotate timestamp columns as `TIMESTAMP(ms|us|ns)` using the
|
||||
server-reported `TimestampPrecision`. Raw timestamp integers are transported
|
||||
as decimal strings so nanosecond values are not rounded by JavaScript.
|
||||
- The Agent keeps one physical IoTDB session per logical DBX session and
|
||||
invalidates that session after cancellation, timeout, or connection failure.
|
||||
|
||||
|
||||
@@ -78,8 +78,9 @@ type connectionConfig struct {
|
||||
}
|
||||
|
||||
type sessionClient struct {
|
||||
session iotdbSession
|
||||
dialect string
|
||||
session iotdbSession
|
||||
dialect string
|
||||
timestampPrecision string
|
||||
}
|
||||
|
||||
func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
@@ -215,10 +216,9 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
func newSessionClient(config connectionConfig) (*sessionClient, error) {
|
||||
var session client.Session
|
||||
var err error
|
||||
database := config.Database
|
||||
if config.Dialect == client.TableSqlDialect {
|
||||
database = ""
|
||||
}
|
||||
// DBX applies a table database with USE after switching dialects. Do not
|
||||
// include a tree database in openSession: IoTDB 2.x rejects it there, while
|
||||
// DBX still retains it for metadata and path qualification.
|
||||
if len(config.NodeURLs) > 1 {
|
||||
session, err = client.NewClusterSession(&client.ClusterConfig{
|
||||
NodeUrls: config.NodeURLs,
|
||||
@@ -227,7 +227,6 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) {
|
||||
FetchSize: config.FetchSize,
|
||||
TimeZone: config.TimeZone,
|
||||
ConnectRetryMax: config.ConnectRetryMax,
|
||||
Database: database,
|
||||
TLSConfig: config.TLSConfig,
|
||||
})
|
||||
if err == nil {
|
||||
@@ -242,7 +241,6 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) {
|
||||
FetchSize: config.FetchSize,
|
||||
TimeZone: config.TimeZone,
|
||||
ConnectRetryMax: config.ConnectRetryMax,
|
||||
Database: database,
|
||||
TLSConfig: config.TLSConfig,
|
||||
})
|
||||
err = session.Open(config.EnableCompression, config.ConnectTimeoutMS)
|
||||
@@ -266,9 +264,64 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
precisionTimeout := min(time.Duration(config.ConnectTimeoutMS)*time.Millisecond, 5*time.Second)
|
||||
precisionCtx, precisionCancel := context.WithTimeout(context.Background(), precisionTimeout)
|
||||
connected.timestampPrecision, _ = queryTimestampPrecision(precisionCtx, wrapped)
|
||||
precisionCancel()
|
||||
return connected, nil
|
||||
}
|
||||
|
||||
func queryTimestampPrecision(ctx context.Context, session iotdbSession) (string, error) {
|
||||
dataset, err := session.ExecuteQueryStatement(ctx, "SHOW VARIABLES", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dataset.Close()
|
||||
columns := dataset.GetColumnNames()
|
||||
variableIndex, valueIndex := int32(1), int32(2)
|
||||
for index, column := range columns {
|
||||
switch strings.ToLower(strings.TrimSpace(column)) {
|
||||
case "variable":
|
||||
variableIndex = int32(index + 1)
|
||||
case "value":
|
||||
valueIndex = int32(index + 1)
|
||||
}
|
||||
}
|
||||
for {
|
||||
hasNext, err := dataset.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasNext {
|
||||
return "", errors.New("IoTDB SHOW VARIABLES did not return TimestampPrecision")
|
||||
}
|
||||
variable, err := dataset.GetStringByIndex(variableIndex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(strings.ReplaceAll(strings.TrimSpace(variable), "_", ""), "TimestampPrecision") {
|
||||
continue
|
||||
}
|
||||
value, err := dataset.GetStringByIndex(valueIndex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if precision := normalizeTimestampPrecision(value); precision != "" {
|
||||
return precision, nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported IoTDB timestamp precision: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimestampPrecision(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "ms", "us", "ns":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionClient) Close() error {
|
||||
if s == nil || s.session == nil {
|
||||
return nil
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
|
||||
if err != nil || len(page.Rows) != 2 || !page.HasMore || page.SessionID == nil {
|
||||
t.Fatalf("tree first page = %#v, %v", page, err)
|
||||
}
|
||||
if page.ColumnTypes[0] != "TIMESTAMP(ms)" || page.Rows[0][0] != "1" {
|
||||
t.Fatalf("tree time column = types %#v row %#v", page.ColumnTypes, page.Rows[0])
|
||||
}
|
||||
lastPage, err := treeServer.fetchQueryPage(*page.SessionID, 2)
|
||||
if err != nil || len(lastPage.Rows) != 1 || lastPage.HasMore || !lastPage.Truncated {
|
||||
t.Fatalf("tree final page = %#v, %v", lastPage, err)
|
||||
@@ -62,15 +65,15 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
|
||||
mustExecuteNonQuery(t, tableServer, "CREATE DATABASE "+quoteTableIdentifier(tableDatabase), "")
|
||||
mustExecuteNonQuery(t, tableServer,
|
||||
"CREATE TABLE "+quoteTableIdentifier(tableDatabase)+"."+quoteTableIdentifier("d1")+
|
||||
" (time TIMESTAMP TIME, device STRING TAG, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')",
|
||||
" (time TIMESTAMP TIME, device STRING TAG, event_time TIMESTAMP FIELD, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')",
|
||||
tableDatabase,
|
||||
)
|
||||
mustExecuteNonQuery(t, tableServer,
|
||||
"INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,s1) VALUES(1,'a',10),(2,'a',20)",
|
||||
"INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,event_time,s1) VALUES(1,'a',1001,10),(2,'a',1002,20)",
|
||||
tableDatabase,
|
||||
)
|
||||
tableColumns, err := tableServer.getColumns(tableDatabase, "d1")
|
||||
if err != nil || len(tableColumns) != 3 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey {
|
||||
if err != nil || len(tableColumns) != 4 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey || tableColumns[2].IsPrimaryKey {
|
||||
t.Fatalf("table getColumns() = %#v, %v", tableColumns, err)
|
||||
}
|
||||
comment, err := tableServer.getTableComment(tableDatabase, "d1")
|
||||
@@ -78,7 +81,7 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
|
||||
t.Fatalf("table comment = %#v, %v", comment, err)
|
||||
}
|
||||
result, err := tableServer.executeQuery(queryOptions{SQL: "SELECT * FROM d1 ORDER BY time", Database: tableDatabase, MaxRows: 10})
|
||||
if err != nil || len(result.Rows) != 2 || result.Rows[0][0] != "1970-01-01T08:00:00.001+08:00" {
|
||||
if err != nil || len(result.Rows) != 2 || result.ColumnTypes[0] != "TIMESTAMP(ms)" || result.ColumnTypes[2] != "TIMESTAMP(ms)" || result.Rows[0][0] != "1" || result.Rows[0][2] != "1001" {
|
||||
t.Fatalf("table query = %#v, %v", result, err)
|
||||
}
|
||||
tableDDL, err := tableServer.getTableDDL(tableDatabase, "d1")
|
||||
@@ -87,6 +90,30 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveIoTDBAgentTreeDatabaseConnection(t *testing.T) {
|
||||
if os.Getenv("DBX_IOTDB_LIVE") != "1" {
|
||||
t.Skip("set DBX_IOTDB_LIVE=1 to run against a real IoTDB server")
|
||||
}
|
||||
database := "root.dbx_go_connection_" + strconv.Itoa(os.Getpid())
|
||||
bootstrap := liveIoTDBServer(t, connectParams{})
|
||||
defer bootstrap.disconnect()
|
||||
mustExecuteNonQuery(t, bootstrap, "CREATE DATABASE "+database, "")
|
||||
defer func() { _ = bootstrap.executeNonQuery("DELETE DATABASE "+database, "", 0) }()
|
||||
|
||||
scoped := liveIoTDBServer(t, connectParams{Database: database})
|
||||
defer scoped.disconnect()
|
||||
if err := scoped.validateConnection(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables, err := scoped.listTables(database, metadataListConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tables == nil {
|
||||
t.Fatal("expected an initialized tree table list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveIoTDBAgentProcessMultiSessionAndCancellation(t *testing.T) {
|
||||
if os.Getenv("DBX_IOTDB_LIVE") != "1" {
|
||||
t.Skip("set DBX_IOTDB_LIVE=1 to run against a real IoTDB server")
|
||||
|
||||
@@ -120,8 +120,8 @@ func TestMetadataHelpers(t *testing.T) {
|
||||
|
||||
func TestNormalizeIoTDBValues(t *testing.T) {
|
||||
when := time.Date(2026, time.August, 10, 12, 34, 56, 123, time.FixedZone("CST", 8*60*60))
|
||||
if got := normalizeIoTDBValue(when, "TIMESTAMP"); got != "2026-08-10T12:34:56.000000123+08:00" {
|
||||
t.Fatalf("unexpected timestamp: %#v", got)
|
||||
if got := normalizeIoTDBValue(when, "DATETIME"); got != "2026-08-10T12:34:56.000000123+08:00" {
|
||||
t.Fatalf("unexpected datetime: %#v", got)
|
||||
}
|
||||
if got := normalizeIoTDBValue(when, "DATE"); got != "2026-08-10" {
|
||||
t.Fatalf("unexpected date: %#v", got)
|
||||
@@ -131,6 +131,53 @@ func TestNormalizeIoTDBValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeTimeColumnPresentation(t *testing.T) {
|
||||
columns := []string{"Time", "root.db.d1.s1"}
|
||||
for _, precision := range []string{"ms", "us", "ns"} {
|
||||
types := normalizedColumnTypes([]string{"INT64", "DOUBLE"}, columns, client.TreeSqlDialect, precision)
|
||||
want := []string{"TIMESTAMP(" + precision + ")", "DOUBLE"}
|
||||
if !reflect.DeepEqual(types, want) {
|
||||
t.Fatalf("unexpected tree column types for %s: %#v", precision, types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableTimestampColumnPresentation(t *testing.T) {
|
||||
columns := []string{"time", "device", "event_time"}
|
||||
for _, precision := range []string{"ms", "us", "ns"} {
|
||||
got := normalizedColumnTypes([]string{"TIMESTAMP", "STRING", "TIMESTAMP"}, columns, client.TableSqlDialect, precision)
|
||||
want := []string{"TIMESTAMP(" + precision + ")", "STRING", "TIMESTAMP(" + precision + ")"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected table column types for %s: %#v", precision, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownTimestampPrecisionDoesNotEnableFormattingMetadata(t *testing.T) {
|
||||
columns := []string{"Time", "root.db.d1.s1"}
|
||||
if got := normalizedColumnTypes([]string{"INT64", "DOUBLE"}, columns, client.TreeSqlDialect, ""); !reflect.DeepEqual(got, []string{"TIMESTAMP", "DOUBLE"}) {
|
||||
t.Fatalf("unexpected unknown-precision types: %#v", got)
|
||||
}
|
||||
for _, value := range []string{"ms", "US", " ns "} {
|
||||
if normalizeTimestampPrecision(value) == "" {
|
||||
t.Fatalf("expected supported precision %q", value)
|
||||
}
|
||||
}
|
||||
if normalizeTimestampPrecision("seconds") != "" {
|
||||
t.Fatal("unsupported precision must not be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeTimeColumnPresentationDoesNotRewriteOrdinaryInt64(t *testing.T) {
|
||||
columns := []string{"Time", "value"}
|
||||
if got := normalizedColumnTypes([]string{"INT64", "INT64"}, columns, client.TableSqlDialect, "ms"); !reflect.DeepEqual(got, []string{"INT64", "INT64"}) {
|
||||
t.Fatalf("unexpected table column types: %#v", got)
|
||||
}
|
||||
if got := normalizedColumnTypes([]string{"INT64", "INT64"}, []string{"value", "Time"}, client.TreeSqlDialect, "ms"); !reflect.DeepEqual(got, []string{"INT64", "INT64"}) {
|
||||
t.Fatalf("unexpected non-axis column types: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyIoTDBErrors(t *testing.T) {
|
||||
canceled := classifyRPCError("execute_query", "session-1", context.Canceled)
|
||||
if canceled.Data.Category != "canceled" || canceled.Data.SessionDisposition != "quarantine" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -87,8 +88,8 @@ func (s *server) queryValues(sql, database string, limit, timeoutSecs int) (quer
|
||||
}
|
||||
defer dataset.Close()
|
||||
columns := append([]string(nil), dataset.GetColumnNames()...)
|
||||
columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), len(columns))
|
||||
rows, truncated, err := readDataset(ctx, dataset, connected.dialect, columns, columnTypes, limit)
|
||||
columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), columns, connected.dialect, connected.timestampPrecision)
|
||||
rows, truncated, err := readDataset(ctx, dataset, connected, columns, columnTypes, limit)
|
||||
if err != nil {
|
||||
return queryResult{}, err
|
||||
}
|
||||
@@ -163,7 +164,7 @@ func (s *server) executeQueryPage(options queryOptions, requestedPageSize int) (
|
||||
dataset: dataset,
|
||||
client: connected,
|
||||
columns: append([]string(nil), dataset.GetColumnNames()...),
|
||||
columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), len(dataset.GetColumnNames())),
|
||||
columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), dataset.GetColumnNames(), connected.dialect, connected.timestampPrecision),
|
||||
remaining: limit,
|
||||
}
|
||||
rows, hasMore, truncated, err := s.readQuerySessionPage(ctx, queryState, pageSize)
|
||||
@@ -279,7 +280,7 @@ func (s *server) nextDatasetRow(ctx context.Context, state *querySession) ([]any
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
row, err := datasetRow(state.dataset, state.client.dialect, state.columns, state.columnTypes)
|
||||
row, err := datasetRow(state.dataset, state.client, state.columns, state.columnTypes)
|
||||
return row, true, err
|
||||
}
|
||||
|
||||
@@ -372,7 +373,7 @@ func runCancelable[T any](s *server, timeoutSecs int, connected *sessionClient,
|
||||
return value, err
|
||||
}
|
||||
|
||||
func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect string, columns, columnTypes []string, limit int) ([][]any, bool, error) {
|
||||
func readDataset(ctx context.Context, dataset *client.SessionDataSet, connected *sessionClient, columns, columnTypes []string, limit int) ([][]any, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultMaxRows
|
||||
}
|
||||
@@ -391,7 +392,7 @@ func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect st
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
row, err := datasetRow(dataset, dialect, columns, columnTypes)
|
||||
row, err := datasetRow(dataset, connected, columns, columnTypes)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -404,7 +405,7 @@ func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect st
|
||||
return rows, hasNext, err
|
||||
}
|
||||
|
||||
func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnTypes []string) ([]any, error) {
|
||||
func datasetRow(dataset *client.SessionDataSet, connected *sessionClient, columns, columnTypes []string) ([]any, error) {
|
||||
row := make([]any, len(columns))
|
||||
for index := range columns {
|
||||
columnIndex := int32(index + 1)
|
||||
@@ -412,12 +413,20 @@ func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnT
|
||||
if index < len(columnTypes) {
|
||||
columnType = strings.ToUpper(columnTypes[index])
|
||||
}
|
||||
if dialect == client.TreeSqlDialect && index == 0 && strings.EqualFold(columns[index], "Time") {
|
||||
value, err := dataset.GetStringByIndex(columnIndex)
|
||||
if iotdbColumnTypeBase(columnType) == "TIMESTAMP" {
|
||||
isNull, err := dataset.IsNullByIndex(columnIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row[index] = value
|
||||
if isNull {
|
||||
row[index] = nil
|
||||
continue
|
||||
}
|
||||
value, err := dataset.GetLongByIndex(columnIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row[index] = strconv.FormatInt(value, 10)
|
||||
continue
|
||||
}
|
||||
value, err := dataset.GetObjectByIndex(columnIndex)
|
||||
@@ -455,16 +464,37 @@ func normalizeIoTDBValue(value any, columnType string) any {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedColumnTypes(values []string, columnCount int) []string {
|
||||
result := make([]string, columnCount)
|
||||
func normalizedColumnTypes(values, columns []string, dialect, timestampPrecision string) []string {
|
||||
result := make([]string, len(columns))
|
||||
for index := range result {
|
||||
if index < len(values) {
|
||||
result[index] = strings.ToUpper(values[index])
|
||||
}
|
||||
if isTreeTimeColumn(dialect, columns, index) {
|
||||
result[index] = "TIMESTAMP"
|
||||
}
|
||||
if iotdbColumnTypeBase(result[index]) == "TIMESTAMP" {
|
||||
result[index] = timestampColumnType(timestampPrecision)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func timestampColumnType(precision string) string {
|
||||
if normalized := normalizeTimestampPrecision(precision); normalized != "" {
|
||||
return "TIMESTAMP(" + normalized + ")"
|
||||
}
|
||||
return "TIMESTAMP"
|
||||
}
|
||||
|
||||
func iotdbColumnTypeBase(value string) string {
|
||||
return strings.TrimSpace(strings.SplitN(strings.ToUpper(value), "(", 2)[0])
|
||||
}
|
||||
|
||||
func isTreeTimeColumn(dialect string, columns []string, index int) bool {
|
||||
return dialect == client.TreeSqlDialect && index == 0 && len(columns) > 0 && strings.EqualFold(columns[0], "Time")
|
||||
}
|
||||
|
||||
func timeoutMilliseconds(timeoutSecs int) *int64 {
|
||||
if timeoutSecs <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -62,7 +62,7 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
|
||||
stringOrNull(rs, "COLUMN_DEFAULT"),
|
||||
"YES".equalsIgnoreCase(stringOrNull(rs, "PRIMARY_KEY")),
|
||||
null,
|
||||
null,
|
||||
stringOrNull(rs, "DESCRIPTION"),
|
||||
intOrNull(rs, "NUMERIC_PRECISION"),
|
||||
intOrNull(rs, "NUMERIC_SCALE"),
|
||||
intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
|
||||
@@ -126,7 +126,7 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
|
||||
private static String columnSql(String schema) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
|
||||
+ "PRIMARY_KEY, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH "
|
||||
+ "PRIMARY_KEY, DESCRIPTION, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH "
|
||||
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE "
|
||||
);
|
||||
if (!isBlank(schema)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class IrisAgentTest {
|
||||
@@ -63,12 +64,45 @@ class IrisAgentTest {
|
||||
assertEquals("INTEGER", columns.get(0).getData_type());
|
||||
assertFalse(columns.get(0).getIs_nullable());
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertEquals("42", columns.get(0).getColumn_default());
|
||||
assertEquals("identifier", columns.get(0).getComment());
|
||||
assertEquals(Integer.valueOf(10), columns.get(0).getNumeric_precision());
|
||||
assertEquals(Integer.valueOf(0), columns.get(0).getNumeric_scale());
|
||||
assertNull(columns.get(0).getCharacter_maximum_length());
|
||||
assertEquals("NAME", columns.get(1).getName());
|
||||
assertEquals("VARCHAR", columns.get(1).getData_type());
|
||||
assertTrue(columns.get(1).getIs_nullable());
|
||||
assertNull(columns.get(1).getColumn_default());
|
||||
assertNull(columns.get(1).getComment());
|
||||
assertNull(columns.get(1).getNumeric_precision());
|
||||
assertNull(columns.get(1).getNumeric_scale());
|
||||
assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length());
|
||||
assertEquals(1, calls.size());
|
||||
assertFalse(calls.get(0).contains("LIMIT"));
|
||||
assertTrue(calls.get(0).contains("PRIMARY_KEY"));
|
||||
assertTrue(calls.get(0).contains("DESCRIPTION"));
|
||||
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
|
||||
assertTrue(calls.get(0).contains("UPPER(TABLE_SCHEMA) = UPPER(?)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsDescriptionsWithoutSchemaFilter() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
true,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
Collections.emptyList()
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = IrisAgent.irisColumns(conn, null, "People");
|
||||
|
||||
assertEquals("identifier", columns.get(0).getComment());
|
||||
assertNull(columns.get(1).getComment());
|
||||
assertEquals(1, calls.size());
|
||||
assertTrue(calls.get(0).contains("DESCRIPTION"));
|
||||
assertFalse(calls.get(0).contains("TABLE_SCHEMA"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -185,8 +219,11 @@ class IrisAgentTest {
|
||||
"COLUMN_NAME", "ID",
|
||||
"DATA_TYPE", "INTEGER",
|
||||
"IS_NULLABLE", "NO",
|
||||
"COLUMN_DEFAULT", "42",
|
||||
"PRIMARY_KEY", inlinePrimaryKey ? "YES" : "NO",
|
||||
"NUMERIC_PRECISION", 10
|
||||
"DESCRIPTION", "identifier",
|
||||
"NUMERIC_PRECISION", 10,
|
||||
"NUMERIC_SCALE", 0
|
||||
),
|
||||
Map.of(
|
||||
"COLUMN_NAME", "NAME",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user