mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
Compare commits
615
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
966d444867 | ||
|
|
5f5a78a044 | ||
|
|
2dfd4e0058 | ||
|
|
e4d7113a18 | ||
|
|
ab5aba9497 | ||
|
|
84ae93aaa8 | ||
|
|
13a209d4c2 | ||
|
|
1c937f0cfc | ||
|
|
1a7b44f8fc | ||
|
|
d35d578b03 | ||
|
|
2c97e44d82 | ||
|
|
b891bf80fd | ||
|
|
57e98b6d86 | ||
|
|
b969a86d38 | ||
|
|
1ed06f270b | ||
|
|
e8affe9436 | ||
|
|
c6ed3b6d11 | ||
|
|
d766228bb6 | ||
|
|
f5673ca0a1 | ||
|
|
ecc1135de3 | ||
|
|
81292760e2 | ||
|
|
81a0ed922a | ||
|
|
2a318a296b | ||
|
|
1d19c8b273 | ||
|
|
ea1de14517 | ||
|
|
b2d56625e0 | ||
|
|
85653427ef | ||
|
|
9cb6b28a0f | ||
|
|
5b60c2efcd | ||
|
|
94a8e63fde | ||
|
|
e9be6d0419 | ||
|
|
91b8fa0aa8 | ||
|
|
a57906ed7e | ||
|
|
888f37c3c8 | ||
|
|
69d3f02843 | ||
|
|
44bb44a9d9 | ||
|
|
4a42a2bd29 | ||
|
|
13ebba40c9 | ||
|
|
193e9e898d | ||
|
|
ad3c5cf239 | ||
|
|
b8ae066f8e | ||
|
|
dade7c39b5 | ||
|
|
0239d2c56b | ||
|
|
197ba97f73 | ||
|
|
3496451ca4 | ||
|
|
9fca87c744 | ||
|
|
2c4f2091a6 | ||
|
|
048b9ed291 | ||
|
|
b0d30e6f73 | ||
|
|
f43bf80015 | ||
|
|
1170bcaa1c | ||
|
|
7ab781ce19 | ||
|
|
9eafac706e | ||
|
|
72be4ad9eb | ||
|
|
4bf073d5d1 | ||
|
|
9bf52dab69 | ||
|
|
e916e9e927 | ||
|
|
c2f4c6e2be | ||
|
|
a8cf635292 | ||
|
|
43f9aa8916 | ||
|
|
ee767bd64f | ||
|
|
e2cc5786a6 | ||
|
|
15c38da8ea | ||
|
|
dffd3025a7 | ||
|
|
8373f153a3 | ||
|
|
69b138dc22 | ||
|
|
7f12fecc08 | ||
|
|
1938c4a8aa | ||
|
|
2d5fc80c91 | ||
|
|
8dab022452 | ||
|
|
e498fc3731 | ||
|
|
3c8ae159fb | ||
|
|
4c80d16e26 | ||
|
|
300fc029a9 | ||
|
|
d19e43b42b | ||
|
|
6a6f7712c1 | ||
|
|
f73424c3d3 | ||
|
|
d0f8bd0f24 | ||
|
|
7942c59aa7 | ||
|
|
7c5ffb1630 | ||
|
|
041469269e | ||
|
|
464350d086 | ||
|
|
cc573615cf | ||
|
|
df22dd49b9 | ||
|
|
28b795e7a9 | ||
|
|
168f854f8a | ||
|
|
2fd8f42246 | ||
|
|
94bcbc1631 | ||
|
|
0437f952e0 | ||
|
|
b06e952ba0 | ||
|
|
5a0e74eec6 | ||
|
|
571fc07d85 | ||
|
|
62fab080e8 | ||
|
|
3a74411c28 | ||
|
|
1ae691fc70 | ||
|
|
3d96ca39ae | ||
|
|
ab277e0fa2 | ||
|
|
256917d8cf | ||
|
|
e255c49e7d | ||
|
|
dd99786c01 | ||
|
|
aeafddfae7 | ||
|
|
fd4ca30707 | ||
|
|
2ee04c51ec | ||
|
|
2b1c5fa71e | ||
|
|
39042f7917 | ||
|
|
d79dc05cf5 | ||
|
|
715d834c32 | ||
|
|
20e026eea9 | ||
|
|
9c22856285 | ||
|
|
4a997fe63f | ||
|
|
08c163fe32 | ||
|
|
83c8447b4d | ||
|
|
99738c160c | ||
|
|
14bcde806f | ||
|
|
1464d33677 | ||
|
|
36b96ea32c | ||
|
|
faf259fcc2 | ||
|
|
f95650f039 | ||
|
|
3f74195835 | ||
|
|
a9cf5822fa | ||
|
|
c4952adb98 | ||
|
|
f852a57673 | ||
|
|
f1c1785959 | ||
|
|
8e7d4c387f | ||
|
|
7855707b69 | ||
|
|
c489dead10 | ||
|
|
62e81745ab | ||
|
|
1ea822b80e | ||
|
|
a81ae9f455 | ||
|
|
837ea07cf3 | ||
|
|
bd9e784d90 | ||
|
|
ace6173aa9 | ||
|
|
a04b424e37 | ||
|
|
400ff6dd02 | ||
|
|
a5bb8cf1c1 | ||
|
|
e84e49fa28 | ||
|
|
5918414127 | ||
|
|
c6ecd9808f | ||
|
|
8240df143a | ||
|
|
ad9d9cad8f | ||
|
|
b288a6b5f4 | ||
|
|
3d1b84831f | ||
|
|
b70d6da227 | ||
|
|
af424b782f | ||
|
|
c63de19cbf | ||
|
|
0854cc3e08 | ||
|
|
cabc7656de | ||
|
|
760d2c6409 | ||
|
|
eba6853480 | ||
|
|
805d10346a | ||
|
|
28e2568a8f | ||
|
|
608633edff | ||
|
|
14aeb93081 | ||
|
|
a7a2e2f434 | ||
|
|
2e3b7da533 | ||
|
|
d9154ee2b7 | ||
|
|
deb47df2eb | ||
|
|
760cad6d1b | ||
|
|
a3aa50ded9 | ||
|
|
1ea6d2907b | ||
|
|
1533064f1f | ||
|
|
6d866905b5 | ||
|
|
f1cc68a4ba | ||
|
|
8d32cf9ffa | ||
|
|
7cbd1418b3 | ||
|
|
e561bd156f | ||
|
|
193d875b8a | ||
|
|
58d902c07f | ||
|
|
05534fce32 | ||
|
|
19365b09bb | ||
|
|
2249aa30c0 | ||
|
|
94617a5923 | ||
|
|
9c973a2ee3 | ||
|
|
8efb24f2c7 | ||
|
|
b28374867a | ||
|
|
defc4dcfa5 | ||
|
|
ae1da14ddf | ||
|
|
638aa48f57 | ||
|
|
974848d6e2 | ||
|
|
ac2dcc1b79 | ||
|
|
18e34bf33a | ||
|
|
2808996526 | ||
|
|
ba65d4a0e0 | ||
|
|
72b8b8f0ac | ||
|
|
f28f050d97 | ||
|
|
28c0977a56 | ||
|
|
e2dc7ce176 | ||
|
|
f52be8f439 | ||
|
|
61b13826c5 | ||
|
|
f15d93deb2 | ||
|
|
d3587719d2 | ||
|
|
78f9740366 | ||
|
|
1b6ff2ba54 | ||
|
|
ac7fbaeb43 | ||
|
|
933decc98b | ||
|
|
56c0f8567d | ||
|
|
8cd5372e22 | ||
|
|
e8e1ece80d | ||
|
|
83fdd78d5b | ||
|
|
f6e5b3ab52 | ||
|
|
78a78cc209 | ||
|
|
91da8b2a9a | ||
|
|
ba77d94815 | ||
|
|
a33768ef6e | ||
|
|
45ed1047a8 | ||
|
|
7afa87e849 | ||
|
|
832e08ae1f | ||
|
|
a3e4a95400 | ||
|
|
fd1f7f0adf | ||
|
|
68afe61693 | ||
|
|
729bb454e3 | ||
|
|
061d4009ee | ||
|
|
0459dfea12 | ||
|
|
1137cab7f4 | ||
|
|
28206f2963 | ||
|
|
17f184cd07 | ||
|
|
1842ae9de7 | ||
|
|
5ed03ca2b2 | ||
|
|
bcd1aafb33 | ||
|
|
7b7d21f54f | ||
|
|
f900357474 | ||
|
|
84e8bab622 | ||
|
|
b5072f1a3e | ||
|
|
3767f4a83b | ||
|
|
b8f39f9c24 | ||
|
|
6694e4382e | ||
|
|
579008ffbd | ||
|
|
7472f16066 | ||
|
|
80d314b711 | ||
|
|
f26e373df9 | ||
|
|
859a004136 | ||
|
|
4fe04c8bb9 | ||
|
|
8ff56b4837 | ||
|
|
bf4874906c | ||
|
|
177aba9979 | ||
|
|
d36c094c4f | ||
|
|
4bac6b56de | ||
|
|
e03046ba55 | ||
|
|
0e67027acf | ||
|
|
b6cfb3b728 | ||
|
|
1191118b5c | ||
|
|
bd2ac50ac1 | ||
|
|
f8ce8ab353 | ||
|
|
20f6092cf2 | ||
|
|
f32b3928ea | ||
|
|
7cb1e6502f | ||
|
|
90feaa43fb | ||
|
|
e60b0f3083 | ||
|
|
0bfb1035e4 | ||
|
|
774f175c33 | ||
|
|
076c39bc21 | ||
|
|
083f81bc03 | ||
|
|
058a67be0b | ||
|
|
e43fd9c502 | ||
|
|
575f71dfd8 | ||
|
|
83e1e293b0 | ||
|
|
4e54b49961 | ||
|
|
e68029d020 | ||
|
|
72847a69be | ||
|
|
3affa544a9 | ||
|
|
35773ace52 | ||
|
|
8b1379ff4f | ||
|
|
2d9521898f | ||
|
|
e8b3c1457a | ||
|
|
48d6c94aad | ||
|
|
074216ed0f | ||
|
|
32e070f387 | ||
|
|
3ccf4c2fed | ||
|
|
6f259a60c5 | ||
|
|
27f21d5cc4 | ||
|
|
71cc734cdd | ||
|
|
1ceeaad8a4 | ||
|
|
84b02d6960 | ||
|
|
1268cd5615 | ||
|
|
8f29273653 | ||
|
|
19be861c49 | ||
|
|
5fa0c5a56d | ||
|
|
f7b7b9baa6 | ||
|
|
99b59cc094 | ||
|
|
f9f9a28edf | ||
|
|
16866293a3 | ||
|
|
339c96cc81 | ||
|
|
a04713001e | ||
|
|
5e111145d3 | ||
|
|
72faf4887b | ||
|
|
c834421c49 | ||
|
|
a4eab4f73f | ||
|
|
ed810d6c92 | ||
|
|
0a6c10555a | ||
|
|
7c2db2d142 | ||
|
|
5ba5813247 | ||
|
|
61bd3cdf62 | ||
|
|
6299d62125 | ||
|
|
5cd4706d3d | ||
|
|
3840426128 | ||
|
|
ee7ee0d572 | ||
|
|
8186c697d5 | ||
|
|
ead906477f | ||
|
|
1c9846dc57 | ||
|
|
ea44730c8a | ||
|
|
7b32836f27 | ||
|
|
a216e0938e | ||
|
|
15d451d43a | ||
|
|
14298b31a8 | ||
|
|
e1e925fbab | ||
|
|
bad696ed11 | ||
|
|
c89a2f4979 | ||
|
|
718b18655b | ||
|
|
6f2bc68f1a | ||
|
|
a1ede71ed5 | ||
|
|
6696af471d | ||
|
|
ec65e998bf | ||
|
|
be966a8465 | ||
|
|
6f6abf1d38 | ||
|
|
8f3afe0397 | ||
|
|
69098bf0d6 | ||
|
|
065da1a829 | ||
|
|
77323715e7 | ||
|
|
7b7ed6f7b1 | ||
|
|
16f41d06bf | ||
|
|
d0f85555f6 | ||
|
|
61e9902690 | ||
|
|
5c95f2c00a | ||
|
|
a9b4470944 | ||
|
|
4fce108247 | ||
|
|
dcc0b20a34 | ||
|
|
e7ea2e92f2 | ||
|
|
f7afa8dca7 | ||
|
|
3e707d7777 | ||
|
|
e0cc787f0a | ||
|
|
5b5d58cd2a | ||
|
|
bd828076ab | ||
|
|
981978dcf2 | ||
|
|
4e686bd00c | ||
|
|
ab04deea11 | ||
|
|
c3f2bbf650 | ||
|
|
88f4999691 | ||
|
|
a688aadec2 | ||
|
|
b37f63e716 | ||
|
|
6ddf4f5a10 | ||
|
|
e17246c0f6 | ||
|
|
dd708fc052 | ||
|
|
087351f73f | ||
|
|
1e9a3c0f67 | ||
|
|
86eabb7224 | ||
|
|
d48167ef66 | ||
|
|
2cee2da21d | ||
|
|
0f07d337c3 | ||
|
|
464f2f33c5 | ||
|
|
182b6f3d8d | ||
|
|
e96af4aa30 | ||
|
|
7affad8ae0 | ||
|
|
0456af3f2c | ||
|
|
72bfd74e5e | ||
|
|
436dd7129f | ||
|
|
dc1b651949 | ||
|
|
b2d2507aec | ||
|
|
1d9cfe45f9 | ||
|
|
0e32e003f5 | ||
|
|
dbaa640762 | ||
|
|
1324aba3f9 | ||
|
|
32eeb92039 | ||
|
|
5536ddf16b | ||
|
|
bc4ed3b89f | ||
|
|
ce3bd1c8ed | ||
|
|
38be5ba35c | ||
|
|
e2240d4ed1 | ||
|
|
1663f3633f | ||
|
|
7b0ea38bbf | ||
|
|
758ac1b5a2 | ||
|
|
aca3faa762 | ||
|
|
619cd8f897 | ||
|
|
3caa4f2b89 | ||
|
|
7ec1d9f6e2 | ||
|
|
c32be4185f | ||
|
|
41b5e51dcc | ||
|
|
2b26bd6a1a | ||
|
|
a9ec07e651 | ||
|
|
f3e55a8337 | ||
|
|
b84d162049 | ||
|
|
c8c7a40e46 | ||
|
|
7434295b1f | ||
|
|
9e18dc2f58 | ||
|
|
c30fc33305 | ||
|
|
fd44331876 | ||
|
|
3b46109d36 | ||
|
|
3d34f318e6 | ||
|
|
78b0f935be | ||
|
|
19b0c49db4 | ||
|
|
918ff69338 | ||
|
|
23ebc67e6c | ||
|
|
a04ba3abd4 | ||
|
|
1cbb5abf3b | ||
|
|
a535cf8f51 | ||
|
|
894ecdec42 | ||
|
|
4e8a58a08c | ||
|
|
cd43ff07f0 | ||
|
|
b2784def56 | ||
|
|
775b45f235 | ||
|
|
c8ef1f44f3 | ||
|
|
c63d7927f9 | ||
|
|
3408ddbb65 | ||
|
|
ef1d7ae9c4 | ||
|
|
99c698f72e | ||
|
|
776d01199c | ||
|
|
271ed56a00 | ||
|
|
886ba2c596 | ||
|
|
a0cce8cc8f | ||
|
|
004b27a3c2 | ||
|
|
08edb60772 | ||
|
|
047cf96f8e | ||
|
|
451e50b2fb | ||
|
|
ed29ef9eed | ||
|
|
b80135dbbd | ||
|
|
6d1eb026cd | ||
|
|
a4e4fd0d2f | ||
|
|
7835f4c309 | ||
|
|
9690feee4f | ||
|
|
895661710b | ||
|
|
3c06e9a432 | ||
|
|
646745a880 | ||
|
|
d191c93ccc | ||
|
|
20378d61ad | ||
|
|
f267f4ccfb | ||
|
|
210512e842 | ||
|
|
3d450a42cc | ||
|
|
03cfeba8f7 | ||
|
|
69ef418d5f | ||
|
|
789740c9be | ||
|
|
16eb52c81f | ||
|
|
ecc19fdd30 | ||
|
|
d4f4538843 | ||
|
|
4a3d1b1db3 | ||
|
|
d6f919208f | ||
|
|
ca44609c56 | ||
|
|
40016d45cc | ||
|
|
e5c6cacec4 | ||
|
|
2e0d003538 | ||
|
|
cd6e0d6e92 | ||
|
|
b7c9312c9f | ||
|
|
dcbe9701ef | ||
|
|
f102c5d2d5 | ||
|
|
4528a15492 | ||
|
|
798733fd29 | ||
|
|
b70d4153a4 | ||
|
|
3bb54625f3 | ||
|
|
ed20ed275d | ||
|
|
acb0070f9e | ||
|
|
49923296d8 | ||
|
|
92f052fa6c | ||
|
|
52f23e3c79 | ||
|
|
1431d4996f | ||
|
|
39e34d44f7 | ||
|
|
9a0e3e744c | ||
|
|
1268dcdf52 | ||
|
|
f445d008de | ||
|
|
6cefb3d372 | ||
|
|
755f7b31bd | ||
|
|
cd6ee718b6 | ||
|
|
09b8439c86 | ||
|
|
05b719e7a6 | ||
|
|
3b9cd3eec4 | ||
|
|
e8b8e20eaa | ||
|
|
5c400a4498 | ||
|
|
cbd769d36b | ||
|
|
538aeed99c | ||
|
|
2b2d1f0d95 | ||
|
|
d0832f17f4 | ||
|
|
c7dbaf5965 | ||
|
|
a081f6a839 | ||
|
|
da217c2e34 | ||
|
|
47e41bb48a | ||
|
|
4c0ca9293a | ||
|
|
301e6e2802 | ||
|
|
579317b4bf | ||
|
|
0bebb90790 | ||
|
|
d361ef526e | ||
|
|
9392a68832 | ||
|
|
ab5fa637e0 | ||
|
|
04ab5ec96e | ||
|
|
8642e1a599 | ||
|
|
a6cd501a78 | ||
|
|
80cd461ea1 | ||
|
|
4b64656069 | ||
|
|
d0b9e49b7e | ||
|
|
39aa663ebf | ||
|
|
5fb667716f | ||
|
|
772ce36de8 | ||
|
|
abc32f1c71 | ||
|
|
4f2c67e34e | ||
|
|
d2de504248 | ||
|
|
eb87c9e837 | ||
|
|
2e72bee9a1 | ||
|
|
3ae1548de0 | ||
|
|
b4d807b818 | ||
|
|
7961e688f2 | ||
|
|
d44798f70f | ||
|
|
9ae2e70030 | ||
|
|
0ac2d33188 | ||
|
|
889fa4f5d0 | ||
|
|
5814ff0088 | ||
|
|
9e0381f737 | ||
|
|
743583249f | ||
|
|
5efbe360d9 | ||
|
|
0bacb5effd | ||
|
|
e09437ea8f | ||
|
|
ca73e9f2eb | ||
|
|
3e75be87eb | ||
|
|
39b2d2a6ac | ||
|
|
11e0db5acb | ||
|
|
96a3dc052a | ||
|
|
e3b9bae67e | ||
|
|
fcc7dfb896 | ||
|
|
e3d5d14183 | ||
|
|
f64827a607 | ||
|
|
0ec7bef098 | ||
|
|
5f81ed2e11 | ||
|
|
c0c97ef0e4 | ||
|
|
dc7b905220 | ||
|
|
29c6d1fbed | ||
|
|
852ad1c808 | ||
|
|
d29befe81c | ||
|
|
7bd2c62f65 | ||
|
|
89dbbfff3f | ||
|
|
2a3e1c1439 | ||
|
|
691ecd084b | ||
|
|
2eaf7f9914 | ||
|
|
14e1d4f25b | ||
|
|
93a4b5803a | ||
|
|
148345494e | ||
|
|
3123e962c8 | ||
|
|
0ef8369e2b | ||
|
|
17441ad0d2 | ||
|
|
16491cc3dd | ||
|
|
b9ea54350d | ||
|
|
260c6710d8 | ||
|
|
5acc5f7e82 | ||
|
|
c0b4fa09cc | ||
|
|
450d9fb9ee | ||
|
|
3e82d6d650 | ||
|
|
d6c4fa1aff | ||
|
|
68b1c4e971 | ||
|
|
42fb2a48aa | ||
|
|
c0d0a62fa8 | ||
|
|
53be33c86c | ||
|
|
a1192fc651 | ||
|
|
0bccda193b | ||
|
|
0938b6bee0 | ||
|
|
eea3faa224 | ||
|
|
ab35a5b1da | ||
|
|
5d65be03a3 | ||
|
|
6886fde709 | ||
|
|
27369a40dd | ||
|
|
c0390bff16 | ||
|
|
72ffe7e224 | ||
|
|
5244e57dca | ||
|
|
38df8e3097 | ||
|
|
7e06992f3b | ||
|
|
644812fa07 | ||
|
|
3c3ab96421 | ||
|
|
bc2d32f20f | ||
|
|
4bef571fc4 | ||
|
|
eab1f6344b | ||
|
|
781bb46d3a | ||
|
|
559b7febad | ||
|
|
63363c2834 | ||
|
|
a1e987ec0f | ||
|
|
fc621a3840 | ||
|
|
3907cb1372 | ||
|
|
da03c1f0c4 | ||
|
|
290576cb4f | ||
|
|
8598416998 | ||
|
|
0b9ddff19c | ||
|
|
641f82c962 | ||
|
|
cbaccb3a3a | ||
|
|
2f63c34ffa | ||
|
|
51d340eb9b | ||
|
|
54d7a2c05d | ||
|
|
dca6d0e5f6 | ||
|
|
295db2903b | ||
|
|
7390cf6684 | ||
|
|
0b8c7d700a | ||
|
|
a196a68d38 | ||
|
|
9e9e20a57b | ||
|
|
73c167bed3 | ||
|
|
b8d4388caf | ||
|
|
5a26faae22 | ||
|
|
c66a699aa7 | ||
|
|
789d65d3df | ||
|
|
46afd0dd5e | ||
|
|
cae4c5fde3 | ||
|
|
124819e1fa | ||
|
|
a19a77c6df | ||
|
|
f661ff7ff3 | ||
|
|
116aced217 | ||
|
|
b5fcb3a832 | ||
|
|
d5a511b719 | ||
|
|
ab6a7645f6 | ||
|
|
ffe1e58fbb | ||
|
|
2a45374490 | ||
|
|
aeab0f8b03 | ||
|
|
4cc6d0959d | ||
|
|
3be523b2f5 | ||
|
|
ce36a7f40d | ||
|
|
61aa00bf8f | ||
|
|
14168cc36a | ||
|
|
a9ce18aef1 | ||
|
|
c258fe86be | ||
|
|
23a4ca8903 | ||
|
|
719aa3e849 | ||
|
|
6ac2b68553 | ||
|
|
b8becde86a | ||
|
|
8b8c0b8075 | ||
|
|
6bdfd72809 | ||
|
|
51a642d179 |
@@ -11,6 +11,9 @@ apps/desktop/tsconfig.json text eol=lf
|
||||
apps/desktop/*.ts text eol=lf
|
||||
src-tauri/windows/nsis/**/*.nsi text eol=lf
|
||||
|
||||
# Keep plugin dev-host formatting checks consistent on Windows checkouts.
|
||||
plugins/sdk/dev-host/** text=auto eol=lf
|
||||
|
||||
# Keep tests available for review and CI without counting them as shipped code
|
||||
# in GitHub's repository language breakdown.
|
||||
packages/app-tests/** linguist-vendored
|
||||
|
||||
@@ -7,58 +7,51 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
感谢反馈。涉及敏感信息请脱敏。请尽量提供数据库类型、版本、复现步骤、截图和错误日志。
|
||||
Thanks for reporting this. Redact sensitive information and include the database type/version, reproduction steps, screenshots, and logs when possible.
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
|
||||
|
||||
You can copy the DBX version, runtime, operating system, and architecture in the app via “Settings → About → Copy support information”.
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。
|
||||
placeholder: "例如:PostgreSQL 16.2"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。 / For example, PostgreSQL 16, MySQL 8.0, or Redis 7.2.
|
||||
placeholder: "例如:PostgreSQL 16.2 / Example: PostgreSQL 16.2"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请按下面的清单逐项填写,截图和错误日志尤其重要。
|
||||
label: 问题描述 / Problem description
|
||||
description: 请按下面的清单逐项填写。 / Complete each item below.
|
||||
placeholder: |
|
||||
1. 复现步骤:新建连接 → 填写信息 → 点击保存
|
||||
2. 期望结果:能正常展开 schema
|
||||
3. 实际结果:一直 loading,无错误提示
|
||||
4. 最好附上截图和错误日志(设置 → 外观 → 启用"启用调试日志" → 复现问题 → 点击"下载日志",将内容粘贴到这里)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
1. 复现步骤 / Steps: 新建连接 → 填写信息 → 点击保存 / Create connection → enter details → click Save
|
||||
2. 期望结果 / Expected: 能正常展开 schema / Schema expands normally
|
||||
3. 实际结果 / Actual: 一直 loading,无错误提示 / Loading never finishes
|
||||
4. 附上截图和错误日志 / Attach screenshots and logs when possible
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 粘贴“复制支持信息”的结果;如无法打开应用,请说明原因。 / Paste the copied support information; explain if the app cannot open.
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
操作系统:macOS 26.4
|
||||
系统架构:aarch64
|
||||
validations:
|
||||
required: true
|
||||
|
||||
DBX 版本 / Version: v0.5.52
|
||||
运行环境 / Runtime: Desktop
|
||||
操作系统 / OS: macOS 26.4
|
||||
系统架构 / Architecture: aarch64
|
||||
validations: {required: true}
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级(是否紧急)
|
||||
description: 紧急 = 希望下个版本优先修。维护者可根据实际情况调整。
|
||||
label: 优先级(是否紧急) / Priority (urgency)
|
||||
description: 紧急 = 希望下个版本优先修;维护者可调整。 / Urgent means you hope for the next release; maintainers may adjust it.
|
||||
options:
|
||||
- "🔴 P0 紧急 · 下个版本必须修"
|
||||
- "🟠 P1 重要 · 尽快处理"
|
||||
- "🟡 P2 普通 · 按排期处理"
|
||||
- "🟢 P3 低优 · 有空再修"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- "🔴 P0 紧急 · 下个版本必须修 / Urgent · Must fix next release"
|
||||
- "🟠 P1 重要 · 尽快处理 / Important · Fix soon"
|
||||
- "🟡 P2 普通 · 按排期处理 / Normal · Schedule as appropriate"
|
||||
- "🟢 P3 低优 · 有空再修 / Low · Fix when possible"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 截图、SQL 样例、连接方式、特殊环境等。
|
||||
label: 补充信息 / Additional information
|
||||
description: 截图、SQL 样例、连接方式、特殊环境等。 / Screenshots, SQL samples, connection method, or special environments.
|
||||
|
||||
@@ -7,13 +7,14 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
用于收集数据库兼容性细节,例如连接失败、schema 读取不完整、字段类型显示错误等。请脱敏。
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
|
||||
This form collects database compatibility details such as connection failures, incomplete schema reads, and incorrect field types. Redact sensitive information.
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息 / Support information”一键复制。
|
||||
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。 / Include the distribution, such as MariaDB, AWS RDS, or TiDB.
|
||||
placeholder: "例如:MariaDB 10.11"
|
||||
validations:
|
||||
required: true
|
||||
@@ -21,16 +22,17 @@ body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 什么操作,出了什么错,期望怎样?
|
||||
placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。"
|
||||
label: 问题描述 / Description / Problem description
|
||||
description: 什么操作,出了什么错,期望怎样? / What did you do, what went wrong, and what did you expect?
|
||||
# What did you do, what went wrong, and what did you expect?
|
||||
placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。 / Example: expanding a schema reports 'unknown type'; fields should display normally."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error
|
||||
attributes:
|
||||
label: 错误信息
|
||||
label: 错误信息 / Error message
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
@@ -38,8 +40,8 @@ body:
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 / Open DBX Settings → About, copy Support Information, and paste it here. Explain if the app cannot open.
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
@@ -51,5 +53,6 @@ body:
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。
|
||||
label: 补充信息 / Additional information
|
||||
description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。 / Include schema, SQL samples, screenshots, and deployment details (Docker, cloud, or self-hosted).
|
||||
# Include schema, SQL samples, screenshots, and deployment details (Docker, cloud, or self-hosted).
|
||||
|
||||
@@ -6,64 +6,55 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
好的建议最好说明使用场景、数据库类型和期望效果。请尽量避免只写“支持 X”。
|
||||
好的建议最好说明使用场景、数据库类型和期望效果。请避免只写“支持 X”。
|
||||
Describe the use case, database type, and desired outcome; avoid requests that only say “support X”.
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
|
||||
|
||||
You can copy the DBX version, runtime, operating system, and architecture in the app via “Settings → About → Copy support information”.
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 如果是通用功能,请填写“通用”。如果只影响特定数据源,请注明数据库类型、版本或发行版。
|
||||
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 通用功能请填写“通用”;特定数据源请注明类型和版本。 / Enter “通用” for general features; otherwise specify the data source and version.
|
||||
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用 · General"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 粘贴应用中的支持信息。 / Paste the support information copied from the app.
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
操作系统:macOS 26.4
|
||||
系统架构:aarch64
|
||||
validations:
|
||||
required: true
|
||||
|
||||
DBX 版本 / Version: v0.5.52
|
||||
运行环境 / Runtime: Desktop
|
||||
操作系统 / OS: macOS 26.4
|
||||
系统架构 / Architecture: aarch64
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 当前痛点
|
||||
description: 现在做这件事有什么不方便?
|
||||
placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 当前痛点 / Current pain point
|
||||
description: 现在做这件事有什么不方便? / What is inconvenient about the current workflow?
|
||||
placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。 / Example: I must manually copy query results and convert them to Markdown."
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: 期望方案
|
||||
description: 希望怎么支持?
|
||||
placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 期望方案 / Proposed solution
|
||||
description: 希望怎么支持? / How would you like this to work?
|
||||
placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。 / Example: Add an Export to Markdown button."
|
||||
validations: {required: true}
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级(是否紧急)
|
||||
description: 紧急 = 希望下个版本优先做。维护者可根据实际情况调整。
|
||||
label: 优先级(是否紧急) / Priority (urgency)
|
||||
description: 紧急 = 希望下个版本优先做;维护者可调整。 / Urgent means you hope for the next release; maintainers may adjust it.
|
||||
options:
|
||||
- "🔴 P0 紧急 · 下个版本必须做"
|
||||
- "🟠 P1 重要 · 尽快处理"
|
||||
- "🟡 P2 普通 · 按排期处理"
|
||||
- "🟢 P3 低优 · 有空再做"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- "🔴 P0 紧急 · 下个版本必须做 / Urgent · Must do next release"
|
||||
- "🟠 P1 重要 · 尽快处理 / Important · Do soon"
|
||||
- "🟡 P2 普通 · 按排期处理 / Normal · Schedule as appropriate"
|
||||
- "🟢 P3 低优 · 有空再做 / Low · Do when possible"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 参考截图、替代方案、特殊环境等。
|
||||
label: 补充信息 / Additional information
|
||||
description: 参考截图、替代方案、特殊环境等。 / Screenshots, alternatives, or special environments.
|
||||
|
||||
@@ -6,8 +6,9 @@ body:
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。 / Enter “无关” if unrelated to a database; otherwise specify the type, version, or distribution.
|
||||
# Enter “无关” if unrelated to a database; otherwise specify the type, version, or distribution.
|
||||
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 无关"
|
||||
validations:
|
||||
required: true
|
||||
@@ -15,8 +16,8 @@ body:
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息 / Support information”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
@@ -28,8 +29,9 @@ body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: 描述
|
||||
description: 你想完成什么?已经尝试了什么?
|
||||
label: 描述 / Description
|
||||
description: 你想完成什么?已经尝试了什么? / What are you trying to accomplish, and what have you already tried?
|
||||
# What are you trying to accomplish, and what have you already tried?
|
||||
placeholder: "例如:我想通过 SSH 隧道连接内网 PostgreSQL,已确认本机 psql 可以连接,但 DBX 中测试连接提示..."
|
||||
validations:
|
||||
required: true
|
||||
@@ -37,8 +39,9 @@ body:
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 连接方式、特殊环境、已尝试的排查方式等。
|
||||
label: 环境信息 / Environment
|
||||
description: 连接方式、特殊环境、已尝试的排查方式等。 / Connection method, special environment, and troubleshooting already attempted.
|
||||
# Connection method, special environment, and troubleshooting already attempted.
|
||||
placeholder: |
|
||||
连接方式:
|
||||
补充环境:
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
## 变更说明
|
||||
## 变更说明 / Change Description
|
||||
|
||||
<!-- 简要描述这个 PR 做了什么 -->
|
||||
<!-- 简要描述这个 PR 做了什么 / Briefly describe what this PR changes. -->
|
||||
|
||||
## 变更类型
|
||||
## 变更类型 / Change Type
|
||||
|
||||
- [ ] 新功能
|
||||
- [ ] Bug 修复
|
||||
- [ ] 性能优化
|
||||
- [ ] 代码重构
|
||||
- [ ] 文档更新
|
||||
- [ ] CI / 构建
|
||||
- [ ] 新功能 / New feature
|
||||
- [ ] Bug 修复 / Bug fix
|
||||
- [ ] 性能优化 / Performance improvement
|
||||
- [ ] 代码重构 / Code refactoring
|
||||
- [ ] 文档更新 / Documentation
|
||||
- [ ] CI / 构建 / CI or build
|
||||
|
||||
## 涉及前端
|
||||
## 涉及前端 / Frontend Changes
|
||||
|
||||
<!-- 如果 PR 涉及任何前端改动(UI 组件、样式、交互、文案等),必须附截图或录屏 -->
|
||||
<!-- 如果 PR 涉及任何前端改动(UI 组件、样式、交互、文案等),必须附截图或录屏 / If this PR changes UI components, styles, interactions, or user-facing text, include screenshots or a recording. -->
|
||||
|
||||
- [ ] 本 PR 涉及前端改动,已附截图/录屏(见下方)
|
||||
- [ ] 本 PR 涉及前端改动,已附截图/录屏(见下方) / This PR includes frontend changes and screenshots or a recording are attached below.
|
||||
|
||||
<!-- 截图区域 -->
|
||||
<!-- 截图区域 / Screenshots or recording -->
|
||||
|
||||
## 验证
|
||||
## 验证 / Validation
|
||||
|
||||
<!-- 说明如何验证你的改动,例如:命令、测试用例、手动操作步骤 -->
|
||||
<!-- 说明如何验证你的改动,例如:命令、测试用例、手动操作步骤 / Explain how you verified the change, including commands, tests, or manual steps. -->
|
||||
|
||||
- [ ] `make check` 通过
|
||||
- [ ] `make cargo-check-fast` 通过
|
||||
- [ ] 相关测试通过
|
||||
- [ ] `make check` 通过 / `make check` passes
|
||||
- [ ] `make cargo-check-fast` 通过 / `make cargo-check-fast` passes
|
||||
- [ ] 相关测试通过 / Relevant tests pass
|
||||
|
||||
## 关联 Issue
|
||||
## 关联 Issue / Related Issue
|
||||
|
||||
<!-- 如有,填写 `Close #xxx` 或 `Related #xxx` -->
|
||||
<!-- 如有,填写 `Close #xxx` 或 `Related #xxx` / If applicable, use `Close #xxx` or `Related #xxx`. -->
|
||||
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test Oracle native agent
|
||||
working-directory: agents/drivers/oracle-go
|
||||
run: go test ./...
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test Xugu native agent
|
||||
working-directory: agents/drivers/xugu
|
||||
run: GONOSUMDB=gitee.com/XuguDB/go-xugu-driver go test ./...
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test RabbitMQ native agent
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
run: go test ./...
|
||||
@@ -595,7 +595,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test Kingbase native agent
|
||||
working-directory: agents/drivers/kingbase-go
|
||||
run: go test ./...
|
||||
|
||||
@@ -21,12 +21,6 @@ jobs:
|
||||
env:
|
||||
# The workspace intentionally contains platform-specific CLI/MCP packages for every release target.
|
||||
NPM_CONFIG_LOGLEVEL: error
|
||||
# Same two-layer cache as rust-test/rust-fmt-clippy: sccache cannot
|
||||
# reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -44,92 +38,11 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm --filter dbx... install --frozen-lockfile
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- 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}"
|
||||
# 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
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./ -> target"
|
||||
# Own key: this job builds one crate with --no-default-features,
|
||||
# a different fingerprint than either Rust job's feature set, so
|
||||
# sharing their key would only ever miss and waste cache space.
|
||||
shared-key: ci-frontend-docs-export-smoke-x86_64-unknown-linux-gnu
|
||||
# Preserve completed dependency builds when a later step fails.
|
||||
cache-on-failure: true
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
# exportSmoke.spec.ts shells out to this example to build a real
|
||||
# to_standalone_html fixture. Built here, before `pnpm check`, so the
|
||||
# test's own `cargo run` (same flags — see the spec's comment) is a
|
||||
# cache hit rather than a fresh compile of dbx-core inside a vitest
|
||||
# hook. `--no-default-features`: the docs module needs none of
|
||||
# duckdb-sidecar, mq-admin, sqlite-sqlcipher or system-fonts, so this
|
||||
# avoids requiring any system dev packages this job doesn't already
|
||||
# install.
|
||||
- name: Build the docs export smoke fixture example
|
||||
run: cargo build -p dbx-core --locked --no-default-features --example docs_export_smoke
|
||||
|
||||
# Guards that the committed bundle (crates/dbx-core/assets/docs-export.*)
|
||||
# matches the sources under apps/desktop/ it was built from. This job is
|
||||
# what runs on a frontend-only change, so the guard has to run here too —
|
||||
# rust-test is gated on crates/**/src-tauri/**/Cargo.*, none of which a
|
||||
# frontend-only PR touches. Same `--no-default-features` fingerprint as
|
||||
# the prebuild step above, so this is a cache hit, not a fresh compile.
|
||||
- name: Guard the committed docs export bundle
|
||||
run: cargo test -p dbx-core --locked --no-default-features --lib docs::export
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
- name: Frontend check
|
||||
run: pnpm check
|
||||
|
||||
# Advisory, not a guard: the manifest guard above hashes INPUTS, so it
|
||||
# cannot see a hand-edited artefact (no source hash moves) or a
|
||||
# toolchain bump (vite, tailwindcss and @vitejs/plugin-vue are
|
||||
# plugins, not modules — they never enter the graph). Rebuilding and
|
||||
# diffing checks the relationship between inputs and output instead,
|
||||
# closing both gaps. Must run last: it overwrites the committed
|
||||
# bundle, and both the guard above and `pnpm check` above (whose
|
||||
# exportSmoke.spec.ts executes the *committed* bundle) need it
|
||||
# untouched to be testing what is actually checked in.
|
||||
# Rebuild and diff the committed docs-export bundle after the frontend
|
||||
# checks so source changes cannot silently leave stale generated assets.
|
||||
- name: Rebuild the docs export bundle
|
||||
run: pnpm build:docs-export
|
||||
|
||||
@@ -187,6 +100,9 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install plugin development host dependencies
|
||||
run: npm ci --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
|
||||
@@ -16,7 +16,21 @@ jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# main 的 ruleset 要求改动走 PR,默认 GITHUB_TOKEN 直推会被 GH013 拒掉;
|
||||
# 用 owner PAT 后 push 以 admin 身份命中 ruleset 的 bypass(同 mcp-release.yml)。
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
token: ${{ secrets.MCP_RELEASE_TOKEN || secrets.PROJECT_AUTOMATION_TOKEN }}
|
||||
- name: Verify push token
|
||||
# 快照抓取要打好几页 GitHub API,先用一步廉价检查把缺 token 的情况挡在前面,
|
||||
# 否则要等到最后 push 才以 GH013 报错,很难看出是令牌问题。
|
||||
env:
|
||||
PUSH_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN || secrets.PROJECT_AUTOMATION_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$PUSH_TOKEN" ]; then
|
||||
echo "::error::No owner PAT configured; the default GITHUB_TOKEN cannot push to the protected main branch."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -57,6 +57,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install dev-host dependencies for plugin-cli packing
|
||||
run: npm ci --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Set package versions
|
||||
id: version
|
||||
env:
|
||||
@@ -916,39 +919,66 @@ jobs:
|
||||
publish-homebrew-formula:
|
||||
name: Publish Homebrew formula
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, publish-cli, publish-mcp-server]
|
||||
needs: [prepare, publish-cli-github-release]
|
||||
steps:
|
||||
- name: Download CLI npm tarball and compute SHA256
|
||||
- name: Download native CLI archives and compute SHA256
|
||||
id: cli-hash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
VERSION="${VERSION}"
|
||||
NPM_TARBALL="cli-${VERSION}.tgz"
|
||||
NPM_URL="https://registry.npmjs.org/@dbx-app/cli/-/${NPM_TARBALL}"
|
||||
|
||||
# Poll npm until the package is available (CDN propagation delay)
|
||||
for i in $(seq 1 12); do
|
||||
if curl -fsSLI "${NPM_URL}" >/dev/null 2>&1; then
|
||||
echo "Package found on attempt ${i}"
|
||||
break
|
||||
mkdir -p release-assets
|
||||
ASSETS=(
|
||||
"CLI-SHA256SUMS"
|
||||
"dbx-cli-darwin-arm64.tar.gz"
|
||||
"dbx-cli-darwin-x64.tar.gz"
|
||||
"dbx-cli-linux-arm64-gnu.tar.gz"
|
||||
"dbx-cli-linux-x64-gnu.tar.gz"
|
||||
)
|
||||
for ASSET in "${ASSETS[@]}"; do
|
||||
gh release download "${TAG}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "${ASSET}" \
|
||||
--dir release-assets \
|
||||
--clobber
|
||||
done
|
||||
|
||||
(
|
||||
cd release-assets
|
||||
sha256sum --check --ignore-missing CLI-SHA256SUMS
|
||||
)
|
||||
|
||||
hash_for() {
|
||||
awk -v f="$1" '$2 == f {print $1}' release-assets/CLI-SHA256SUMS
|
||||
}
|
||||
|
||||
SHA_DARWIN_ARM64="$(hash_for dbx-cli-darwin-arm64.tar.gz)"
|
||||
SHA_DARWIN_X64="$(hash_for dbx-cli-darwin-x64.tar.gz)"
|
||||
SHA_LINUX_ARM64="$(hash_for dbx-cli-linux-arm64-gnu.tar.gz)"
|
||||
SHA_LINUX_X64="$(hash_for dbx-cli-linux-x64-gnu.tar.gz)"
|
||||
|
||||
for HASH_NAME in SHA_DARWIN_ARM64 SHA_DARWIN_X64 SHA_LINUX_ARM64 SHA_LINUX_X64; do
|
||||
if [ -z "${!HASH_NAME}" ]; then
|
||||
echo "::error::Missing SHA256 entry for ${HASH_NAME} in CLI-SHA256SUMS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for npm CDN (attempt ${i}/12)..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
curl -fsSL -o "${NPM_TARBALL}" "${NPM_URL}"
|
||||
CLI_SHA256=$(sha256sum "${NPM_TARBALL}" | cut -d ' ' -f 1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha256=${CLI_SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo " cli version: ${VERSION}"
|
||||
echo " sha256: ${CLI_SHA256}"
|
||||
echo "sha_darwin_arm64=${SHA_DARWIN_ARM64}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_darwin_x64=${SHA_DARWIN_X64}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_linux_arm64=${SHA_LINUX_ARM64}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_linux_x64=${SHA_LINUX_X64}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push formula to homebrew-tap
|
||||
env:
|
||||
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
|
||||
CLI_VERSION: ${{ steps.cli-hash.outputs.version }}
|
||||
CLI_SHA256: ${{ steps.cli-hash.outputs.sha256 }}
|
||||
SHA_DARWIN_ARM64: ${{ steps.cli-hash.outputs.sha_darwin_arm64 }}
|
||||
SHA_DARWIN_X64: ${{ steps.cli-hash.outputs.sha_darwin_x64 }}
|
||||
SHA_LINUX_ARM64: ${{ steps.cli-hash.outputs.sha_linux_arm64 }}
|
||||
SHA_LINUX_X64: ${{ steps.cli-hash.outputs.sha_linux_x64 }}
|
||||
run: |
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/t8y2/homebrew-tap.git" \
|
||||
@@ -956,32 +986,52 @@ jobs:
|
||||
cd homebrew-tap
|
||||
mkdir -p Formula
|
||||
|
||||
# The formula installs the prebuilt native binary from the packages
|
||||
# release. Installing the npm tarball instead breaks within the
|
||||
# Homebrew --min-release-age cooldown: the platform binary is an npm
|
||||
# optionalDependency, so during the cooldown npm silently skips it
|
||||
# and brew ships a non-working CLI (t8y2/homebrew-tap#2).
|
||||
cat > Formula/dbx-cli.rb <<'RUBY_EOF'
|
||||
class DbxCli < Formula
|
||||
desc "Command-line interface for DBX database connections, schema, and safe queries"
|
||||
homepage "https://github.com/t8y2/dbx"
|
||||
url "https://registry.npmjs.org/@dbx-app/cli/-/cli-__CLI_VERSION__.tgz"
|
||||
sha256 "__CLI_SHA256__"
|
||||
version "__CLI_VERSION__"
|
||||
license "Apache-2.0"
|
||||
|
||||
depends_on "node"
|
||||
on_macos do
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-darwin-x64.tar.gz"
|
||||
sha256 "__SHA_DARWIN_X64__"
|
||||
else
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-darwin-arm64.tar.gz"
|
||||
sha256 "__SHA_DARWIN_ARM64__"
|
||||
end
|
||||
end
|
||||
on_linux do
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-linux-x64-gnu.tar.gz"
|
||||
sha256 "__SHA_LINUX_X64__"
|
||||
else
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-cli-linux-arm64-gnu.tar.gz"
|
||||
sha256 "__SHA_LINUX_ARM64__"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
system "npm", "install", *std_npm_args
|
||||
bin.install_symlink libexec.glob("bin/*")
|
||||
|
||||
bin.install "dbx"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_path_exists bin/"dbx"
|
||||
system bin/"dbx", "doctor"
|
||||
assert_match version.to_s, shell_output("#{bin}/dbx --version")
|
||||
end
|
||||
end
|
||||
RUBY_EOF
|
||||
|
||||
sed -i 's/^ //' Formula/dbx-cli.rb
|
||||
sed -i "s/__CLI_VERSION__/${CLI_VERSION}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__CLI_SHA256__/${CLI_SHA256}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__SHA_DARWIN_ARM64__/${SHA_DARWIN_ARM64}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__SHA_DARWIN_X64__/${SHA_DARWIN_X64}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__SHA_LINUX_ARM64__/${SHA_LINUX_ARM64}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__SHA_LINUX_X64__/${SHA_LINUX_X64}/g" Formula/dbx-cli.rb
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
@@ -77,20 +77,22 @@ jobs:
|
||||
elif event in ("pull_request", "pull_request_target"):
|
||||
title = os.environ["PR_TITLE"]
|
||||
url = os.environ["PR_URL"]
|
||||
if action == "opened":
|
||||
# if action == "opened":
|
||||
# user = os.environ["PR_USER"]
|
||||
# msg = "\n".join([
|
||||
# "🔀 新 Pull Request",
|
||||
# f"标题: {title}",
|
||||
# f"提交者: {user}",
|
||||
# f"链接: {url}",
|
||||
# ])
|
||||
if action == "closed" and os.environ.get("PR_MERGED") == "true":
|
||||
user = os.environ["PR_USER"]
|
||||
msg = "\n".join([
|
||||
"🔀 新 Pull Request",
|
||||
f"标题: {title}",
|
||||
f"提交者: {user}",
|
||||
f"链接: {url}",
|
||||
])
|
||||
elif action == "closed" and os.environ.get("PR_MERGED") == "true":
|
||||
user = os.environ.get("PR_MERGED_BY", "unknown")
|
||||
merged_by = os.environ.get("PR_MERGED_BY", "unknown")
|
||||
msg = "\n".join([
|
||||
"✅ PR 已合并",
|
||||
f"标题: {title}",
|
||||
f"合并者: {user}",
|
||||
f"提交者: {user}",
|
||||
f"合并者: {merged_by}",
|
||||
f"链接: {url}",
|
||||
])
|
||||
|
||||
@@ -195,7 +197,9 @@ jobs:
|
||||
|
||||
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 \
|
||||
if ! 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' \
|
||||
--data-binary @-"
|
||||
--data-binary @-"; then
|
||||
echo "::warning::QQ group notification failed; the event notification was sent to Feishu."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
name: Plugin CLI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-cli-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Plugin CLI version to publish, for example 0.1.6"
|
||||
required: true
|
||||
default: "0.1.6"
|
||||
npm-tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
default: "latest"
|
||||
type: choice
|
||||
options:
|
||||
- latest
|
||||
- next
|
||||
|
||||
permissions:
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: plugin-cli-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Validate plugin CLI release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
npm-tag: ${{ steps.release.outputs.npm-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
cache: false
|
||||
|
||||
- name: Validate release branch and versions
|
||||
id: release
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_NPM_TAG: ${{ inputs.npm-tag }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
let version;
|
||||
let tag;
|
||||
let npmTag;
|
||||
if (process.env.GITHUB_EVENT_NAME === "push") {
|
||||
tag = process.env.GITHUB_REF_NAME;
|
||||
const match = /^plugin-cli-v(.+)$/.exec(tag);
|
||||
if (!match) throw new Error(`Unexpected plugin CLI tag: ${tag}`);
|
||||
version = match[1];
|
||||
npmTag = version.includes("-") ? "next" : "latest";
|
||||
} else {
|
||||
if (process.env.GITHUB_REF !== "refs/heads/main") {
|
||||
throw new Error(`Manual plugin CLI releases must run from main, received ${process.env.GITHUB_REF}.`);
|
||||
}
|
||||
version = process.env.INPUT_VERSION.trim();
|
||||
tag = `plugin-cli-v${version}`;
|
||||
npmTag = process.env.INPUT_NPM_TAG;
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`Invalid semantic version: ${version}`);
|
||||
}
|
||||
if (version.includes("-") && npmTag === "latest") {
|
||||
throw new Error(`Prerelease ${version} cannot use the npm latest tag.`);
|
||||
}
|
||||
|
||||
const packagePaths = [
|
||||
"packages/plugin-cli/package.json",
|
||||
"packages/plugin-cli-darwin-arm64/package.json",
|
||||
"packages/plugin-cli-darwin-x64/package.json",
|
||||
"packages/plugin-cli-linux-arm64-gnu/package.json",
|
||||
"packages/plugin-cli-linux-x64-gnu/package.json",
|
||||
"packages/plugin-cli-win32-arm64/package.json",
|
||||
"packages/plugin-cli-win32-x64/package.json",
|
||||
];
|
||||
for (const path of packagePaths) {
|
||||
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
if (pkg.version !== version) {
|
||||
throw new Error(`${path} has version ${pkg.version}; expected ${version}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const cliCargo = fs.readFileSync("plugins/sdk/cli/Cargo.toml", "utf8");
|
||||
const cliVersion = cliCargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (cliVersion !== version) {
|
||||
throw new Error(`plugins/sdk/cli/Cargo.toml has version ${cliVersion}; expected ${version}.`);
|
||||
}
|
||||
|
||||
const launcher = JSON.parse(fs.readFileSync(packagePaths[0], "utf8"));
|
||||
for (const [dependency, dependencyVersion] of Object.entries(launcher.optionalDependencies ?? {})) {
|
||||
if (dependencyVersion !== version) {
|
||||
throw new Error(`${dependency} is pinned to ${dependencyVersion}; expected ${version}.`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tag}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `npm-tag=${npmTag}\n`);
|
||||
NODE
|
||||
|
||||
- name: Check npm token
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${NODE_AUTH_TOKEN}" ]; then
|
||||
echo "::error::NPM_TOKEN secret is required to publish @dbx-app/plugin-cli."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test Rust CLI
|
||||
run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
|
||||
- name: Prepare and test plugin development runtime
|
||||
run: |
|
||||
npm ci --prefix plugins/sdk/dev-host
|
||||
npm test --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Test npm launcher
|
||||
run: npm --prefix packages/plugin-cli test
|
||||
|
||||
- name: Verify packed npm installation
|
||||
env:
|
||||
DBX_PLUGIN_CLI_VERIFY_NATIVE: "1"
|
||||
run: node scripts/verify-plugin-cli-package.mjs
|
||||
|
||||
- name: Create immutable source tag
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
git fetch origin --tags --force
|
||||
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
TAG_SHA="$(git rev-list -n 1 "refs/tags/${TAG}")"
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
if [ "${TAG_SHA}" != "${HEAD_SHA}" ]; then
|
||||
echo "::error::${TAG} already points to ${TAG_SHA}, not ${HEAD_SHA}."
|
||||
exit 1
|
||||
fi
|
||||
echo "${TAG} already points to this commit."
|
||||
else
|
||||
git tag "${TAG}" HEAD
|
||||
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||
fi
|
||||
|
||||
refresh-lockfile:
|
||||
name: Refresh pnpm lockfile
|
||||
needs: prepare
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Sync plugin-cli lockfile specifiers
|
||||
id: sync
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
const version = process.env.VERSION;
|
||||
const manifest = JSON.parse(fs.readFileSync("packages/plugin-cli/package.json", "utf8"));
|
||||
if (manifest.version !== version) {
|
||||
console.log(`main pins plugin-cli ${manifest.version}; release ${version} does not need a lockfile sync.`);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, "changed=false\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const expected = Object.keys(manifest.optionalDependencies ?? {});
|
||||
const lockPath = "pnpm-lock.yaml";
|
||||
const lines = fs.readFileSync(lockPath, "utf8").split("\n");
|
||||
let inImporter = false;
|
||||
let specifiers = 0;
|
||||
let changed = 0;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (/^ {2}\S/.test(line)) {
|
||||
inImporter = line === " packages/plugin-cli:";
|
||||
continue;
|
||||
}
|
||||
if (!inImporter) continue;
|
||||
const match = line.match(/^( {8}specifier: )(.+)$/);
|
||||
if (!match) continue;
|
||||
specifiers += 1;
|
||||
if (match[2] !== version) {
|
||||
lines[i] = match[1] + version;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
if (specifiers !== expected.length) {
|
||||
throw new Error(`pnpm-lock.yaml has ${specifiers} plugin-cli specifiers; manifest declares ${expected.length}.`);
|
||||
}
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed > 0}\n`);
|
||||
if (changed > 0) {
|
||||
fs.writeFileSync(lockPath, lines.join("\n"));
|
||||
console.log(`Updated ${changed} plugin-cli specifiers to ${version}.`);
|
||||
} else {
|
||||
console.log("plugin-cli lockfile specifiers already match; nothing to do.");
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Commit refreshed lockfile
|
||||
if: steps.sync.outputs.changed == 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore(plugin-cli): refresh pnpm lockfile for ${VERSION}"
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
echo "::notice::Refreshed pnpm-lock.yaml plugin-cli specifiers to ${VERSION}."
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt} rejected; rebasing onto origin/main."
|
||||
git pull --rebase origin main
|
||||
done
|
||||
echo "::error::Unable to push the lockfile refresh after 3 attempts."
|
||||
exit 1
|
||||
|
||||
publish-platforms:
|
||||
name: Publish ${{ matrix.package-name }}
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
target: aarch64-apple-darwin
|
||||
package-dir: plugin-cli-darwin-arm64
|
||||
package-name: "@dbx-app/plugin-cli-darwin-arm64"
|
||||
binary: dbx-plugin
|
||||
- runner: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
package-dir: plugin-cli-darwin-x64
|
||||
package-name: "@dbx-app/plugin-cli-darwin-x64"
|
||||
binary: dbx-plugin
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
package-dir: plugin-cli-linux-arm64-gnu
|
||||
package-name: "@dbx-app/plugin-cli-linux-arm64-gnu"
|
||||
binary: dbx-plugin
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package-dir: plugin-cli-linux-x64-gnu
|
||||
package-name: "@dbx-app/plugin-cli-linux-x64-gnu"
|
||||
binary: dbx-plugin
|
||||
- runner: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
package-dir: plugin-cli-win32-arm64
|
||||
package-name: "@dbx-app/plugin-cli-win32-arm64"
|
||||
binary: dbx-plugin.exe
|
||||
- runner: windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
package-dir: plugin-cli-win32-x64
|
||||
package-name: "@dbx-app/plugin-cli-win32-x64"
|
||||
binary: dbx-plugin.exe
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install Linux compatibility builder
|
||||
if: runner.os == 'Linux'
|
||||
run: pip install ziglang==0.14.0 cargo-zigbuild==0.23.0
|
||||
|
||||
- name: Build precompiled CLI
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target/plugin-cli
|
||||
run: |
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
cargo zigbuild --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}.2.31"
|
||||
else
|
||||
cargo build --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- name: Stage platform package
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "packages/${{ matrix.package-dir }}/bin"
|
||||
cp "target/plugin-cli/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
|
||||
if [[ "${{ runner.os }}" != "Windows" ]]; then
|
||||
chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Publish platform package
|
||||
shell: bash
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
|
||||
run: |
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists; refreshing ${NPM_TAG}."
|
||||
npm dist-tag add "${{ matrix.package-name }}@${VERSION}" "${NPM_TAG}"
|
||||
else
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance --tag "${NPM_TAG}"
|
||||
fi
|
||||
|
||||
publish-launcher:
|
||||
name: Publish @dbx-app/plugin-cli
|
||||
needs: [prepare, publish-platforms]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Prepare development runtime build dependencies
|
||||
run: npm ci --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Wait for platform packages
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
wait_for_package() {
|
||||
package="$1"
|
||||
for attempt in $(seq 1 60); do
|
||||
if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for ${package}@${VERSION} (${attempt}/60)."
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
for package in \
|
||||
@dbx-app/plugin-cli-darwin-arm64 \
|
||||
@dbx-app/plugin-cli-darwin-x64 \
|
||||
@dbx-app/plugin-cli-linux-arm64-gnu \
|
||||
@dbx-app/plugin-cli-linux-x64-gnu \
|
||||
@dbx-app/plugin-cli-win32-arm64 \
|
||||
@dbx-app/plugin-cli-win32-x64; do
|
||||
wait_for_package "${package}" || {
|
||||
echo "::error::${package}@${VERSION} is unavailable; refusing to publish the launcher."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Publish launcher and bundled SDK
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
|
||||
run: |
|
||||
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "@dbx-app/plugin-cli@${VERSION} already exists; refreshing ${NPM_TAG}."
|
||||
npm dist-tag add "@dbx-app/plugin-cli@${VERSION}" "${NPM_TAG}"
|
||||
else
|
||||
npm publish ./packages/plugin-cli --access public --provenance --tag "${NPM_TAG}"
|
||||
fi
|
||||
|
||||
verify-published-package:
|
||||
name: Verify clean npm installation
|
||||
needs: [prepare, publish-launcher]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
cache: false
|
||||
|
||||
- name: Install from npm and package every template
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
work="$(mktemp -d)"
|
||||
cd "${work}"
|
||||
npm init -y >/dev/null
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [ "${attempt}" -eq 60 ]; then
|
||||
echo "::error::@dbx-app/plugin-cli@${VERSION} did not become visible on npm."
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
npm install --ignore-scripts --no-audit --no-fund "@dbx-app/plugin-cli@${VERSION}"
|
||||
./node_modules/.bin/dbx-plugin --version
|
||||
|
||||
for template in frontend rust go; do
|
||||
./node_modules/.bin/dbx-plugin create "${template}-plugin" \
|
||||
--template "${template}" \
|
||||
--yes \
|
||||
--id "com.example.npm-${template}" \
|
||||
--name "npm ${template} smoke" \
|
||||
--publisher example \
|
||||
--description "Published npm package smoke"
|
||||
./node_modules/.bin/dbx-plugin package "${template}-plugin"
|
||||
test "$(find "${template}-plugin/dist" -maxdepth 1 -name '*.dbxp' | wc -l)" -eq 1
|
||||
done
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Plugin development host
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitattributes'
|
||||
- 'plugins/sdk/cli/**'
|
||||
- 'plugins/sdk/dev-host/**'
|
||||
- 'packages/plugin-cli/**'
|
||||
- 'scripts/verify-plugin-cli-package.mjs'
|
||||
- '.github/workflows/plugin-dev-host.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, macos-15, windows-2022]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
- run: npm test --prefix plugins/sdk/dev-host
|
||||
- run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
- run: npm test --prefix packages/plugin-cli
|
||||
- run: node scripts/verify-plugin-cli-package.mjs
|
||||
env:
|
||||
DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'
|
||||
@@ -0,0 +1,218 @@
|
||||
name: Reusable DBX plugin release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release-tag:
|
||||
description: Existing GitHub Release tag that receives the plugin assets
|
||||
required: true
|
||||
type: string
|
||||
package-command:
|
||||
description: Command that builds one unsigned candidate .dbxp and one .artifact.json for DBX_PLUGIN_TARGET
|
||||
required: true
|
||||
type: string
|
||||
working-directory:
|
||||
description: Plugin repository directory containing the package command
|
||||
default: .
|
||||
required: false
|
||||
type: string
|
||||
package-path:
|
||||
description: Glob relative to working-directory for built .dbxp files
|
||||
default: dist/*.dbxp
|
||||
required: false
|
||||
type: string
|
||||
metadata-path:
|
||||
description: Glob relative to working-directory for built .artifact.json files
|
||||
default: dist/*.artifact.json
|
||||
required: false
|
||||
type: string
|
||||
node-version:
|
||||
default: "22"
|
||||
required: false
|
||||
type: string
|
||||
go-version:
|
||||
default: "1.22.x"
|
||||
required: false
|
||||
type: string
|
||||
rust-toolchain:
|
||||
default: stable
|
||||
required: false
|
||||
type: string
|
||||
sdk-ref:
|
||||
description: DBX tag or commit used only when installing the plugin CLI from source
|
||||
default: plugin-sdk-v1
|
||||
required: false
|
||||
type: string
|
||||
plugin-cli-version:
|
||||
description: Published @dbx-app/plugin-cli version used to package the plugin
|
||||
default: "0.1.2"
|
||||
required: false
|
||||
type: string
|
||||
install-plugin-cli:
|
||||
description: Install the dbx-plugin CLI before packaging
|
||||
default: true
|
||||
required: false
|
||||
type: boolean
|
||||
install-plugin-cli-from-source:
|
||||
description: Build the CLI from sdk-ref instead of installing its precompiled npm package
|
||||
default: false
|
||||
required: false
|
||||
type: boolean
|
||||
build-matrix:
|
||||
description: JSON matrix with runner and DBX target pairs; use one universal entry for platform-independent plugins
|
||||
default: >-
|
||||
{"include":[{"runner":"ubuntu-24.04","target":"linux-x64"},{"runner":"ubuntu-24.04-arm","target":"linux-arm64"},{"runner":"windows-2022","target":"windows-x64"},{"runner":"macos-15-intel","target":"darwin-x64"},{"runner":"macos-15","target":"darwin-arm64"}]}
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(inputs.build-matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ inputs.rust-toolchain }}
|
||||
|
||||
- name: Checkout DBX plugin SDK
|
||||
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: t8y2/dbx
|
||||
ref: ${{ inputs.sdk-ref }}
|
||||
path: .dbx-plugin-sdk
|
||||
|
||||
- name: Install precompiled DBX plugin CLI
|
||||
if: inputs.install-plugin-cli && !inputs.install-plugin-cli-from-source
|
||||
shell: bash
|
||||
run: npm install --global "@dbx-app/plugin-cli@${{ inputs.plugin-cli-version }}"
|
||||
|
||||
- name: Install DBX plugin CLI from source
|
||||
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
|
||||
shell: bash
|
||||
run: cargo install --locked --path .dbx-plugin-sdk/plugins/sdk/cli
|
||||
|
||||
- name: Build unsigned plugin candidate
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
DBX_PLUGIN_TARGET: ${{ matrix.target }}
|
||||
DBX_PLUGIN_SDK_ROOT: ${{ inputs.install-plugin-cli-from-source && format('{0}/.dbx-plugin-sdk', github.workspace) || '' }}
|
||||
run: ${{ inputs.package-command }}
|
||||
|
||||
- name: Upload target artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dbx-plugin-${{ matrix.target }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
path: |
|
||||
${{ inputs.working-directory }}/${{ inputs.package-path }}
|
||||
${{ inputs.working-directory }}/${{ inputs.metadata-path }}
|
||||
|
||||
publish:
|
||||
name: Publish plugin release assets
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download target artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: dbx-plugin-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Validate and merge candidate metadata
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const childProcess = require("node:child_process");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function filesUnder(root) {
|
||||
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||||
const current = path.join(root, entry.name);
|
||||
return entry.isDirectory() ? filesUnder(current) : [current];
|
||||
});
|
||||
}
|
||||
|
||||
const files = filesUnder("release-assets");
|
||||
const metadataFiles = files.filter((file) => file.endsWith(".artifact.json"));
|
||||
const packages = new Map();
|
||||
for (const file of files.filter((file) => file.endsWith(".dbxp"))) {
|
||||
const name = path.basename(file);
|
||||
if (packages.has(name)) throw new Error(`Duplicate package filename ${name}`);
|
||||
packages.set(name, file);
|
||||
}
|
||||
if (metadataFiles.length === 0 || packages.size === 0) throw new Error("Release produced no DBX plugin artifacts");
|
||||
|
||||
const targets = new Set();
|
||||
let pluginIdentity;
|
||||
const artifacts = metadataFiles.map((file) => {
|
||||
const artifact = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(artifact.target || "")) throw new Error(`Invalid artifact target in ${file}`);
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(artifact.sha256 || "")) throw new Error(`Invalid artifact SHA-256 in ${file}`);
|
||||
if (artifact.signingKeyId !== undefined) throw new Error(`Candidate metadata ${file} must not declare signingKeyId`);
|
||||
if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) throw new Error(`Invalid artifact size in ${file}`);
|
||||
const packageName = path.basename(new URL(artifact.url, "https://plugins.invalid/releases/").pathname);
|
||||
const packageFile = packages.get(packageName);
|
||||
if (!packageFile) throw new Error(`Artifact metadata ${file} references missing package ${packageName}`);
|
||||
const packageBytes = fs.readFileSync(packageFile);
|
||||
const packageSha256 = crypto.createHash("sha256").update(packageBytes).digest("hex");
|
||||
if (artifact.size !== packageBytes.length) throw new Error(`Artifact metadata ${file} has the wrong package size`);
|
||||
if (artifact.sha256.toLowerCase() !== packageSha256) throw new Error(`Artifact metadata ${file} has the wrong package SHA-256`);
|
||||
const entries = childProcess.execFileSync("unzip", ["-Z1", packageFile], { encoding: "utf8" }).split(/\r?\n/);
|
||||
if (entries.includes("signature.json")) throw new Error(`Candidate package ${packageName} must be unsigned`);
|
||||
const manifest = JSON.parse(childProcess.execFileSync("unzip", ["-p", packageFile, "manifest.json"], { encoding: "utf8" }));
|
||||
const identity = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
description: manifest.description || "",
|
||||
publisher: manifest.publisher,
|
||||
version: manifest.version,
|
||||
permissions: [...(manifest.permissions || [])].sort(),
|
||||
};
|
||||
if (!identity.id || !identity.name || !identity.publisher || !identity.version) throw new Error(`Package ${packageName} has incomplete manifest identity`);
|
||||
if (pluginIdentity && JSON.stringify(pluginIdentity) !== JSON.stringify(identity)) throw new Error(`Package ${packageName} manifest identity differs from other targets`);
|
||||
pluginIdentity ||= identity;
|
||||
if (targets.has(artifact.target)) throw new Error(`Duplicate artifact target ${artifact.target}`);
|
||||
targets.add(artifact.target);
|
||||
return artifact;
|
||||
}).sort((left, right) => left.target.localeCompare(right.target));
|
||||
|
||||
fs.writeFileSync("release-assets/release-candidates.json", `${JSON.stringify({ plugin: pluginIdentity, artifacts }, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload assets to GitHub Release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release-tag }}
|
||||
run: |
|
||||
mapfile -d '' packages < <(find release-assets -type f -name '*.dbxp' -print0)
|
||||
if [ "${#packages[@]}" -eq 0 ]; then
|
||||
echo "No .dbxp packages were downloaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${packages[@]}" release-assets/release-candidates.json \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
@@ -110,8 +110,7 @@ jobs:
|
||||
sha256 arm: "__SHA_ARM64__",
|
||||
intel: "__SHA_X64__"
|
||||
|
||||
url "https://github.com/__OWNER__/__APP__/releases/download/v#{version}/__PRODUCT___#{version}_#{arch}.dmg",
|
||||
verified: "github.com/__OWNER__/__APP__/"
|
||||
url "https://github.com/__OWNER__/__APP__/releases/download/v#{version}/__PRODUCT___#{version}_#{arch}.dmg"
|
||||
name "DBX"
|
||||
desc "Database management tool"
|
||||
homepage "https://dbxio.com/"
|
||||
@@ -239,6 +238,26 @@ jobs:
|
||||
--clobber
|
||||
ls -lh assets/
|
||||
|
||||
- name: Validate latest.json completeness
|
||||
run: |
|
||||
LATEST_JSON="assets/latest.json"
|
||||
if [ ! -f "$LATEST_JSON" ]; then
|
||||
echo "::error::latest.json not found in release assets; refusing to sync an incomplete manifest to R2"
|
||||
exit 1
|
||||
fi
|
||||
MISSING=""
|
||||
for p in darwin-aarch64 darwin-x86_64 windows-x86_64 windows-aarch64 linux-x86_64 linux-aarch64; do
|
||||
if ! jq -e --arg p "$p" '.platforms[$p] != null' "$LATEST_JSON" >/dev/null; then
|
||||
MISSING="${MISSING} ${p}"
|
||||
fi
|
||||
done
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "::error::latest.json is missing updater platforms:${MISSING}. Release builds may still be uploading assets; dispatch again after all release jobs finish."
|
||||
exit 1
|
||||
fi
|
||||
echo "Platform manifest complete:"
|
||||
jq -r '.platforms | keys[]' "$LATEST_JSON"
|
||||
|
||||
- name: Rewrite latest.json download URLs to R2 CDN
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
|
||||
@@ -23,21 +23,37 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# The ref is always the trusted base SHA; when a fork head is synced
|
||||
# to the same commit as the base, checkout v5 refuses the SHA because
|
||||
# it matches pull_request.head.sha. That commit is still trusted
|
||||
# content, so opt out of the guard here.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Fetch pull request head without checkout
|
||||
id: verify_head
|
||||
env:
|
||||
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
git fetch --no-tags origin "pull/${PULL_REQUEST_NUMBER}/head"
|
||||
test "$(git rev-parse FETCH_HEAD)" = "${EXPECTED_HEAD_SHA}"
|
||||
ACTUAL_HEAD_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$ACTUAL_HEAD_SHA" != "${EXPECTED_HEAD_SHA}" ]; then
|
||||
# The PR was pushed to after this event was queued; that push
|
||||
# triggers its own labeling run, so skip this stale one.
|
||||
echo "::notice::Pull request head is now ${ACTUAL_HEAD_SHA}, expected ${EXPECTED_HEAD_SHA}; skipping stale labeling run."
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.verify_head.outputs.stale != 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: Synchronize pull request labels
|
||||
if: steps.verify_head.outputs.stale != 'true'
|
||||
run: node .github/scripts/label-pull-request.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -7,8 +7,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows-x64:
|
||||
build-windows-7-x64:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -33,16 +37,25 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Setup Rust for standard Windows
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Check standard Windows dependency path
|
||||
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Setup Rust for Windows 7
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./ -> target"
|
||||
shared-key: sqlserver2000-windows-x64
|
||||
shared-key: sqlserver2000-windows-7-x64
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Build SQL Server legacy agent
|
||||
@@ -59,15 +72,63 @@ jobs:
|
||||
}
|
||||
Copy-Item $agent.FullName "sqlserver-legacy-agent.jar" -Force
|
||||
|
||||
- name: Build Windows x64 installer
|
||||
run: pnpm tauri build --bundles nsis --target x86_64-pc-windows-msvc --ci --no-sign
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
|
||||
- name: Prepare WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Probe WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Build DBX for Windows 7
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
|
||||
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
|
||||
|
||||
- name: Audit Windows 7 PE imports
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Audit Win7 WebView2 loader markers
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-webview2-win7-loader.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Bundle Windows 7 fixed-runtime installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (!$installer) {
|
||||
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
|
||||
exit 1
|
||||
}
|
||||
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
|
||||
|
||||
- name: Audit Windows 7 installer contents
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = Get-ChildItem "target/x86_64-win7-windows-msvc/release/bundle/nsis" -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
|
||||
|
||||
- name: Upload installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: DBX-SQLServer2000-Windows-x64
|
||||
name: DBX-SQLServer2000-Windows-7-x64
|
||||
path: |
|
||||
target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
target/x86_64-win7-windows-msvc/release/bundle/nsis/*.exe
|
||||
sqlserver-legacy-agent.jar
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
@@ -97,9 +97,15 @@ jobs:
|
||||
--overwrite-existing
|
||||
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
aws s3 cp "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" r2-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq --arg notes "$NOTES" '.notes = $notes' r2-latest.json > r2-latest.json.tmp
|
||||
mv r2-latest.json.tmp r2-latest.json
|
||||
# Derive the R2 manifest from the GitHub release latest.json (notes already
|
||||
# injected above) instead of re-reading the R2 copy, so every sync also
|
||||
# heals a stale or partial R2 manifest.
|
||||
jq --arg tag "$TAG" '
|
||||
(.platforms[]?.url?) |= (
|
||||
sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/latest/")
|
||||
+ "?v=" + $tag
|
||||
)
|
||||
' updater-assets/latest.json > r2-latest.json
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/${TAG}/latest.json" \
|
||||
--endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" \
|
||||
|
||||
+12
@@ -1,4 +1,5 @@
|
||||
# Dependencies
|
||||
openGauss-server/
|
||||
node_modules
|
||||
.claude/
|
||||
.codex/
|
||||
@@ -28,7 +29,11 @@ agents/drivers/etcd-go/etcd-go
|
||||
agents/drivers/etcd2-go/etcd2-go
|
||||
plugins/jdbc/.gradle/
|
||||
plugins/jdbc/build/
|
||||
# Java IDE compilation output (keep the launcher scripts in bin/ tracked).
|
||||
plugins/jdbc/bin/main/
|
||||
plugins/jdbc/bin/test/
|
||||
plugins/jdbc/dist/
|
||||
packages/plugin-cli/sdk-root/
|
||||
docs/.next/
|
||||
docs/out/
|
||||
docs/.source/
|
||||
@@ -62,14 +67,19 @@ Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.dbx-plugin-signing-key.env
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
|
||||
# Local issue screenshots (never commit)
|
||||
issue-assets/
|
||||
|
||||
# AI background-run dev artifacts (never commit)
|
||||
.dlp-quarantine/
|
||||
outputs/
|
||||
target-ai-write-confirmation/
|
||||
.serena/
|
||||
|
||||
# Code review tool artifacts
|
||||
review_diff.json
|
||||
@@ -77,6 +87,7 @@ review_classified.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
/.dbx-dev/
|
||||
|
||||
|
||||
# Generated changelog data
|
||||
@@ -95,3 +106,4 @@ dbx-devtest-connections.json
|
||||
|
||||
# Real database integration credentials (see docs/TEST_ENVIRONMENTS.md)
|
||||
/docs/TEST_ENVIRONMENTS.md
|
||||
.wrangler/
|
||||
|
||||
@@ -65,6 +65,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl
|
||||
| `crates/dbx-web/` | Docker / Web HTTP backend |
|
||||
| `packages/cli/` | `@dbx-app/cli` |
|
||||
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
|
||||
| `packages/plugin-cli/` | Precompiled `@dbx-app/plugin-cli` launcher and bundled plugin SDKs |
|
||||
| `packages/mongo-shell/` | Private MongoDB editor parsing helpers |
|
||||
| `docs/` | Official documentation site |
|
||||
| `examples/` | Sample configs and automation scripts |
|
||||
@@ -104,6 +105,13 @@ pnpm test
|
||||
|
||||
For frontend or package changes, run the relevant package tests under `packages/` or `packages/app-tests/`.
|
||||
|
||||
Test quality matters more than test count:
|
||||
|
||||
- Exercise production functions or mounted components and assert observable results, state changes, errors, or emitted events. Mock external boundaries, not the behavior under test.
|
||||
- Do not copy the implementation into a test or use source-string matching to pin class names, local variable names, template fragments, or helper-call spelling. These checks break on harmless refactors without proving runtime behavior. Check layout in a browser rather than inferring it from CSS strings.
|
||||
- Extend the existing behavior suite for a regression instead of adding a second source-wiring snapshot. Use table-driven cases when only the inputs and expected outputs differ.
|
||||
- File-content checks are appropriate for shipped artifacts, permissions, compatibility rules, and cross-runtime contracts. Keep safety guards until equivalent behavior coverage exists; do not delete a test merely because it reads files, uses mocks, or runs slowly.
|
||||
|
||||
### Documentation
|
||||
|
||||
User-facing docs live in two places:
|
||||
|
||||
@@ -104,6 +104,13 @@ pnpm test
|
||||
|
||||
如果改的是前端或某个 package,再补跑对应目录下的测试。
|
||||
|
||||
测试质量比测试数量更重要:
|
||||
|
||||
- 调用生产函数或挂载真实组件,验证可观察的结果、状态变化、错误或事件。Mock 外部边界,不要 Mock 正在验证的行为。
|
||||
- 不要在测试中复制实现,也不要用源码字符串匹配锁定 class、局部变量名、模板片段或辅助函数调用写法。这类检查会阻碍无害重构,却不能证明运行行为;布局应在浏览器中验证,而不是从 CSS 字符串推断。
|
||||
- 回归用例优先补进已有行为测试,不再另加一份源码接线快照。只有输入和预期不同的场景优先使用参数化用例。
|
||||
- 发布产物、权限、兼容性规则和跨运行时契约可以使用文件内容检查。安全门禁在有等价行为覆盖前保留,不要仅因测试读文件、使用 Mock 或运行较慢就删除。
|
||||
|
||||
### 文档
|
||||
|
||||
用户文档主要分两块:
|
||||
|
||||
Generated
+95
-20
@@ -1701,6 +1701,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc16"
|
||||
version = "0.4.0"
|
||||
@@ -1916,6 +1931,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto 0.2.9",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
@@ -2002,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.4"
|
||||
version = "0.6.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2035,6 +2051,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
@@ -2048,6 +2065,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"tiberius",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
@@ -2060,7 +2078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.80"
|
||||
version = "0.4.89"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
@@ -2088,15 +2106,17 @@ dependencies = [
|
||||
"calamine",
|
||||
"cbc 0.1.2",
|
||||
"chrono",
|
||||
"crc",
|
||||
"csv",
|
||||
"dbx-sqlite-worker",
|
||||
"deadpool-postgres",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"font-kit",
|
||||
"fs2",
|
||||
"futures",
|
||||
"httpdate",
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"json5",
|
||||
"jsonwebtoken",
|
||||
@@ -2125,10 +2145,12 @@ dependencies = [
|
||||
"rust_decimal",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serde_yaml_ng",
|
||||
"sha1 0.10.7",
|
||||
"sha2 0.10.9",
|
||||
"sqlparser",
|
||||
"ssfmt",
|
||||
@@ -2156,7 +2178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.80"
|
||||
version = "0.4.89"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2191,7 +2213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.4"
|
||||
version = "0.6.14"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -2210,6 +2232,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
@@ -2586,19 +2609,43 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8 0.10.2",
|
||||
"signature 2.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"signature 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek 4.1.3",
|
||||
"ed25519 2.2.3",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2608,11 +2655,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
|
||||
dependencies = [
|
||||
"curve25519-dalek 5.0.0-pre.6",
|
||||
"ed25519",
|
||||
"ed25519 3.0.0-rc.4",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2637,7 +2684,7 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
"once_cell",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"rustcrypto-ff",
|
||||
"rustcrypto-group",
|
||||
@@ -3034,6 +3081,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -4209,7 +4266,7 @@ dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"crypto-bigint",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint-dig",
|
||||
@@ -4221,7 +4278,7 @@ dependencies = [
|
||||
"sec1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -6096,6 +6153,16 @@ dependencies = [
|
||||
"spki 0.8.0-rc.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0-rc.11"
|
||||
@@ -6850,6 +6917,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -7052,10 +7120,10 @@ dependencies = [
|
||||
"crypto-primes",
|
||||
"digest 0.11.3",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -7120,7 +7188,7 @@ dependencies = [
|
||||
"der 0.8.1",
|
||||
"digest 0.10.7",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"elliptic-curve",
|
||||
"enum_dispatch",
|
||||
"flate2",
|
||||
@@ -7149,7 +7217,7 @@ dependencies = [
|
||||
"pbkdf2 0.13.0",
|
||||
"pkcs1",
|
||||
"pkcs5",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"polyval 0.7.3",
|
||||
"rand 0.10.2",
|
||||
"rand_core 0.10.1",
|
||||
@@ -7164,7 +7232,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.0",
|
||||
"sha3",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -8033,6 +8101,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
@@ -8846,8 +8923,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
|
||||
+11
-2
@@ -1,9 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
|
||||
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
|
||||
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
|
||||
|
||||
[patch.crates-io]
|
||||
# Offline signed update restoration and checked Windows installer launch.
|
||||
tauri-plugin-updater = { path = "vendor/tauri-plugin-updater" }
|
||||
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
|
||||
# upstream rust-ctor#443 until tauri-utils moves to ctor 1.x.
|
||||
ctor = { path = "vendor/ctor" }
|
||||
@@ -35,9 +37,16 @@ postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git"
|
||||
# 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.
|
||||
# UTF-16 surrogate, or varchar/text bytes that are invalid in the column's
|
||||
# collation (replaced with U+FFFD, no BOM sniffing). Remove after the behavior
|
||||
# is available in upstream tiberius.
|
||||
tiberius = { path = "vendor/tiberius" }
|
||||
|
||||
[profile.dev.package."*"]
|
||||
# Dependencies are never breakpoint-inspected in dev; line tables keep
|
||||
# backtraces resolvable while shrinking dev/test build time and artifacts.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<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 style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
|
||||
@@ -8,7 +8,8 @@
|
||||
<p>
|
||||
<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://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="Join WeChat Group" /></a>
|
||||
<a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-Join-3370FF?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="Join Feishu Group" /></a>
|
||||
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="Join Discord" /></a>
|
||||
</p>
|
||||
<p>
|
||||
@@ -111,6 +112,17 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">
|
||||
<img src="docs/public/sponsors/hualong-card.png" alt="HuaLongAI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HuaLongAI is a model API relay built for heavy AI developers, offering 100% official-source Codex and Claude models with transparent token-level billing, enterprise contracts, and invoicing.
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">Visit HuaLongAI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -167,7 +179,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🪶 20 MB, zero runtime bloat</h3>
|
||||
<h3>🪶 25 MB, zero runtime bloat</h3>
|
||||
<p>No Java JRE. No Python venv. No bundled Chromium. DBX ships as a single small binary — download, install, connect. DBeaver needs Java; TablePlus is Freemium. DBX runs everywhere with nothing extra.</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -191,7 +203,7 @@
|
||||
|
||||
### 90+ Databases, One Tool
|
||||
|
||||
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.
|
||||
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 ~25 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
@@ -447,6 +459,8 @@ make docs
|
||||
|
||||
The official DBX documentation site lives in `docs/`. If you want to improve the website content or documentation pages, edit the files under `docs/` and run `make docs` to preview the site locally.
|
||||
|
||||
Plugin authors should start with [Develop and Submit DBX Plugins](https://dbxio.com/en/docs/plugin-development). Plugin source normally stays in its own repository; Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), while plugin host, SDK, and CLI changes go to this repository.
|
||||
|
||||
For clean, reproducible local database instances, use the versioned Docker Compose recipes under [`deploy/database/`](deploy/database/README.md):
|
||||
|
||||
```bash
|
||||
@@ -484,6 +498,7 @@ The installer will be in `src-tauri/target/release/bundle/`.
|
||||
## Documentation
|
||||
|
||||
- [Official docs](https://dbxio.com/en/docs/what-is-dbx) — feature guides and tutorials
|
||||
- [Plugin development](https://dbxio.com/en/docs/plugin-development) — create plugins and submit Marketplace listings to the correct repository
|
||||
- [Database Test Lab](https://dbxio.com/en/docs/database-lab) — local database recipes for development and verification
|
||||
- [Contributing](CONTRIBUTING.md) — how to pick up issues and open PRs
|
||||
- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments
|
||||
@@ -506,7 +521,8 @@ DBX is free and open source, but ongoing maintenance, database compatibility tes
|
||||
|
||||
<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>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-Join-07C160?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="WeChat Group" /></a><a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-Join-3370FF?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="Feishu Group" /></a>
|
||||
|
||||
[](https://linux.do)
|
||||
[](https://1panel.cn)
|
||||
|
||||
|
||||
+20
-7
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
|
||||
@@ -8,7 +8,8 @@
|
||||
<p>
|
||||
<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://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-点击加入-07C160?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="加入微信群" /></a>
|
||||
<a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-点击加入-3370FF?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="加入飞书群" /></a>
|
||||
<a href="https://discord.gg/W7NyVDRt6a"><img src="https://dcbadge.limes.pink/api/server/W7NyVDRt6a" alt="加入 Discord" /></a>
|
||||
</p>
|
||||
<p>
|
||||
@@ -111,6 +112,17 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">
|
||||
<img src="docs/public/sponsors/hualong-card.png" alt="HuaLongAI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HuaLongAI(华龙算力)是面向重度 AI 开发者的模型 API 中转服务商,主营 Codex 与 Claude 系列模型,100% 官方源直供、不掺假;计费透明,Token 级账单可逐笔核验,支持企业合同与发票。
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">访问 HuaLongAI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -167,7 +179,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🪶 20 MB,极致轻量</h3>
|
||||
<h3>🪶 25 MB,极致轻量</h3>
|
||||
<p>无需 Java 运行环境,无需 Python 虚拟环境,不内嵌 Chromium。DBX 是单个小巧的二进制文件——下载、安装、连接。DBeaver 依赖 Java;TablePlus 是 Freemium。DBX 全平台可用,无需额外运行时。</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -191,7 +203,7 @@
|
||||
|
||||
### 90+ 种数据库,一个工具搞定
|
||||
|
||||
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。
|
||||
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 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -495,7 +507,8 @@ make package
|
||||
|
||||
<a href="https://discord.gg/W7NyVDRt6a" target="_blank"><img src="https://img.shields.io/badge/Discord-加入-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 群" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信交流群-点击加入-07C160?logo=wechat&logoColor=white" alt="微信交流群" /></a>
|
||||
<a href="https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz" target="_blank"><img src="https://img.shields.io/badge/微信群-点击加入-07C160?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0iIzA3YzE2MCIgZD0iTTguNjkxIDIuMTg4QzMuODkxIDIuMTg4IDAgNS40NzYgMCA5LjUzYzAgMi4yMTIgMS4xNyA0LjIwMyAzLjAwMiA1LjU1YS41OS41OSAwIDAgMSAuMjEzLjY2NWwtLjM5IDEuNDhjLS4wMTkuMDctLjA0OC4xNDEtLjA0OC4yMTNjMCAuMTYzLjEzLjI5NS4yOS4yOTVhLjMzLjMzIDAgMCAwIC4xNjctLjA1NGwxLjkwMy0xLjExNGEuODYuODYgMCAwIDEgLjcxNy0uMDk4YTEwLjIgMTAuMiAwIDAgMCAyLjgzNy40MDNjLjI3NiAwIC41NDMtLjAyNy44MTEtLjA1Yy0uODU3LTIuNTc4LjE1Ny00Ljk3MiAxLjkzMi02LjQ0NmMxLjcwMy0xLjQxNSAzLjg4Mi0xLjk4IDUuODUzLTEuODM4Yy0uNTc2LTMuNTgzLTQuMTk2LTYuMzQ4LTguNTk2LTYuMzQ4TTUuNzg1IDUuOTkxYy42NDIgMCAxLjE2Mi41MjkgMS4xNjIgMS4xOGExLjE3IDEuMTcgMCAwIDEtMS4xNjIgMS4xNzhBMS4xNyAxLjE3IDAgMCAxIDQuNjIzIDcuMTdjMC0uNjUxLjUyLTEuMTggMS4xNjItMS4xOHptNS44MTMgMGMuNjQyIDAgMS4xNjIuNTI5IDEuMTYyIDEuMThhMS4xNyAxLjE3IDAgMCAxLTEuMTYyIDEuMTc4YTEuMTcgMS4xNyAwIDAgMS0xLjE2Mi0xLjE3OGMwLS42NTEuNTItMS4xOCAxLjE2Mi0xLjE4bTUuMzQgMi44NjdjLTEuNzk3LS4wNTItMy43NDYuNTEyLTUuMjggMS43ODZjLTEuNzIgMS40MjgtMi42ODcgMy43Mi0xLjc4IDYuMjJjLjk0MiAyLjQ1MyAzLjY2NiA0LjIyOSA2Ljg4NCA0LjIyOWMuODI2IDAgMS42MjItLjEyIDIuMzYxLS4zMzZhLjcyLjcyIDAgMCAxIC41OTguMDgybDEuNTg0LjkyNmEuMy4zIDAgMCAwIC4xNC4wNDdjLjEzNCAwIC4yNC0uMTExLjI0LS4yNDdjMC0uMDYtLjAyMy0uMTItLjAzOC0uMTc3bC0uMzI3LTEuMjMzYS42LjYgMCAwIDEtLjAyMy0uMTU2YS40OS40OSAwIDAgMSAuMjAxLS4zOThDMjMuMDI0IDE4LjQ4IDI0IDE2LjgyIDI0IDE0Ljk4YzAtMy4yMS0yLjkzMS01LjgzNy02LjY1Ni02LjA4OFY4Ljg5Yy0uMTM1LS4wMS0uMjctLjAyNy0uNDA3LS4wM3ptLTIuNTMgMy4yNzRjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk3LS45ODJ6bTQuODQ0IDBjLjUzNSAwIC45NjkuNDQuOTY5Ljk4MmEuOTc2Ljk3NiAwIDAgMS0uOTY5Ljk4M2EuOTc2Ljk3NiAwIDAgMS0uOTY5LS45ODNjMC0uNTQyLjQzNC0uOTgyLjk2OS0uOTgyIi8%2BPC9zdmc%2B" alt="微信群" /></a><a href="https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=30cvb14f-a9b1-4b12-adb6-2ff6d476a227" target="_blank"><img src="https://img.shields.io/badge/飞书群-点击加入-3370FF?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjE2NCAyMDQgNzYyIDYxNyI%2BPHBhdGggZmlsbD0iIzAwRDZCOSIgZD0iTTU1OS45MTUgNTMwLjQ1M2MtNDYuNTA3LTExMS43ODYtMTk0LjU2LTI0OC40NjktMjYyLjgwNi0zMDIuODI2aDMzMy43ODJjNDcuMTQ2IDE2LjI5OCA4Ny42MTYgMTM0LjY3NyAxMDEuOTczIDE5MS44MDgtMzUuNDk5IDMxLjIxLTExOS43ODcgOTcuMTA5LTE3Mi45NSAxMTEuMDE4eiIvPjxwYXRoIGZpbGw9IiMxMzNDOUEiIGQ9Ik02MzIuMDIxIDQ1Mi45OTJjLTQ1LjE4NCA2MC40OC0xMzMuNTQ2IDEyMS45NjMtMTcyLjA1MyAxNDUuMTNsLTIuODggMjQuMjc4IDIzNS45NDcgNjMuNjM3YzMyLjIxMy0yNS45NjIgMTAzLjA2MS04Ny4yOTYgMTI4Ljk2LTEyNC45MjggNC4zOTQtNi4zNzggNjguOTkyLTEzNS45MTQgNzkuNDAyLTE1MS41NTItMTguMjQtMTEuMzA2LTQyLjU2LTE4LjI2MS0xMDQuMjc3LTIxLjczOC04Mi41Ni00LjMzMS0xMTYuNDM3IDIwLjg2NC0xNjUuMDk5IDY1LjE3M3oiLz48cGF0aCBmaWxsPSIjMzM3MEZGIiBkPSJNMTg3Ljg4MyA3MTIuOTE3VjM5My41MTVDMzk3LjU2OCA1OTkuODA4IDU1OC4zMTUgNjQyLjY4OCA2NDEuMDQ1IDY1My43NmMxMjQuNDU5IDUuNDE5IDE1NC42NjctNzMuMDQ1IDE4MS4xNDItOTMuMDk5LTk3LjAyNCAxNTMuMTc0LTIyNC42NCAyMzUuNzM0LTM4NC43NDcgMjM1LjczNC0xMjguMTA3IDAtMjE5Ljc1NS01NS42NTktMjQ5LjU1Ny04My40Nzh6Ii8%2BPC9zdmc%2B" alt="飞书群" /></a>
|
||||
|
||||
[](https://linux.do)
|
||||
[](https://1panel.cn)
|
||||
|
||||
@@ -515,7 +528,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
||||
|
||||
<details>
|
||||
<summary><strong>DBX 会收集用户数据吗?</strong></summary>
|
||||
不会。DBX 不收集任何遥测数据。自动更新功能仅检查 GitHub Releases 获取新版本——你可以在设置中禁用它。
|
||||
不会。DBX 不收集任何遥测数据。开启更新通知时,桌面端会通过所选更新源检查新版本并静默下载安装包;下载并校验完成后,更新入口显示提示,只有点击“重启并更新”才会安装。已下载的安装包会保留到下次启动,也可忽略该版本。你可以在设置中关闭自动检查和下载。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -526,7 +539,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
||||
|
||||
<details>
|
||||
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
|
||||
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
|
||||
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -26,5 +26,8 @@ Security-sensitive areas include:
|
||||
- AI provider keys and OpenAI-compatible endpoint configuration.
|
||||
- MCP and CLI access to local DBX connections.
|
||||
- Docker web service authentication and data directory handling.
|
||||
- Plugin package verification, native sidecar execution, sandboxed plugin UI, host bridge permissions, and plugin connection secrets.
|
||||
|
||||
Plugin connection secrets are stored outside ordinary connection JSON. Unencrypted sync snapshots are always redacted. If encrypted secret sync is enabled with a user-provided passphrase, plugin secrets are included only in the encrypted payload and are restored through the normal secret-store path.
|
||||
|
||||
Please avoid testing against systems you do not own or have explicit permission to assess.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
**/libs/*.jar
|
||||
!drivers/cache/libs/CacheDB.jar
|
||||
!drivers/oscar/libs/oscarJDBC8.jar
|
||||
!drivers/uxdb/libs/uxdbjdbc-2.1.2.3p.jre8.jar
|
||||
gradle.properties
|
||||
|
||||
@@ -50,6 +50,8 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
|
||||
| rabbitmq | RabbitMQ | amqp091-go native agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
|
||||
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
|
||||
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
|
||||
|
||||
|
||||
## Multi-JRE Support
|
||||
|
||||
@@ -49,6 +49,8 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
||||
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
|
||||
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
|
||||
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
|
||||
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
|
||||
|
||||
|
||||
## 多 JRE 支持
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
|
||||
def pooledJdbcProjects = [
|
||||
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
|
||||
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
|
||||
'ignite', 'ignite3', 'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'ignite', 'ignite3', 'informix', 'iris', 'cache', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'vertica', 'yashandb'
|
||||
] as Set
|
||||
|
||||
@@ -255,13 +255,19 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
|
||||
@Override
|
||||
public QueryResult executeTransaction(List<String> statements, String schema) {
|
||||
return TransactionExecutor.executeUpdateStatements(
|
||||
requireConnected(),
|
||||
statements,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
this::resetSchemaSQL
|
||||
);
|
||||
Connection conn = requireConnected();
|
||||
return unchecked(() -> {
|
||||
if (!conn.getAutoCommit()) {
|
||||
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
|
||||
}
|
||||
return TransactionExecutor.executeUpdateStatements(
|
||||
conn,
|
||||
statements,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
this::resetSchemaSQL
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -377,7 +383,9 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
JdbcExecutor executor,
|
||||
boolean succeeded,
|
||||
boolean requiresSessionAffinity,
|
||||
boolean evictAfterRequest
|
||||
boolean evictAfterRequest,
|
||||
boolean endsSessionAffinity,
|
||||
boolean preservesSchemaContext
|
||||
) {
|
||||
if (poolRegistry == null) {
|
||||
return;
|
||||
@@ -389,7 +397,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
}
|
||||
if (succeeded && requiresSessionAffinity) {
|
||||
sessionAffinity = true;
|
||||
JdbcSchemaSwitcher.forget(connection);
|
||||
if (!preservesSchemaContext) {
|
||||
JdbcSchemaSwitcher.forget(connection);
|
||||
}
|
||||
}
|
||||
if (succeeded && endsSessionAffinity) {
|
||||
sessionAffinity = false;
|
||||
}
|
||||
if (pooledLease == null) {
|
||||
connection = null;
|
||||
@@ -441,7 +454,57 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
}
|
||||
try {
|
||||
String quote = connection.getMetaData().getIdentifierQuoteString();
|
||||
return quote == null || quote.trim().isEmpty() ? "" : quote.trim();
|
||||
if (quote == null || quote.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return sanitizeInformixFamilyBacktickQuote(params, connection, quote.trim());
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// GBase 8s and other Informix-family JDBC drivers report "`" from
|
||||
// getIdentifierQuoteString() even though their servers reject backtick-quoted
|
||||
// identifiers with "illegal character in statement" outside MySQL compatibility
|
||||
// mode (SQLMODE=mysql). An empty quote makes clients emit unquoted identifiers,
|
||||
// which both SQL modes accept.
|
||||
private static String sanitizeInformixFamilyBacktickQuote(ConnectParams params, Connection connection, String quote) {
|
||||
if (!"`".equals(quote) || !isInformixFamilyJdbc(params, connection) || isInformixFamilyMysqlCompat(params, connection)) {
|
||||
return quote;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static boolean isInformixFamilyJdbc(ConnectParams params, Connection connection) {
|
||||
StringBuilder identity = new StringBuilder();
|
||||
appendJdbcIdentity(identity, params.getConnection_string());
|
||||
appendJdbcIdentity(identity, params.getJdbc_driver_class());
|
||||
List<String> driverPaths = params.getJdbc_driver_paths();
|
||||
if (driverPaths != null) {
|
||||
for (String path : driverPaths) {
|
||||
appendJdbcIdentity(identity, path);
|
||||
}
|
||||
}
|
||||
appendJdbcIdentity(identity, connectionUrl(connection));
|
||||
String normalized = identity.toString().toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("jdbc:gbasedbt")
|
||||
|| normalized.contains("jdbc:informix")
|
||||
|| normalized.contains("com.gbasedbt")
|
||||
|| normalized.contains("com.informix");
|
||||
}
|
||||
|
||||
private static boolean isInformixFamilyMysqlCompat(ConnectParams params, Connection connection) {
|
||||
if (params.isMysql_compat_mode()) {
|
||||
return true;
|
||||
}
|
||||
String identity = (params.getConnection_string() + "\n" + connectionUrl(connection)).toLowerCase(Locale.ROOT);
|
||||
return identity.contains("sqlmode=mysql");
|
||||
}
|
||||
|
||||
private static String connectionUrl(Connection connection) {
|
||||
try {
|
||||
String url = connection.getMetaData().getURL();
|
||||
return url == null ? "" : url;
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -68,8 +68,12 @@ public final class AgentProtocol {
|
||||
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_START_FIND_CURSOR = "start_find_cursor";
|
||||
public static final String MONGO_METHOD_FETCH_FIND_CURSOR = "fetch_find_cursor";
|
||||
public static final String MONGO_METHOD_CLOSE_FIND_CURSOR = "close_find_cursor";
|
||||
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_REPLACE_DOCUMENT = "replace_document";
|
||||
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";
|
||||
@@ -127,6 +131,8 @@ public final class AgentProtocol {
|
||||
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_MONGO_REPLACE_DOCUMENT = "mongo_replace_document";
|
||||
public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor";
|
||||
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
|
||||
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
|
||||
|
||||
@@ -162,7 +168,9 @@ public final class AgentProtocol {
|
||||
CAPABILITY_MONGO_DROP_DATABASE,
|
||||
CAPABILITY_MONGO_CLONE_COLLECTION,
|
||||
CAPABILITY_MONGO_RUN_COMMAND,
|
||||
CAPABILITY_MONGO_INSERT_DOCUMENTS
|
||||
CAPABILITY_MONGO_INSERT_DOCUMENTS,
|
||||
CAPABILITY_MONGO_REPLACE_DOCUMENT,
|
||||
CAPABILITY_MONGO_FIND_CURSOR
|
||||
));
|
||||
|
||||
public static final List<String> MULTI_SESSION_CAPABILITIES;
|
||||
@@ -226,6 +234,8 @@ public final class AgentProtocol {
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_REPLACE_DOCUMENT);
|
||||
mongoCapabilities.add(CAPABILITY_MONGO_FIND_CURSOR);
|
||||
MONGO_LEGACY_CAPABILITIES = Collections.unmodifiableList(mongoCapabilities);
|
||||
|
||||
List<String> mongoMultiSessionCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
|
||||
@@ -233,6 +243,8 @@ public final class AgentProtocol {
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_CLONE_COLLECTION);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_RUN_COMMAND);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_INSERT_DOCUMENTS);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_REPLACE_DOCUMENT);
|
||||
mongoMultiSessionCapabilities.add(CAPABILITY_MONGO_FIND_CURSOR);
|
||||
MONGO_LEGACY_MULTI_SESSION_CAPABILITIES = Collections.unmodifiableList(mongoMultiSessionCapabilities);
|
||||
|
||||
List<String> jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES);
|
||||
@@ -274,9 +286,13 @@ public final class AgentProtocol {
|
||||
MONGO_METHOD_INSERT_DOCUMENTS,
|
||||
MONGO_METHOD_UPDATE_DOCUMENT,
|
||||
MONGO_METHOD_UPDATE_DOCUMENTS,
|
||||
MONGO_METHOD_REPLACE_DOCUMENT,
|
||||
MONGO_METHOD_DELETE_DOCUMENT,
|
||||
MONGO_METHOD_DELETE_DOCUMENTS,
|
||||
MONGO_METHOD_RUN_COMMAND
|
||||
MONGO_METHOD_RUN_COMMAND,
|
||||
MONGO_METHOD_START_FIND_CURSOR,
|
||||
MONGO_METHOD_FETCH_FIND_CURSOR,
|
||||
MONGO_METHOD_CLOSE_FIND_CURSOR
|
||||
));
|
||||
|
||||
public static final List<String> KV_METHODS = Collections.unmodifiableList(Arrays.asList(
|
||||
|
||||
@@ -47,7 +47,7 @@ public abstract class ConfiguredJdbcAgent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), profile);
|
||||
return StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), profile, configuredDatabase);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -224,13 +224,71 @@ public interface DatabaseAgent {
|
||||
if (conn == null) {
|
||||
throw new IllegalStateException("Not connected");
|
||||
}
|
||||
return TransactionExecutor.executeUpdateStatements(
|
||||
conn,
|
||||
statements,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
this::resetSchemaSQL
|
||||
);
|
||||
return unchecked(() -> {
|
||||
if (!conn.getAutoCommit()) {
|
||||
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
|
||||
}
|
||||
return TransactionExecutor.executeUpdateStatements(
|
||||
conn,
|
||||
statements,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
this::resetSchemaSQL
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts an interactive transaction on the current JDBC connection. */
|
||||
default Map<String, Object> beginManualTransaction(String schema) {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) {
|
||||
throw new IllegalStateException("Not connected");
|
||||
}
|
||||
return unchecked(() -> {
|
||||
if (!conn.getAutoCommit()) {
|
||||
throw new IllegalStateException("Manual transaction already open");
|
||||
}
|
||||
if (schema != null && !schema.trim().isEmpty()) {
|
||||
JdbcSchemaSwitcher.apply(conn, schema, this::setSchemaSQL, this::resetSchemaSQL);
|
||||
}
|
||||
conn.setAutoCommit(false);
|
||||
JdbcSchemaSwitcher.preserve(conn);
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
});
|
||||
}
|
||||
|
||||
/** Commits an interactive transaction on the current JDBC connection. */
|
||||
default Map<String, Object> commitManualTransaction() {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) {
|
||||
throw new IllegalStateException("Not connected");
|
||||
}
|
||||
return unchecked(() -> {
|
||||
if (conn.getAutoCommit()) {
|
||||
throw new IllegalStateException("No manual transaction open");
|
||||
}
|
||||
conn.commit();
|
||||
conn.setAutoCommit(true);
|
||||
JdbcSchemaSwitcher.releasePreserved(conn);
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
});
|
||||
}
|
||||
|
||||
/** Rolls back an interactive transaction on the current JDBC connection. */
|
||||
default Map<String, Object> rollbackManualTransaction() {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) {
|
||||
throw new IllegalStateException("Not connected");
|
||||
}
|
||||
return unchecked(() -> {
|
||||
if (conn.getAutoCommit()) {
|
||||
throw new IllegalStateException("No manual transaction open");
|
||||
}
|
||||
conn.rollback();
|
||||
conn.setAutoCommit(true);
|
||||
JdbcSchemaSwitcher.releasePreserved(conn);
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
});
|
||||
}
|
||||
|
||||
default QueryResult executeBatch(List<String> statements, String schema) {
|
||||
|
||||
@@ -219,6 +219,118 @@ public final class DdlBuilder {
|
||||
return ddl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends trailing SQL (e.g. GRANT statements) after a CREATE TABLE DDL script.
|
||||
* Returns the original DDL unchanged when {@code trailingSql} is blank.
|
||||
*/
|
||||
public static String appendTrailingSql(String ddl, String trailingSql) {
|
||||
if (!notBlank(trailingSql)) {
|
||||
return ddl == null ? "" : ddl;
|
||||
}
|
||||
String base = ddl == null ? "" : ddl.trim();
|
||||
if (base.isEmpty()) {
|
||||
return trailingSql.trim();
|
||||
}
|
||||
if (!base.endsWith(";")) {
|
||||
base = base + ";";
|
||||
}
|
||||
String grants = trailingSql.trim();
|
||||
return base + "\n\n" + grants + (grants.endsWith(";") ? "" : ";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Oracle-style object GRANT statements for table and column privileges.
|
||||
* Privileges are grouped by grantee / grantable / privilege(+columns).
|
||||
*/
|
||||
public static String buildOracleObjectGrantSql(
|
||||
String schema,
|
||||
String table,
|
||||
List<OracleObjectPrivilege> privileges
|
||||
) {
|
||||
if (privileges == null || privileges.isEmpty() || !notBlank(table)) {
|
||||
return "";
|
||||
}
|
||||
String tableRef = qualifiedName(schema, table, false);
|
||||
|
||||
Map<String, List<String>> tablePrivileges = new LinkedHashMap<>();
|
||||
Map<String, List<String>> columnPrivileges = new LinkedHashMap<>();
|
||||
for (OracleObjectPrivilege privilege : privileges) {
|
||||
if (privilege == null || !notBlank(privilege.grantee()) || !notBlank(privilege.privilege())) {
|
||||
continue;
|
||||
}
|
||||
String privilegeName = privilege.privilege().trim().toUpperCase(Locale.ROOT);
|
||||
String grantableKey = privilege.grantable() ? "1" : "0";
|
||||
if (notBlank(privilege.columnName())) {
|
||||
String key = privilege.grantee() + "\u0000" + privilegeName + "\u0000" + grantableKey;
|
||||
columnPrivileges
|
||||
.computeIfAbsent(key, ignored -> new ArrayList<>())
|
||||
.add(privilege.columnName().trim());
|
||||
} else {
|
||||
String key = privilege.grantee() + "\u0000" + grantableKey;
|
||||
List<String> names = tablePrivileges.computeIfAbsent(key, ignored -> new ArrayList<>());
|
||||
if (!names.contains(privilegeName)) {
|
||||
names.add(privilegeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> statements = new ArrayList<>();
|
||||
for (Map.Entry<String, List<String>> entry : tablePrivileges.entrySet()) {
|
||||
String[] parts = entry.getKey().split("\u0000", -1);
|
||||
String grantee = parts[0];
|
||||
boolean grantable = "1".equals(parts[1]);
|
||||
statements.add(formatOracleGrant(
|
||||
join(entry.getValue(), ", "),
|
||||
null,
|
||||
tableRef,
|
||||
grantee,
|
||||
grantable
|
||||
));
|
||||
}
|
||||
for (Map.Entry<String, List<String>> entry : columnPrivileges.entrySet()) {
|
||||
String[] parts = entry.getKey().split("\u0000", -1);
|
||||
String grantee = parts[0];
|
||||
String privilegeName = parts[1];
|
||||
boolean grantable = "1".equals(parts[2]);
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (String column : entry.getValue()) {
|
||||
String quoted = quoteIdent(column, false);
|
||||
if (!columns.contains(quoted)) {
|
||||
columns.add(quoted);
|
||||
}
|
||||
}
|
||||
statements.add(formatOracleGrant(
|
||||
privilegeName,
|
||||
join(columns, ", "),
|
||||
tableRef,
|
||||
grantee,
|
||||
grantable
|
||||
));
|
||||
}
|
||||
return join(statements, "\n");
|
||||
}
|
||||
|
||||
private static String formatOracleGrant(
|
||||
String privilegeList,
|
||||
String columnList,
|
||||
String tableRef,
|
||||
String grantee,
|
||||
boolean grantable
|
||||
) {
|
||||
StringBuilder statement = new StringBuilder("GRANT ");
|
||||
statement.append(privilegeList);
|
||||
if (notBlank(columnList)) {
|
||||
statement.append(" (").append(columnList).append(")");
|
||||
}
|
||||
statement.append(" ON ").append(tableRef);
|
||||
statement.append(" TO ").append(quoteIdent(grantee, false));
|
||||
if (grantable) {
|
||||
statement.append(" WITH GRANT OPTION");
|
||||
}
|
||||
statement.append(";");
|
||||
return statement.toString();
|
||||
}
|
||||
|
||||
private static List<ForeignKeyGroup> groupForeignKeys(List<ForeignKeyInfo> foreignKeys) {
|
||||
List<ForeignKeyGroup> result = new ArrayList<>();
|
||||
Map<String, ForeignKeyGroup> namedGroups = new LinkedHashMap<>();
|
||||
|
||||
@@ -18,6 +18,13 @@ public final class IndexInfo {
|
||||
// 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;
|
||||
private List<Integer> key_options = Collections.emptyList();
|
||||
// True when this index is the object behind a table constraint (PRIMARY KEY / UNIQUE)
|
||||
// instead of a standalone index. Dameng lists both kinds in ALL_INDEXES but only accepts
|
||||
// index-level DDL for standalone ones, so the SQL builder needs to tell them apart
|
||||
// (#7959). Mirrors crate::types::IndexInfo::constraint_backed on the Rust side; false
|
||||
// when the introspection source doesn't report it.
|
||||
private boolean constraint_backed;
|
||||
|
||||
public IndexInfo() {
|
||||
this("", Collections.emptyList(), false, false);
|
||||
@@ -98,6 +105,14 @@ public final class IndexInfo {
|
||||
return key_is_expression;
|
||||
}
|
||||
|
||||
public List<Integer> getKey_options() {
|
||||
return key_options;
|
||||
}
|
||||
|
||||
public boolean getConstraint_backed() {
|
||||
return constraint_backed;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -134,6 +149,14 @@ public final class IndexInfo {
|
||||
this.key_is_expression = key_is_expression;
|
||||
}
|
||||
|
||||
public void setKey_options(List<Integer> key_options) {
|
||||
this.key_options = key_options == null ? Collections.emptyList() : key_options;
|
||||
}
|
||||
|
||||
public void setConstraint_backed(boolean constraint_backed) {
|
||||
this.constraint_backed = constraint_backed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -141,19 +164,31 @@ public final class IndexInfo {
|
||||
IndexInfo that = (IndexInfo) other;
|
||||
return is_unique == that.is_unique
|
||||
&& is_primary == that.is_primary
|
||||
&& constraint_backed == that.constraint_backed
|
||||
&& Objects.equals(name, that.name)
|
||||
&& Objects.equals(columns, that.columns)
|
||||
&& 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(key_is_expression, that.key_is_expression);
|
||||
&& Objects.equals(key_is_expression, that.key_is_expression)
|
||||
&& Objects.equals(key_options, that.key_options);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(
|
||||
name, columns, is_unique, is_primary, filter, index_type, included_columns, comment, key_is_expression
|
||||
name,
|
||||
columns,
|
||||
is_unique,
|
||||
is_primary,
|
||||
filter,
|
||||
index_type,
|
||||
included_columns,
|
||||
comment,
|
||||
key_is_expression,
|
||||
key_options,
|
||||
constraint_backed
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,6 +203,8 @@ public final class IndexInfo {
|
||||
+ ", included_columns=" + included_columns
|
||||
+ ", comment=" + comment
|
||||
+ ", key_is_expression=" + key_is_expression
|
||||
+ ", key_options=" + key_options
|
||||
+ ", constraint_backed=" + constraint_backed
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.util.function.Supplier;
|
||||
final class JdbcSchemaSwitcher {
|
||||
private static final Map<Connection, ResetState> RESET_STATE_BY_CONNECTION =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private static final Map<Connection, Boolean> PRESERVED_BY_CONNECTION =
|
||||
Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private JdbcSchemaSwitcher() {
|
||||
}
|
||||
@@ -27,6 +29,9 @@ final class JdbcSchemaSwitcher {
|
||||
Supplier<String> resetSchemaSql
|
||||
) throws Exception {
|
||||
if (schema == null || schema.trim().isEmpty()) {
|
||||
if (isPreserved(conn)) {
|
||||
return;
|
||||
}
|
||||
if (!restore(conn, true)) {
|
||||
throw new SQLException("Failed to restore the original JDBC schema context");
|
||||
}
|
||||
@@ -70,7 +75,20 @@ final class JdbcSchemaSwitcher {
|
||||
RESET_STATE_BY_CONNECTION.remove(conn);
|
||||
}
|
||||
|
||||
static void preserve(Connection conn) {
|
||||
PRESERVED_BY_CONNECTION.put(conn, Boolean.TRUE);
|
||||
}
|
||||
|
||||
static void releasePreserved(Connection conn) {
|
||||
PRESERVED_BY_CONNECTION.remove(conn);
|
||||
}
|
||||
|
||||
private static boolean isPreserved(Connection conn) {
|
||||
return PRESERVED_BY_CONNECTION.containsKey(conn);
|
||||
}
|
||||
|
||||
private static boolean restore(Connection conn, boolean preserveOnFailure) {
|
||||
PRESERVED_BY_CONNECTION.remove(conn);
|
||||
ResetState resetState = RESET_STATE_BY_CONNECTION.remove(conn);
|
||||
if (resetState == null) {
|
||||
return true;
|
||||
|
||||
@@ -111,7 +111,9 @@ public final class JsonRpcServer {
|
||||
jdbcExecutor,
|
||||
succeeded,
|
||||
requiresSessionAffinity(method, params),
|
||||
evictAfterRequest(method)
|
||||
evictAfterRequest(method),
|
||||
endsSessionAffinity(method),
|
||||
preservesSchemaContext()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -315,6 +317,15 @@ public final class JsonRpcServer {
|
||||
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
|
||||
return agent.executeTransaction(statements, stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
|
||||
return agent.beginManualTransaction(stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) {
|
||||
return agent.commitManualTransaction();
|
||||
}
|
||||
if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) {
|
||||
return agent.rollbackManualTransaction();
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) {
|
||||
Type statementsType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
|
||||
@@ -422,6 +433,9 @@ public final class JsonRpcServer {
|
||||
}
|
||||
|
||||
private boolean requiresSessionAffinity(String method, JsonObject params) {
|
||||
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)
|
||||
|| AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)
|
||||
@@ -447,6 +461,23 @@ public final class JsonRpcServer {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean endsSessionAffinity(String method) {
|
||||
return AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)
|
||||
|| AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method);
|
||||
}
|
||||
|
||||
private boolean preservesSchemaContext() {
|
||||
Connection connection = agent.getConnection();
|
||||
if (connection == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return !connection.getAutoCommit();
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean evictAfterRequest(String method) {
|
||||
return AgentProtocol.METHOD_GET_EXPLAIN_INFO.equals(method);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
/**
|
||||
* Oracle-compatible object privilege row used when reconstructing GRANT DDL.
|
||||
*/
|
||||
public final class OracleObjectPrivilege {
|
||||
private final String grantee;
|
||||
private final String privilege;
|
||||
private final boolean grantable;
|
||||
private final String columnName;
|
||||
|
||||
public OracleObjectPrivilege(String grantee, String privilege, boolean grantable) {
|
||||
this(grantee, privilege, grantable, null);
|
||||
}
|
||||
|
||||
public OracleObjectPrivilege(String grantee, String privilege, boolean grantable, String columnName) {
|
||||
this.grantee = grantee;
|
||||
this.privilege = privilege;
|
||||
this.grantable = grantable;
|
||||
this.columnName = columnName;
|
||||
}
|
||||
|
||||
public String grantee() {
|
||||
return grantee;
|
||||
}
|
||||
|
||||
public String privilege() {
|
||||
return privilege;
|
||||
}
|
||||
|
||||
public boolean grantable() {
|
||||
return grantable;
|
||||
}
|
||||
|
||||
public String columnName() {
|
||||
return columnName;
|
||||
}
|
||||
}
|
||||
@@ -546,7 +546,9 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
|
||||
String sql = "SELECT i.relname AS index_name, am.amname AS index_type, " +
|
||||
"(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 " +
|
||||
"(a.attname IS NULL) AS is_expression, " +
|
||||
"array_length(ix.indoption, 1) AS nkeyatts, k.n AS key_position, " +
|
||||
"ix.indoption[(k.n - 1)::int] AS key_option " +
|
||||
"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 " +
|
||||
@@ -569,24 +571,36 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
|
||||
indexName,
|
||||
name -> new IndexBuilder(name, indexType, isUnique, isPrimary)
|
||||
);
|
||||
builder.columns.add(rs.getString("column_text"));
|
||||
String columnText = rs.getString("column_text");
|
||||
if (rs.getObject("nkeyatts") != null && rs.getObject("key_position") != null
|
||||
&& rs.getInt("key_position") > rs.getInt("nkeyatts")) {
|
||||
builder.includedColumns.add(columnText);
|
||||
continue;
|
||||
}
|
||||
builder.columns.add(columnText);
|
||||
builder.keyIsExpression.add(rs.getBoolean("is_expression"));
|
||||
Object keyOption = rs.getObject("key_option");
|
||||
if (keyOption != null) {
|
||||
builder.keyOptions.add(rs.getInt("key_option"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
for (IndexBuilder builder : byName.values()) {
|
||||
result.add(new IndexInfo(
|
||||
IndexInfo index = new IndexInfo(
|
||||
builder.name,
|
||||
builder.columns,
|
||||
builder.isUnique,
|
||||
builder.isPrimary,
|
||||
null,
|
||||
builder.indexType,
|
||||
null,
|
||||
builder.includedColumns.isEmpty() ? null : builder.includedColumns,
|
||||
null,
|
||||
builder.keyIsExpression
|
||||
));
|
||||
);
|
||||
index.setKey_options(builder.keyOptions);
|
||||
result.add(index);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@@ -598,8 +612,9 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
|
||||
private final boolean isUnique;
|
||||
private final boolean isPrimary;
|
||||
private final List<String> columns = new ArrayList<>();
|
||||
private final List<String> includedColumns = new ArrayList<>();
|
||||
private final List<Boolean> keyIsExpression = new ArrayList<>();
|
||||
|
||||
private final List<Integer> keyOptions = new ArrayList<>();
|
||||
private IndexBuilder(String name, String indexType, boolean isUnique, boolean isPrimary) {
|
||||
this.name = name;
|
||||
this.indexType = indexType;
|
||||
|
||||
@@ -42,11 +42,23 @@ public final class StandardJdbcMetadata {
|
||||
}
|
||||
|
||||
public List<String> listSchemas(Connection conn, JdbcAgentProfile profile) {
|
||||
return listSchemas(conn, profile, null);
|
||||
}
|
||||
|
||||
public List<String> listSchemas(Connection conn, JdbcAgentProfile profile, String configuredCatalog) {
|
||||
return unchecked(() -> {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
String catalog = blankToNull(configuredCatalog);
|
||||
try {
|
||||
appendSchemas(names, meta.getSchemas(null, null));
|
||||
String currentCatalog = conn.getCatalog();
|
||||
if (currentCatalog != null && !currentCatalog.trim().isEmpty()) {
|
||||
catalog = currentCatalog.trim();
|
||||
}
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
try {
|
||||
appendSchemas(names, meta.getSchemas(catalog, null));
|
||||
} catch (Exception | AbstractMethodError first) {
|
||||
try {
|
||||
appendSchemas(names, meta.getSchemas());
|
||||
|
||||
@@ -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", "mongo_run_command", "mongo_insert_documents"],
|
||||
"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", "mongo_replace_document", "mongo_find_cursor"],
|
||||
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
|
||||
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
|
||||
"commonMethods": [
|
||||
@@ -42,6 +42,6 @@
|
||||
"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", "insert_documents", "update_document", "update_documents", "delete_document", "delete_documents", "run_command"],
|
||||
"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", "replace_document", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"],
|
||||
"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", "mongo_run_command", "mongo_insert_documents", "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", "mongo_replace_document", "mongo_find_cursor", "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": [
|
||||
@@ -46,7 +46,7 @@
|
||||
"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", "insert_documents", "update_document", "update_documents", "delete_document", "delete_documents", "run_command"],
|
||||
"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", "replace_document", "delete_document", "delete_documents", "run_command", "start_find_cursor", "fetch_find_cursor", "close_find_cursor"],
|
||||
"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"
|
||||
|
||||
@@ -115,6 +115,55 @@ class AbstractJdbcAgentTest {
|
||||
assertEquals(0, tracking.compatibilityQueryCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresGbase8sDriverBacktickIdentifierQuote() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
tracking.identifierQuote = "`";
|
||||
tracking.jdbcUrl = "jdbc:gbasedbt-sqli://localhost:9088/appdb:GBASEDBTSERVER=gbase8s";
|
||||
TestAgent agent = new TestAgent(tracking);
|
||||
|
||||
agent.connect(new ConnectParams());
|
||||
|
||||
assertEquals("", agent.getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresBacktickQuoteForInformixFamilyConnectionString() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
tracking.identifierQuote = "`";
|
||||
TestAgent agent = new TestAgent(tracking);
|
||||
|
||||
ConnectParams params = new ConnectParams();
|
||||
params.setConnection_string("jdbc:informix-sqli://localhost:9088/appdb:INFORMIXSERVER=ol_informix");
|
||||
agent.connect(params);
|
||||
|
||||
assertEquals("", agent.getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsInformixFamilyBacktickQuoteInMysqlCompatMode() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
tracking.identifierQuote = "`";
|
||||
tracking.jdbcUrl = "jdbc:gbasedbt-sqli://localhost:9088/appdb:GBASEDBTSERVER=gbase8s;SQLMODE=mysql";
|
||||
TestAgent agent = new TestAgent(tracking);
|
||||
|
||||
agent.connect(new ConnectParams());
|
||||
|
||||
assertEquals("`", agent.getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNonBacktickQuotesForInformixFamilyDrivers() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
tracking.identifierQuote = "\"";
|
||||
tracking.jdbcUrl = "jdbc:gbasedbt-sqli://localhost:9088/appdb:GBASEDBTSERVER=gbase8s";
|
||||
TestAgent agent = new TestAgent(tracking);
|
||||
|
||||
agent.connect(new ConnectParams());
|
||||
|
||||
assertEquals("\"", agent.getIdentifierQuote());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testsConnectionsThroughSharedLifecycle() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
@@ -300,6 +349,22 @@ class AbstractJdbcAgentTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOneShotTransactionWhenManualTransactionIsOpen() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
TestAgent agent = new TestAgent(tracking);
|
||||
agent.connect(new ConnectParams());
|
||||
agent.beginManualTransaction(null);
|
||||
|
||||
IllegalStateException error = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> agent.executeTransaction(Collections.singletonList("UPDATE A SET ID = 1"), null)
|
||||
);
|
||||
|
||||
assertEquals("Cannot start a one-shot transaction while a manual transaction is open", error.getMessage());
|
||||
assertEquals(Collections.singletonList("setAutoCommit:false"), tracking.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesPlSqlBlockTerminatorDuringTransactionExecution() {
|
||||
TrackingConnection tracking = new TrackingConnection();
|
||||
@@ -453,6 +518,7 @@ class AbstractJdbcAgentTest {
|
||||
private boolean autoCommit = true;
|
||||
private String compatibilityMode;
|
||||
private String identifierQuote = "\"";
|
||||
private String jdbcUrl;
|
||||
private boolean compatibilityQueryFails;
|
||||
private int compatibilityQueryCount;
|
||||
private boolean isValidUnsupported;
|
||||
@@ -518,6 +584,9 @@ class AbstractJdbcAgentTest {
|
||||
if ("getIdentifierQuoteString".equals(method.getName())) {
|
||||
return identifierQuote;
|
||||
}
|
||||
if ("getURL".equals(method.getName())) {
|
||||
return jdbcUrl;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,6 +150,42 @@ class CommonJavaCompatibilityTest {
|
||||
assertEquals("Example JDBC", result.getAsJsonObject("databaseInfo").get("driverName").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcServerDispatchesInteractiveTransactionMethods() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
MinimalAgent agent = new MinimalAgent() {
|
||||
@Override
|
||||
public Map<String, Object> beginManualTransaction(String schema) {
|
||||
calls.add("begin:" + schema);
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> commitManualTransaction() {
|
||||
calls.add("commit");
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> rollbackManualTransaction() {
|
||||
calls.add("rollback");
|
||||
return Collections.singletonMap("ok", (Object) true);
|
||||
}
|
||||
};
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
|
||||
assertTrue(JsonParser.parseString(server.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"begin_manual_transaction\",\"params\":{\"schema\":\"APP\"}}"
|
||||
)).getAsJsonObject().has("result"));
|
||||
assertTrue(JsonParser.parseString(server.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"commit_manual_transaction\",\"params\":{}}"
|
||||
)).getAsJsonObject().has("result"));
|
||||
assertTrue(JsonParser.parseString(server.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"rollback_manual_transaction\",\"params\":{}}"
|
||||
)).getAsJsonObject().has("result"));
|
||||
assertEquals(List.of("begin:APP", "commit", "rollback"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiSessionServerCreatesAndClosesIndependentAgents() throws Exception {
|
||||
java.util.List<TrackingAgent> created = new java.util.ArrayList<>();
|
||||
@@ -750,6 +786,36 @@ class CommonJavaCompatibilityTest {
|
||||
assertTrue(ddl.contains("\"NAME\" NVARCHAR(100) NOT NULL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsOracleObjectGrantSqlAfterTableDdl() {
|
||||
String ddl = DdlBuilder.buildTableDdl(
|
||||
"APP",
|
||||
"USERS",
|
||||
Collections.singletonList(new ColumnInfo("ID", "NUMBER", false, null, true)),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList()
|
||||
);
|
||||
String grants = DdlBuilder.buildOracleObjectGrantSql(
|
||||
"APP",
|
||||
"USERS",
|
||||
Arrays.asList(
|
||||
new OracleObjectPrivilege("READER", "SELECT", false),
|
||||
new OracleObjectPrivilege("READER", "INSERT", false),
|
||||
new OracleObjectPrivilege("ADMIN", "SELECT", true),
|
||||
new OracleObjectPrivilege("ANALYST", "UPDATE", false, "NAME")
|
||||
)
|
||||
);
|
||||
|
||||
String combined = DdlBuilder.appendTrailingSql(ddl, grants);
|
||||
|
||||
assertTrue(combined.contains("CREATE TABLE \"APP\".\"USERS\""));
|
||||
assertTrue(combined.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"));
|
||||
assertTrue(combined.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"));
|
||||
assertTrue(combined.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"));
|
||||
assertEquals("", DdlBuilder.buildOracleObjectGrantSql("APP", "USERS", Collections.emptyList()));
|
||||
assertEquals(ddl, DdlBuilder.appendTrailingSql(ddl, " "));
|
||||
}
|
||||
|
||||
private static class MinimalAgent implements DatabaseAgent {
|
||||
@Override
|
||||
public void connect(ConnectParams params) {
|
||||
|
||||
@@ -1869,6 +1869,65 @@ class JdbcConnectionPoolingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void interactiveTransactionPinsConnectionUntilCommit() {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
AtomicInteger requestIds = new AtomicInteger();
|
||||
String url = h2Url("manual_transaction");
|
||||
try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 2)) {
|
||||
openSession(server, requestIds, "transaction");
|
||||
query(server, requestIds, "transaction", "CREATE TABLE ITEMS (ID INT)", null);
|
||||
|
||||
result(request(
|
||||
server,
|
||||
requestIds,
|
||||
AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION,
|
||||
sessionParams("transaction")
|
||||
));
|
||||
query(server, requestIds, "transaction", "INSERT INTO ITEMS VALUES (1)", null);
|
||||
|
||||
openSession(server, requestIds, "observer");
|
||||
JsonObject beforeCommit = query(server, requestIds, "observer", "SELECT COUNT(*) FROM ITEMS", null);
|
||||
assertEquals(0, beforeCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt());
|
||||
|
||||
result(request(
|
||||
server,
|
||||
requestIds,
|
||||
AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
|
||||
sessionParams("transaction")
|
||||
));
|
||||
JsonObject afterCommit = query(server, requestIds, "observer", "SELECT COUNT(*) FROM ITEMS", null);
|
||||
assertEquals(1, afterCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void interactiveTransactionRestoresSchemaBeforeReturningConnection() {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
AtomicInteger requestIds = new AtomicInteger();
|
||||
String url = h2Url("manual_transaction_schema");
|
||||
try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 2)) {
|
||||
openSession(server, requestIds, "transaction-schema");
|
||||
query(server, requestIds, "transaction-schema", "CREATE SCHEMA IF NOT EXISTS APP", null);
|
||||
|
||||
JsonObject beginParams = sessionParams("transaction-schema");
|
||||
beginParams.addProperty("schema", "APP");
|
||||
result(request(server, requestIds, AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, beginParams));
|
||||
JsonObject inTransaction = query(server, requestIds, "transaction-schema", "SELECT CURRENT_SCHEMA", null);
|
||||
assertEquals("APP", inTransaction.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString());
|
||||
|
||||
result(request(
|
||||
server,
|
||||
requestIds,
|
||||
AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
|
||||
sessionParams("transaction-schema")
|
||||
));
|
||||
openSession(server, requestIds, "schema-observer");
|
||||
JsonObject afterCommit = query(server, requestIds, "schema-observer", "SELECT CURRENT_SCHEMA", null);
|
||||
assertEquals("PUBLIC", afterCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void affinityDetectionCoversCommonSessionStateStatements() {
|
||||
assertTrue(JdbcConnectionAffinity.requiresSessionAffinity("SET search_path TO app"));
|
||||
|
||||
@@ -35,7 +35,13 @@ class MetadataConstraintCoverageTest {
|
||||
Map<String, String> coverage = readCoverageMatrix(agentsRoot.resolve("metadata-constraint-coverage.tsv"));
|
||||
Set<String> discovered = discoverCustomMetadataDrivers(agentsRoot.resolve("drivers"));
|
||||
|
||||
assertEquals(discovered, coverage.keySet());
|
||||
// One-directional on purpose: every driver with a custom metadata override must be
|
||||
// documented, but the matrix may also document drivers that only customize other
|
||||
// metadata surfaces (e.g. column/primary-key queries via INFORMATION_SCHEMA) without
|
||||
// overriding the listing methods this test discovers.
|
||||
Set<String> undocumented = new HashSet<>(discovered);
|
||||
undocumented.removeAll(coverage.keySet());
|
||||
assertTrue(undocumented.isEmpty(), "custom metadata drivers missing from coverage matrix: " + undocumented);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -157,6 +157,34 @@ class PostgresLikeAgentTest {
|
||||
assertTrue(index.getIs_unique());
|
||||
assertFalse(index.getIs_primary());
|
||||
assertEquals("btree", index.getIndex_type());
|
||||
assertTrue(index.getKey_options().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listIndexesPreservesPerKeyOrderingAndSeparatesIncludedColumns() {
|
||||
TestPostgresLikeAgent agent = new TestPostgresLikeAgent(preparedConnection(resultSet(
|
||||
new String[]{
|
||||
"index_name", "index_type", "is_unique", "is_primary", "column_text",
|
||||
"is_expression", "nkeyatts", "key_position", "key_option"
|
||||
},
|
||||
new Object[][]{
|
||||
{"event_order_idx", "btree", false, false, "created_at", false, 4, 1, 1},
|
||||
{"event_order_idx", "btree", false, false, "tenant_id", false, 4, 2, 0},
|
||||
{"event_order_idx", "btree", false, false, "score", false, 4, 3, 2},
|
||||
{"event_order_idx", "btree", false, false, "lower(payload)", true, 4, 4, 3},
|
||||
{"event_order_idx", "btree", false, false, "payload", false, 4, 5, null}
|
||||
}
|
||||
)));
|
||||
agent.connect(new ConnectParams());
|
||||
|
||||
IndexInfo index = agent.listIndexes("public", "event_log").get(0);
|
||||
|
||||
assertEquals(java.util.Arrays.asList("created_at", "tenant_id", "score", "lower(payload)"), index.getColumns());
|
||||
assertEquals(java.util.Arrays.asList(false, false, false, true), index.getKey_is_expression());
|
||||
assertEquals(java.util.Arrays.asList(1, 0, 2, 3), index.getKey_options());
|
||||
assertEquals(java.util.Collections.singletonList("payload"), index.getIncluded_columns());
|
||||
com.google.gson.Gson gson = new com.google.gson.Gson();
|
||||
assertEquals(index, gson.fromJson(gson.toJson(index), IndexInfo.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -174,6 +202,8 @@ class PostgresLikeAgentTest {
|
||||
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);
|
||||
assertTrue(sql.contains("ix.indoption[(k.n - 1)::int]"), sql);
|
||||
assertTrue(sql.contains("array_length(ix.indoption, 1) AS nkeyatts"), sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -43,6 +43,37 @@ class StandardJdbcMetadataTest {
|
||||
assertEquals(Arrays.asList("APP", "PUBLIC"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesSchemasToTheConnectionCatalog() {
|
||||
AtomicReference<Object[]> capturedArgs = new AtomicReference<>();
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getSchemas".equals(method.getName())) {
|
||||
capturedArgs.set(args);
|
||||
return rows(row("TABLE_SCHEM", "APP"));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = proxy(Connection.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return meta;
|
||||
}
|
||||
if ("getCatalog".equals(method.getName())) {
|
||||
return "regular_catalog";
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals(List.of("APP"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile, "initial_catalog"));
|
||||
assertEquals("regular_catalog", capturedArgs.get()[0]);
|
||||
assertEquals(null, capturedArgs.get()[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsSchemasWhenConnectionGetSchemaIsUnsupported() {
|
||||
Connection conn = connection(
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
dependencies {
|
||||
implementation files('libs/CacheDB.jar')
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
manifest {
|
||||
attributes('Agent-Label': 'InterSystems Cach\u00e9', 'Main-Class': 'com.dbx.agent.cache.CacheAgent')
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,242 @@
|
||||
package com.dbx.agent.cache;
|
||||
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.StandardJdbcMetadata;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class CacheAgent extends ConfiguredJdbcAgent {
|
||||
public static final JdbcAgentProfile CACHE_PROFILE = new JdbcAgentProfile(
|
||||
"com.intersys.jdbc.CacheDriver",
|
||||
"jdbc:Cache://{host}:{port}/{database}",
|
||||
1972,
|
||||
true
|
||||
);
|
||||
|
||||
private String configuredDatabase = "";
|
||||
|
||||
public CacheAgent() {
|
||||
super(CACHE_PROFILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
configuredDatabase = params.getDatabase();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet rs, int index, int sqlType) {
|
||||
switch (sqlType) {
|
||||
case Types.BIGINT:
|
||||
case Types.INTEGER:
|
||||
case Types.SMALLINT:
|
||||
case Types.TINYINT:
|
||||
// Caché can expose values outside the Java primitive range
|
||||
// implied by its JDBC metadata. Preserve them without narrowing.
|
||||
return unchecked(() -> {
|
||||
Object value = rs.getBigDecimal(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
});
|
||||
case Types.OTHER:
|
||||
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
|
||||
default:
|
||||
return super.resultValue(rs, index, sqlType);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSchemas() {
|
||||
return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), CACHE_PROFILE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return cacheColumns(requireConnection(), getProfile(), configuredDatabase, schema, table);
|
||||
}
|
||||
|
||||
static List<ColumnInfo> cacheColumns(Connection conn, JdbcAgentProfile profile, String configuredDatabase, String schema, String table) {
|
||||
List<ColumnInfo> columns;
|
||||
try {
|
||||
columns = informationSchemaColumns(conn, schema, table);
|
||||
} catch (Exception informationSchemaUnavailable) {
|
||||
// Caché releases before 2012.1 do not expose INFORMATION_SCHEMA;
|
||||
// plain JDBC metadata keeps those servers browsable.
|
||||
return StandardJdbcMetadata.INSTANCE.getColumns(conn, profile, configuredDatabase, schema, table);
|
||||
}
|
||||
if (!hasPrimaryKey(columns)) {
|
||||
markPrimaryKeys(columns, cachePrimaryKeys(conn, schema, table));
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
static List<ColumnInfo> informationSchemaColumns(Connection conn, String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
List<ColumnInfo> columns = new ArrayList<>();
|
||||
try (PreparedStatement stmt = conn.prepareStatement(columnSql(schema))) {
|
||||
int index = 1;
|
||||
if (!isBlank(schema)) {
|
||||
stmt.setString(index++, schema.trim());
|
||||
}
|
||||
stmt.setString(index, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("COLUMN_NAME");
|
||||
if (isBlank(name)) {
|
||||
continue;
|
||||
}
|
||||
columns.add(new ColumnInfo(
|
||||
name,
|
||||
stringOrDefault(rs, "DATA_TYPE", "Unknown"),
|
||||
!"NO".equalsIgnoreCase(stringOrNull(rs, "IS_NULLABLE")),
|
||||
stringOrNull(rs, "COLUMN_DEFAULT"),
|
||||
"YES".equalsIgnoreCase(stringOrNull(rs, "PRIMARY_KEY")),
|
||||
null,
|
||||
stringOrNull(rs, "DESCRIPTION"),
|
||||
intOrNull(rs, "NUMERIC_PRECISION"),
|
||||
intOrNull(rs, "NUMERIC_SCALE"),
|
||||
intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
});
|
||||
}
|
||||
|
||||
static List<String> dedupeCaseInsensitiveSchemas(List<String> schemas) {
|
||||
Map<String, String> byNormalizedName = new LinkedHashMap<>();
|
||||
for (String schema : schemas) {
|
||||
if (schema == null || schema.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String name = schema.trim();
|
||||
byNormalizedName.putIfAbsent(name.toUpperCase(Locale.ROOT), name);
|
||||
}
|
||||
return new ArrayList<>(byNormalizedName.values());
|
||||
}
|
||||
|
||||
private static Set<String> cachePrimaryKeys(Connection conn, String schema, String table) {
|
||||
Set<String> keys = jdbcPrimaryKeys(conn, schema, table);
|
||||
return keys.isEmpty() ? nativePrimaryKeys(conn, schema, table) : keys;
|
||||
}
|
||||
|
||||
private static Set<String> jdbcPrimaryKeys(Connection conn, String schema, String table) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
try {
|
||||
DatabaseMetaData meta = conn.getMetaData();
|
||||
try (ResultSet rs = meta.getPrimaryKeys(null, isBlank(schema) ? null : schema.trim(), table)) {
|
||||
addPrimaryKeys(keys, rs);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static Set<String> nativePrimaryKeys(Connection conn, String schema, String table) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
try (PreparedStatement stmt = conn.prepareStatement(primaryKeySql(schema))) {
|
||||
int index = 1;
|
||||
if (!isBlank(schema)) {
|
||||
stmt.setString(index++, schema.trim());
|
||||
}
|
||||
stmt.setString(index, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
addPrimaryKeys(keys, rs);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Primary key metadata is optional for display; column listing must keep working.
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static String columnSql(String schema) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
|
||||
+ "PRIMARY_KEY, DESCRIPTION, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH "
|
||||
+ "FROM INFORMATION_SCHEMA.COLUMNS WHERE "
|
||||
);
|
||||
if (!isBlank(schema)) {
|
||||
sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND ");
|
||||
}
|
||||
sql.append("UPPER(TABLE_NAME) = UPPER(?) ORDER BY ORDINAL_POSITION");
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
private static void addPrimaryKeys(Set<String> keys, ResultSet rs) throws Exception {
|
||||
while (rs.next()) {
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
if (!isBlank(column)) {
|
||||
keys.add(column.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasPrimaryKey(List<ColumnInfo> columns) {
|
||||
for (ColumnInfo column : columns) {
|
||||
if (column.getIs_primary_key()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void markPrimaryKeys(List<ColumnInfo> columns, Set<String> primaryKeys) {
|
||||
for (ColumnInfo column : columns) {
|
||||
if (primaryKeys.contains(column.getName().toUpperCase(Locale.ROOT))) {
|
||||
column.setIs_primary_key(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String primaryKeySql(String schema) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT k.COLUMN_NAME "
|
||||
+ "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE k "
|
||||
+ "WHERE k.CONSTRAINT_TYPE = 'PRIMARY KEY' AND "
|
||||
);
|
||||
if (!isBlank(schema)) {
|
||||
sql.append("UPPER(k.TABLE_SCHEMA) = UPPER(?) AND ");
|
||||
}
|
||||
sql.append("UPPER(k.TABLE_NAME) = UPPER(?) ORDER BY k.ORDINAL_POSITION");
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
private static String stringOrDefault(ResultSet rs, String column, String fallback) throws Exception {
|
||||
String value = stringOrNull(rs, column);
|
||||
return isBlank(value) ? fallback : value;
|
||||
}
|
||||
|
||||
private static String stringOrNull(ResultSet rs, String column) throws Exception {
|
||||
String value = rs.getString(column);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
|
||||
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 boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
new MultiSessionJsonRpcServer(CacheAgent::new).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.dbx.agent.cache;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
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 CacheAgentTest {
|
||||
@Test
|
||||
void skipsSchemaSwitchingBecauseCacheRejectsSetSchemaContext() {
|
||||
CacheAgent agent = new CacheAgent();
|
||||
|
||||
assertEquals("", agent.setSchemaSQL("SQLUser"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsCacheJdbcUrlWithDefaultPort() {
|
||||
assertEquals(
|
||||
"jdbc:Cache://db.local:1972/USER",
|
||||
CacheAgent.CACHE_PROFILE.getUrlTemplate()
|
||||
.replace("{host}", "db.local")
|
||||
.replace("{port}", "1972")
|
||||
.replace("{database}", "USER")
|
||||
);
|
||||
assertEquals("com.intersys.jdbc.CacheDriver", CacheAgent.CACHE_PROFILE.getDriverClass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedupesSchemasCaseInsensitively() {
|
||||
assertEquals(
|
||||
Arrays.asList("APP", "SQLUSER", "z_user"),
|
||||
CacheAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("APP", "SQLUSER", "SQLUser", "app", "z_user"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresBlankSchemasWhenDeduping() {
|
||||
assertEquals(
|
||||
Collections.singletonList("SQLUSER"),
|
||||
CacheAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("", " ", null, "SQLUSER"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsVendorOtherValuesThroughGetObject() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getObject".equals(method.getName())) {
|
||||
calls.add("getObject");
|
||||
return new Object() {
|
||||
@Override
|
||||
public String toString() {
|
||||
return "%List(1,2)";
|
||||
}
|
||||
};
|
||||
}
|
||||
if ("getString".equals(method.getName())) {
|
||||
calls.add("getString");
|
||||
throw new AssertionError("Caché %LIST must not use getString");
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new CacheAgent().resultValue(resultSet, 1, Types.OTHER);
|
||||
|
||||
assertEquals("%List(1,2)", value);
|
||||
assertEquals(Collections.singletonList("getObject"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsOutOfRangeIntegerThroughBigDecimal() {
|
||||
BigDecimal valueOutsideIntegerRange = new BigDecimal("2147483648");
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getBigDecimal".equals(method.getName())) {
|
||||
calls.add("getBigDecimal");
|
||||
return valueOutsideIntegerRange;
|
||||
}
|
||||
if (method.getName().startsWith("get")) {
|
||||
calls.add(method.getName());
|
||||
throw new SQLException("Numeric value out of range");
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new CacheAgent().resultValue(resultSet, 1, Types.INTEGER);
|
||||
|
||||
assertEquals(valueOutsideIntegerRange, value);
|
||||
assertEquals(Collections.singletonList("getBigDecimal"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesStringPathForStandardValues() {
|
||||
assertStringPath(Types.VARCHAR);
|
||||
assertStringPath(Types.LONGVARCHAR);
|
||||
}
|
||||
|
||||
private static void assertStringPath(int sqlType) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getString".equals(method.getName())) {
|
||||
calls.add("getString");
|
||||
return "ordinary";
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new CacheAgent().resultValue(resultSet, 1, sqlType);
|
||||
|
||||
assertEquals("ordinary", value);
|
||||
assertEquals(Collections.singletonList("getString"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesNullForVendorOtherValues() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getObject".equals(method.getName())) {
|
||||
calls.add("getObject");
|
||||
return null;
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return true;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new CacheAgent().resultValue(resultSet, 1, Types.OTHER);
|
||||
|
||||
assertNull(value);
|
||||
assertEquals(Collections.singletonList("getObject"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsColumnsAndUsesInlinePrimaryKey() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
true,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
Collections.emptyList()
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
|
||||
|
||||
assertEquals(2, columns.size());
|
||||
assertEquals("ID", columns.get(0).getName());
|
||||
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 = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "", 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
|
||||
void usesJdbcPrimaryKeysWhenColumnRowsHaveNoPrimaryKey() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
false,
|
||||
List.of(Map.of("COLUMN_NAME", "ID")),
|
||||
null,
|
||||
Collections.emptyList()
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
|
||||
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertEquals(2, calls.size());
|
||||
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
|
||||
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToNativePrimaryKeysWhenJdbcMetadataFails() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
false,
|
||||
Collections.emptyList(),
|
||||
new SQLException("Caché metadata unavailable"),
|
||||
List.of(Map.of("COLUMN_NAME", "ID"))
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
|
||||
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
|
||||
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
|
||||
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesColumnsWithoutPrimaryKeysWhenBothSourcesAreEmpty() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = fakeConnection(
|
||||
calls,
|
||||
false,
|
||||
Collections.emptyList(),
|
||||
null,
|
||||
Collections.emptyList()
|
||||
);
|
||||
|
||||
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
|
||||
|
||||
assertFalse(columns.get(0).getIs_primary_key());
|
||||
assertFalse(columns.get(1).getIs_primary_key());
|
||||
assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToJdbcMetadataWhenInformationSchemaIsUnavailable() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
Connection conn = proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
calls.add((String) args[0]);
|
||||
throw new SQLException("SQLCODE -361: unknown table INFORMATION_SCHEMA.COLUMNS");
|
||||
}
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return jdbcFallbackMetadata(calls);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
List<ColumnInfo> columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People");
|
||||
|
||||
assertEquals(1, columns.size());
|
||||
assertEquals("ID", columns.get(0).getName());
|
||||
assertEquals("INTEGER", columns.get(0).getData_type());
|
||||
assertTrue(columns.get(0).getIs_primary_key());
|
||||
assertEquals(3, calls.size());
|
||||
assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS"));
|
||||
assertEquals("JDBC_PRIMARY_KEYS", calls.get(1));
|
||||
assertEquals("JDBC_COLUMNS", calls.get(2));
|
||||
}
|
||||
|
||||
private static DatabaseMetaData jdbcFallbackMetadata(List<String> calls) {
|
||||
return proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getPrimaryKeys".equals(name)) {
|
||||
calls.add("JDBC_PRIMARY_KEYS");
|
||||
return fakeResultSet(List.of(Map.of("COLUMN_NAME", "ID")));
|
||||
}
|
||||
if ("getColumns".equals(name)) {
|
||||
calls.add("JDBC_COLUMNS");
|
||||
return fakeResultSet(List.of(Map.of(
|
||||
"COLUMN_NAME", "ID",
|
||||
"TYPE_NAME", "INTEGER",
|
||||
"NULLABLE", 0,
|
||||
"COLUMN_DEF", "0",
|
||||
"REMARKS", "identifier",
|
||||
"COLUMN_SIZE", 10,
|
||||
"DECIMAL_DIGITS", 0
|
||||
)));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection fakeConnection(
|
||||
List<String> callLog,
|
||||
boolean inlinePrimaryKey,
|
||||
List<Map<String, Object>> jdbcPrimaryKeys,
|
||||
SQLException jdbcPrimaryKeyFailure,
|
||||
List<Map<String, Object>> nativePrimaryKeys
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String sql = (String) args[0];
|
||||
callLog.add(sql);
|
||||
return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys);
|
||||
}
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return fakeMetadata(callLog, jdbcPrimaryKeys, jdbcPrimaryKeyFailure);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static DatabaseMetaData fakeMetadata(
|
||||
List<String> callLog,
|
||||
List<Map<String, Object>> jdbcPrimaryKeys,
|
||||
SQLException jdbcPrimaryKeyFailure
|
||||
) {
|
||||
return proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getPrimaryKeys".equals(method.getName())) {
|
||||
callLog.add("JDBC_PRIMARY_KEYS");
|
||||
assertEquals(null, args[0]);
|
||||
assertEquals("SQLUser", args[1]);
|
||||
assertEquals("People", args[2]);
|
||||
if (jdbcPrimaryKeyFailure != null) {
|
||||
throw jdbcPrimaryKeyFailure;
|
||||
}
|
||||
return fakeResultSet(jdbcPrimaryKeys);
|
||||
}
|
||||
if ("getColumns".equals(method.getName())) {
|
||||
throw new AssertionError("Caché 2012+ columns must not use JDBC DatabaseMetaData.getColumns()");
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement fakeStatement(
|
||||
String sql,
|
||||
boolean inlinePrimaryKey,
|
||||
List<Map<String, Object>> nativePrimaryKeys
|
||||
) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
if (sql.contains("KEY_COLUMN_USAGE")) {
|
||||
return fakeResultSet(nativePrimaryKeys);
|
||||
}
|
||||
return fakeResultSet(List.of(
|
||||
Map.of(
|
||||
"COLUMN_NAME", "ID",
|
||||
"DATA_TYPE", "INTEGER",
|
||||
"IS_NULLABLE", "NO",
|
||||
"COLUMN_DEFAULT", "42",
|
||||
"PRIMARY_KEY", inlinePrimaryKey ? "YES" : "NO",
|
||||
"DESCRIPTION", "identifier",
|
||||
"NUMERIC_PRECISION", 10,
|
||||
"NUMERIC_SCALE", 0
|
||||
),
|
||||
Map.of(
|
||||
"COLUMN_NAME", "NAME",
|
||||
"DATA_TYPE", "VARCHAR",
|
||||
"IS_NULLABLE", "YES",
|
||||
"PRIMARY_KEY", "NO",
|
||||
"CHARACTER_MAXIMUM_LENGTH", 64
|
||||
)
|
||||
));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static ResultSet fakeResultSet(List<Map<String, Object>> rows) {
|
||||
final int[] index = {-1};
|
||||
final boolean[] wasNull = {false};
|
||||
return proxy(ResultSet.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("next".equals(name)) {
|
||||
index[0] += 1;
|
||||
return index[0] < rows.size();
|
||||
}
|
||||
if ("getString".equals(name)) {
|
||||
Object value = rows.get(index[0]).get((String) args[0]);
|
||||
wasNull[0] = value == null;
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
if ("getInt".equals(name)) {
|
||||
Object value = rows.get(index[0]).get((String) args[0]);
|
||||
wasNull[0] = value == null;
|
||||
return value instanceof Number ? ((Number) value).intValue() : 0;
|
||||
}
|
||||
if ("getObject".equals(name)) {
|
||||
Object value = rows.get(index[0]).get((String) args[0]);
|
||||
wasNull[0] = value == null;
|
||||
return value;
|
||||
}
|
||||
if ("wasNull".equals(name)) {
|
||||
return wasNull[0];
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
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 (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, invocationHandler);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (Boolean.TYPE.equals(type)) {
|
||||
return false;
|
||||
}
|
||||
if (Integer.TYPE.equals(type)) {
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface MethodHandler {
|
||||
Object handle(Method method, Object[] args) throws Throwable;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ multi-session JSON-RPC protocol without a JVM.
|
||||
- Astra validation: secure-connect bundle parsing and transport configuration;
|
||||
live Astra credentials were not available
|
||||
- Authentication: username/password and Kerberos/GSSAPI
|
||||
- TLS: CA verification, optional client certificate/key, hostname verification
|
||||
- TLS: CA verification, optional client certificate/key, JKS/PKCS#12 stores,
|
||||
hostname verification
|
||||
- Cloud: DataStax Astra secure connect bundles
|
||||
- Configuration: Java Driver 4 HOCON `configfile` mapping plus native extensions
|
||||
- Metadata: keyspaces, tables, columns, indexes, CQL table DDL, completion search
|
||||
@@ -38,6 +39,8 @@ syntax.
|
||||
| `enablessl` | TLS enablement |
|
||||
| `sslenginefactory` | the standard `DefaultSslEngineFactory` maps to native TLS |
|
||||
| `hostnameverification` | TLS hostname verification; enabled by default |
|
||||
| `truststorepath`, `truststorepassword` | JKS/PKCS#12 server trust configuration |
|
||||
| `keystorepath`, `keystorepassword` | JKS/PKCS#12 client certificate configuration |
|
||||
| `user`, `password` | password authentication |
|
||||
| `configfile` | Java Driver 4 HOCON configuration; overrides URL options except contact points and keyspace |
|
||||
| `usekrb5` | Kerberos/GSSAPI authentication using password, keytab, or FILE credential cache |
|
||||
@@ -48,8 +51,8 @@ syntax.
|
||||
|
||||
The Agent rejects custom Java implementation classes because they cannot be
|
||||
loaded by a native binary. This includes custom authentication, SSL, retry,
|
||||
reconnection, and load-balancing classes. Java JKS/PKCS12 truststores and
|
||||
keystores are not read directly; use the native PEM paths described below.
|
||||
reconnection, and load-balancing classes. The default SSL engine's JKS and
|
||||
PKCS#12 truststores and keystores are supported directly.
|
||||
|
||||
## Java Driver HOCON configuration
|
||||
|
||||
@@ -66,7 +69,8 @@ Mapped Java Driver paths include:
|
||||
- `advanced.socket.tcp-no-delay` and `keep-alive`
|
||||
- `advanced.protocol.version`, retry policy, and reconnection policy
|
||||
- `advanced.auth-provider` plaintext and Instaclustr Kerberos options
|
||||
- `advanced.ssl-engine-factory` default TLS and hostname validation
|
||||
- `advanced.ssl-engine-factory` default TLS, hostname validation, truststore,
|
||||
and keystore settings
|
||||
|
||||
Native-only settings can be placed under `dbx.cassandra`:
|
||||
|
||||
@@ -133,7 +137,8 @@ go test -run TestCassandraIntegration -v
|
||||
|
||||
Optional variables include `CASSANDRA_TEST_URL_PARAMS`, `CASSANDRA_TEST_SSL`,
|
||||
`CASSANDRA_TEST_CA_CERT_PATH`, `CASSANDRA_TEST_CLIENT_CERT_PATH`, and
|
||||
`CASSANDRA_TEST_CLIENT_KEY_PATH`.
|
||||
`CASSANDRA_TEST_CLIENT_KEY_PATH`. Truststore and keystore settings can also be
|
||||
provided through `CASSANDRA_TEST_URL_PARAMS`.
|
||||
|
||||
See `bench/README.md` for the archived JDBC comparison workflow and measured
|
||||
Cassandra 4.1.10 results.
|
||||
|
||||
@@ -20,11 +20,21 @@ import (
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
)
|
||||
|
||||
// testPlaceholderPassword returns a stand-in for password fields in tests;
|
||||
// Astra treats the password as an opaque application token, so any non-empty
|
||||
// value exercises the same code path. Set DBX_TEST_PASSWORD to override.
|
||||
func testPlaceholderPassword() string {
|
||||
if value := os.Getenv("DBX_TEST_PASSWORD"); value != "" {
|
||||
return value
|
||||
}
|
||||
return "placeholder-auth-value"
|
||||
}
|
||||
|
||||
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
||||
bundlePath := writeTestSecureConnectBundle(t)
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "astra-token",
|
||||
Password: testPlaceholderPassword(),
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{bundlePath},
|
||||
"requesttimeout": []string{"9s"},
|
||||
@@ -49,7 +59,7 @@ func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
||||
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
|
||||
}
|
||||
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
|
||||
if !ok || credentials.Username != "token" || credentials.Password != "astra-token" {
|
||||
if !ok || credentials.Username != "token" || credentials.Password != testPlaceholderPassword() {
|
||||
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
|
||||
}
|
||||
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
|
||||
@@ -95,7 +105,7 @@ func TestSecureConnectBundleValidatesCredentialsAndAuthMode(t *testing.T) {
|
||||
|
||||
if _, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "astra-token",
|
||||
Password: testPlaceholderPassword(),
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{bundlePath},
|
||||
"usekrb5": []string{"true"},
|
||||
|
||||
@@ -38,11 +38,12 @@ python3 drivers/cassandra-go/bench/agent_compare.py \
|
||||
> /tmp/dbx-cassandra-bench/result.json
|
||||
```
|
||||
|
||||
If Java is only available in a container, provide the full interactive command:
|
||||
If Java is only available in a container, provide the full interactive command
|
||||
as a JSON argv array so no shell is implied (pass `["sh", "-c", "..."]`
|
||||
explicitly when you need shell features):
|
||||
|
||||
```bash
|
||||
JDBC_AGENT_COMMAND='docker run --rm -i --name dbx-cassandra-jdbc-bench -v /tmp/dbx-cassandra-bench:/bench:ro eclipse-temurin:21-jre java -jar /bench/dbx-agent-cassandra.jar'
|
||||
JDBC_RSS_COMMAND="docker inspect --format '{{.State.Pid}}' dbx-cassandra-jdbc-bench | xargs -I{} awk '/VmRSS/ {print \$2}' /proc/{}/status"
|
||||
JDBC_AGENT_COMMAND='["docker","run","--rm","-i","--name","dbx-cassandra-jdbc-bench","-v","/tmp/dbx-cassandra-bench:/bench:ro","eclipse-temurin:21-jre","java","-jar","/bench/dbx-agent-cassandra.jar"]'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -16,7 +15,21 @@ class Candidate:
|
||||
name: str
|
||||
command: list[str]
|
||||
artifact: Path
|
||||
rss_command: str = ""
|
||||
|
||||
|
||||
def command_env_list(name: str) -> list[str]:
|
||||
"""Reads a command from an env var holding a JSON argv array.
|
||||
|
||||
Command env vars take JSON arrays so no shell is implied; operators who
|
||||
need shell features pass ["sh", "-c", "..."] explicitly.
|
||||
"""
|
||||
raw = os.getenv(name, "")
|
||||
if not raw:
|
||||
return []
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise ValueError(f"{name} must be a JSON array of strings")
|
||||
return [item for item in value]
|
||||
|
||||
|
||||
class AgentProcess:
|
||||
@@ -81,9 +94,6 @@ class AgentProcess:
|
||||
return response.get("result")
|
||||
|
||||
def rss_kib(self) -> int:
|
||||
if self.candidate.rss_command:
|
||||
output = subprocess.check_output(self.candidate.rss_command, shell=True, text=True).strip()
|
||||
return int(output)
|
||||
output = subprocess.check_output(
|
||||
["ps", "-o", "rss=", "-p", str(self.process.pid)],
|
||||
text=True,
|
||||
@@ -168,12 +178,11 @@ def configured_candidates() -> list[Candidate]:
|
||||
candidates = []
|
||||
if "go" in selected:
|
||||
artifact = required_path("GO_AGENT")
|
||||
candidates.append(Candidate("go-native", [str(artifact)], artifact, os.getenv("GO_RSS_COMMAND", "")))
|
||||
candidates.append(Candidate("go-native", [str(artifact)], artifact))
|
||||
if "jdbc" in selected:
|
||||
artifact = required_path("JDBC_AGENT_JAR")
|
||||
raw_command = os.getenv("JDBC_AGENT_COMMAND", "")
|
||||
command = shlex.split(raw_command) if raw_command else [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
|
||||
candidates.append(Candidate("jdbc-java", command, artifact, os.getenv("JDBC_RSS_COMMAND", "")))
|
||||
command = command_env_list("JDBC_AGENT_COMMAND") or [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
|
||||
candidates.append(Candidate("jdbc-java", command, artifact))
|
||||
if not candidates:
|
||||
raise ValueError("BENCH_CANDIDATES selected no candidates")
|
||||
return candidates
|
||||
|
||||
@@ -31,6 +31,10 @@ type cassandraConfig struct {
|
||||
caCertPath string
|
||||
clientCertPath string
|
||||
clientKeyPath string
|
||||
truststorePath string
|
||||
truststorePassword string
|
||||
keystorePath string
|
||||
keystorePassword string
|
||||
hostVerification bool
|
||||
tcpNoDelay bool
|
||||
keepAlive bool
|
||||
@@ -61,6 +65,10 @@ func parseCassandraConfig(cp connectParams) (cassandraConfig, error) {
|
||||
caCertPath: cp.CACertPath,
|
||||
clientCertPath: cp.ClientCertPath,
|
||||
clientKeyPath: cp.ClientKeyPath,
|
||||
truststorePath: cp.TruststorePath,
|
||||
truststorePassword: cp.TruststorePassword,
|
||||
keystorePath: cp.KeystorePath,
|
||||
keystorePassword: cp.KeystorePassword,
|
||||
hostVerification: true,
|
||||
tcpNoDelay: true,
|
||||
retryCount: 3,
|
||||
@@ -224,6 +232,14 @@ func applyCassandraURLParams(config *cassandraConfig, params url.Values) error {
|
||||
return fmt.Errorf("invalid host verification option: %w", err)
|
||||
}
|
||||
config.hostVerification = enabled
|
||||
case "truststorepath":
|
||||
config.truststorePath = value
|
||||
case "truststorepassword":
|
||||
config.truststorePassword = value
|
||||
case "keystorepath":
|
||||
config.keystorePath = value
|
||||
case "keystorepassword":
|
||||
config.keystorePassword = value
|
||||
case "tcpnodelay":
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
@@ -406,12 +422,20 @@ func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConf
|
||||
cluster.Authenticator = gocql.PasswordAuthenticator{Username: config.username, Password: config.password}
|
||||
}
|
||||
if config.secureConnectBundle == "" && config.ssl {
|
||||
cluster.SslOpts = &gocql.SslOptions{
|
||||
sslOptions := &gocql.SslOptions{
|
||||
CaPath: config.caCertPath,
|
||||
CertPath: config.clientCertPath,
|
||||
KeyPath: config.clientKeyPath,
|
||||
EnableHostVerification: config.hostVerification,
|
||||
}
|
||||
if config.truststorePath != "" || config.keystorePath != "" {
|
||||
tlsConfig, err := buildCassandraTLSConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sslOptions.Config = tlsConfig
|
||||
}
|
||||
cluster.SslOpts = sslOptions
|
||||
}
|
||||
if config.debug {
|
||||
cluster.Logger = gocql.NewLogger(gocql.LogLevelDebug)
|
||||
@@ -435,6 +459,31 @@ func (config *cassandraConfig) finalize() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra secureconnectbundle: %w", err)
|
||||
}
|
||||
if config.secureConnectBundle == "" {
|
||||
config.truststorePath, err = normalizeLocalFilePath(config.truststorePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra truststore path: %w", err)
|
||||
}
|
||||
config.keystorePath, err = normalizeLocalFilePath(config.keystorePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Cassandra keystore path: %w", err)
|
||||
}
|
||||
if config.truststorePassword != "" && config.truststorePath == "" {
|
||||
return fmt.Errorf("Cassandra truststore password requires a truststore path")
|
||||
}
|
||||
if config.keystorePassword != "" && config.keystorePath == "" {
|
||||
return fmt.Errorf("Cassandra keystore password requires a keystore path")
|
||||
}
|
||||
if config.truststorePath != "" && strings.TrimSpace(config.caCertPath) != "" {
|
||||
return fmt.Errorf("Cassandra truststore cannot be combined with a PEM CA certificate")
|
||||
}
|
||||
if config.keystorePath != "" && (strings.TrimSpace(config.clientCertPath) != "" || strings.TrimSpace(config.clientKeyPath) != "") {
|
||||
return fmt.Errorf("Cassandra keystore cannot be combined with PEM client certificate settings")
|
||||
}
|
||||
if config.truststorePath != "" || config.keystorePath != "" {
|
||||
config.ssl = true
|
||||
}
|
||||
}
|
||||
if config.secureConnectBundle != "" && config.kerberos.enabled {
|
||||
return fmt.Errorf("Cassandra secure connect bundles cannot be combined with Kerberos authentication")
|
||||
}
|
||||
|
||||
@@ -263,13 +263,25 @@ func applyHOCONSSL(config *cassandraConfig, parsed *hocon.Config) error {
|
||||
config.hostVerification = value
|
||||
config.ssl = true
|
||||
}
|
||||
for _, path := range []string{prefix + "truststore-path", prefix + "keystore-path"} {
|
||||
if value, ok, err := hoconString(parsed, path); err != nil {
|
||||
stringMappings := []struct {
|
||||
path string
|
||||
target *string
|
||||
}{
|
||||
{prefix + "truststore-path", &config.truststorePath},
|
||||
{prefix + "truststore-password", &config.truststorePassword},
|
||||
{prefix + "keystore-path", &config.keystorePath},
|
||||
{prefix + "keystore-password", &config.keystorePassword},
|
||||
}
|
||||
for _, mapping := range stringMappings {
|
||||
if value, ok, err := hoconString(parsed, mapping.path); err != nil {
|
||||
return err
|
||||
} else if ok && value != "" {
|
||||
return fmt.Errorf("Java truststore and keystore files are not supported; use dbx.cassandra.tls PEM paths")
|
||||
} else if ok {
|
||||
*mapping.target = value
|
||||
}
|
||||
}
|
||||
if config.truststorePath != "" || config.keystorePath != "" {
|
||||
config.ssl = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ datastax-java-driver {
|
||||
ssl-engine-factory {
|
||||
class = DefaultSslEngineFactory
|
||||
hostname-validation = false
|
||||
truststore-path = /certs/client.truststore
|
||||
truststore-password = trust-secret
|
||||
keystore-path = /certs/client.keystore
|
||||
keystore-password = key-secret
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +65,7 @@ datastax-java-driver {
|
||||
Host: "url.example.com",
|
||||
Database: "url_keyspace",
|
||||
Username: "url-user",
|
||||
Password: "url-password",
|
||||
Password: testPlaceholderPassword(),
|
||||
URLParams: url.Values{
|
||||
"configfile": []string{configPath},
|
||||
"requesttimeout": []string{"30s"},
|
||||
@@ -110,6 +114,12 @@ datastax-java-driver {
|
||||
if !config.ssl || config.hostVerification {
|
||||
t.Fatalf("unexpected TLS config: %#v", config)
|
||||
}
|
||||
if config.truststorePath != filepath.Clean("/certs/client.truststore") || config.truststorePassword != "trust-secret" {
|
||||
t.Fatalf("unexpected truststore config: %#v", config)
|
||||
}
|
||||
if config.keystorePath != filepath.Clean("/certs/client.keystore") || config.keystorePassword != "key-secret" {
|
||||
t.Fatalf("unexpected keystore config: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCassandraConfigFileSupportsKerberosLoginOptionCasing(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,8 @@ require (
|
||||
github.com/gurkankaymak/hocon v1.3.0
|
||||
github.com/jcmturner/gofork v1.7.6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -23,7 +25,7 @@ require (
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.6.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.11.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
)
|
||||
|
||||
@@ -45,6 +45,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ=
|
||||
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4=
|
||||
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -74,16 +76,18 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -112,3 +116,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -35,18 +35,22 @@ type response struct {
|
||||
}
|
||||
|
||||
type connectParams struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
URLParams string `json:"url_params"`
|
||||
ConnectionString string `json:"connection_string"`
|
||||
SSL bool `json:"ssl"`
|
||||
CACertPath string `json:"ca_cert_path"`
|
||||
ClientCertPath string `json:"client_cert_path"`
|
||||
ClientKeyPath string `json:"client_key_path"`
|
||||
SessionRole string `json:"sessionRole"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
URLParams string `json:"url_params"`
|
||||
ConnectionString string `json:"connection_string"`
|
||||
SSL bool `json:"ssl"`
|
||||
CACertPath string `json:"ca_cert_path"`
|
||||
ClientCertPath string `json:"client_cert_path"`
|
||||
ClientKeyPath string `json:"client_key_path"`
|
||||
TruststorePath string `json:"truststore_path"`
|
||||
TruststorePassword string `json:"truststore_password"`
|
||||
KeystorePath string `json:"keystore_path"`
|
||||
KeystorePassword string `json:"keystore_password"`
|
||||
SessionRole string `json:"sessionRole"`
|
||||
}
|
||||
|
||||
type queryOptions struct {
|
||||
|
||||
@@ -231,38 +231,80 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := sortedMapKeys(metadata.Tables)
|
||||
result := make([]tableInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
return tableInfosFromKeyspaceMetadata(metadata, constraints), nil
|
||||
}
|
||||
|
||||
// Materialized views live in their own section of the keyspace metadata, so
|
||||
// listing tables only would hide them from the browser. Report them with their
|
||||
// own table type so tables and views stay distinguishable.
|
||||
func tableInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, constraints metadataListConstraints) []tableInfo {
|
||||
result := make([]tableInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews))
|
||||
for _, name := range sortedMapKeys(metadata.Tables) {
|
||||
if !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
}
|
||||
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
|
||||
}
|
||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
|
||||
for _, name := range sortedMapKeys(metadata.MaterializedViews) {
|
||||
if !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
}
|
||||
result = append(result, tableInfo{Name: name, TableType: "MATERIALIZED_VIEW"})
|
||||
}
|
||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit)
|
||||
}
|
||||
|
||||
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
|
||||
tables, err := s.listTables(schema, metadataListConstraints{Filter: constraints.Filter})
|
||||
metadata, err := s.keyspaceMetadata(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return objectInfosFromKeyspaceMetadata(metadata, schema, constraints), nil
|
||||
}
|
||||
|
||||
func objectInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, schema string, constraints metadataListConstraints) []objectInfo {
|
||||
allowed := stringSet(constraints.ObjectTypes)
|
||||
result := make([]objectInfo, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
if len(allowed) > 0 && !allowed["table"] && !allowed["base_table"] {
|
||||
continue
|
||||
tablesAllowed := len(allowed) == 0 || allowed["table"] || allowed["base_table"]
|
||||
viewsAllowed := len(allowed) == 0 || allowed["view"] || allowed["materialized_view"]
|
||||
result := make([]objectInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews))
|
||||
if tablesAllowed {
|
||||
for _, name := range sortedMapKeys(metadata.Tables) {
|
||||
if !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
}
|
||||
result = append(result, objectInfo{Name: name, ObjectType: "TABLE", Schema: schema})
|
||||
}
|
||||
result = append(result, objectInfo{Name: table.Name, ObjectType: "TABLE", Schema: schema})
|
||||
}
|
||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
|
||||
if viewsAllowed {
|
||||
for _, name := range sortedMapKeys(metadata.MaterializedViews) {
|
||||
if !metadataNameMatches(name, constraints.Filter) {
|
||||
continue
|
||||
}
|
||||
result = append(result, objectInfo{Name: name, ObjectType: "MATERIALIZED_VIEW", Schema: schema})
|
||||
}
|
||||
}
|
||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit)
|
||||
}
|
||||
|
||||
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
|
||||
metadata, err := s.tableMetadata(schema, table)
|
||||
keyspace, err := s.keyspaceMetadata(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columnsForSchemaObject(keyspace, schema, table)
|
||||
}
|
||||
|
||||
func columnsForSchemaObject(keyspace *gocql.KeyspaceMetadata, schema, table string) ([]columnInfo, error) {
|
||||
if view := keyspace.MaterializedViews[table]; view != nil {
|
||||
if view.BaseTable == nil {
|
||||
return nil, fmt.Errorf("Cassandra materialized view base table metadata unavailable: %s.%s", schema, table)
|
||||
}
|
||||
return columnsFromMetadata(view.BaseTable), nil
|
||||
}
|
||||
metadata := keyspace.Tables[table]
|
||||
if metadata == nil {
|
||||
return nil, fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
|
||||
}
|
||||
return columnsFromMetadata(metadata), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -159,3 +159,125 @@ func TestListTriggersReturnsQueryError(t *testing.T) {
|
||||
t.Fatalf("triggers = %#v, err = %v", triggers, err)
|
||||
}
|
||||
}
|
||||
|
||||
func keyspaceWithTablesAndViews() *gocql.KeyspaceMetadata {
|
||||
return &gocql.KeyspaceMetadata{
|
||||
Tables: map[string]*gocql.TableMetadata{
|
||||
"users": {},
|
||||
"events": {},
|
||||
},
|
||||
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||
"users_by_email": {Name: "users_by_email"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableInfosReportMaterializedViewsAsTheirOwnType(t *testing.T) {
|
||||
tables := tableInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), metadataListConstraints{})
|
||||
want := []tableInfo{
|
||||
{Name: "events", TableType: "TABLE"},
|
||||
{Name: "users", TableType: "TABLE"},
|
||||
{Name: "users_by_email", TableType: "MATERIALIZED_VIEW"},
|
||||
}
|
||||
if !reflect.DeepEqual(tables, want) {
|
||||
t.Fatalf("tables = %#v, want %#v", tables, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableInfosFilterAppliesToTablesAndViews(t *testing.T) {
|
||||
tables := tableInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), metadataListConstraints{Filter: "users"})
|
||||
want := []tableInfo{
|
||||
{Name: "users", TableType: "TABLE"},
|
||||
{Name: "users_by_email", TableType: "MATERIALIZED_VIEW"},
|
||||
}
|
||||
if !reflect.DeepEqual(tables, want) {
|
||||
t.Fatalf("tables = %#v, want %#v", tables, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectInfosIncludeMaterializedViews(t *testing.T) {
|
||||
objects := objectInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), "dev", metadataListConstraints{})
|
||||
want := []objectInfo{
|
||||
{Name: "events", ObjectType: "TABLE", Schema: "dev"},
|
||||
{Name: "users", ObjectType: "TABLE", Schema: "dev"},
|
||||
{Name: "users_by_email", ObjectType: "MATERIALIZED_VIEW", Schema: "dev"},
|
||||
}
|
||||
if !reflect.DeepEqual(objects, want) {
|
||||
t.Fatalf("objects = %#v, want %#v", objects, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectInfosRespectObjectTypeFilters(t *testing.T) {
|
||||
keyspace := keyspaceWithTablesAndViews()
|
||||
|
||||
tablesOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"table"}})
|
||||
if len(tablesOnly) != 2 || tablesOnly[0].Name != "events" || tablesOnly[1].Name != "users" {
|
||||
t.Fatalf("tablesOnly = %#v", tablesOnly)
|
||||
}
|
||||
|
||||
viewsOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"view"}})
|
||||
want := []objectInfo{{Name: "users_by_email", ObjectType: "MATERIALIZED_VIEW", Schema: "dev"}}
|
||||
if !reflect.DeepEqual(viewsOnly, want) {
|
||||
t.Fatalf("viewsOnly = %#v, want %#v", viewsOnly, want)
|
||||
}
|
||||
|
||||
materializedOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"materialized_view"}})
|
||||
if !reflect.DeepEqual(materializedOnly, want) {
|
||||
t.Fatalf("materializedOnly = %#v, want %#v", materializedOnly, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnsForSchemaObjectResolvesViewColumnsFromBaseTable(t *testing.T) {
|
||||
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
|
||||
id := &gocql.ColumnMetadata{Name: "email", Kind: gocql.ColumnPartitionKey, Type: textType}
|
||||
name := &gocql.ColumnMetadata{Name: "username", Kind: gocql.ColumnRegular, Type: textType}
|
||||
baseTable := &gocql.TableMetadata{
|
||||
OrderedColumns: []string{"email", "username"},
|
||||
PartitionKey: []*gocql.ColumnMetadata{id},
|
||||
Columns: map[string]*gocql.ColumnMetadata{"email": id, "username": name},
|
||||
}
|
||||
keyspace := &gocql.KeyspaceMetadata{
|
||||
Tables: map[string]*gocql.TableMetadata{"users": baseTable},
|
||||
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||
"users_by_email": {Name: "users_by_email", BaseTable: baseTable},
|
||||
},
|
||||
}
|
||||
|
||||
columns, err := columnsForSchemaObject(keyspace, "dev", "users_by_email")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"email", "username"}
|
||||
if len(columns) != len(want) {
|
||||
t.Fatalf("columns = %#v", columns)
|
||||
}
|
||||
for index, column := range columns {
|
||||
if column.Name != want[index] {
|
||||
t.Fatalf("columns[%d] = %s, want %s", index, column.Name, want[index])
|
||||
}
|
||||
if column.IsPrimaryKey != (index == 0) {
|
||||
t.Fatalf("columns[%d].IsPrimaryKey = %v", index, column.IsPrimaryKey)
|
||||
}
|
||||
}
|
||||
|
||||
tableColumns, err := columnsForSchemaObject(keyspace, "dev", "users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tableColumns) != 2 || tableColumns[0].Name != "email" {
|
||||
t.Fatalf("tableColumns = %#v", tableColumns)
|
||||
}
|
||||
|
||||
if _, err := columnsForSchemaObject(keyspace, "dev", "missing"); err == nil {
|
||||
t.Fatal("expected an error for a missing table")
|
||||
}
|
||||
|
||||
orphanKeyspace := &gocql.KeyspaceMetadata{
|
||||
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||
"users_by_email": {Name: "users_by_email"},
|
||||
},
|
||||
}
|
||||
if _, err := columnsForSchemaObject(orphanKeyspace, "dev", "users_by_email"); err == nil {
|
||||
t.Fatal("expected an error when view base table metadata is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
keystore "github.com/pavlo-v-chernykh/keystore-go/v4"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
const jksMagic = 0xfeedfeed
|
||||
|
||||
func buildCassandraTLSConfig(config cassandraConfig) (*tls.Config, error) {
|
||||
tlsConfig := &tls.Config{InsecureSkipVerify: !config.hostVerification}
|
||||
if config.truststorePath != "" {
|
||||
certificates, err := loadTruststore(config.truststorePath, config.truststorePassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Cassandra truststore %q: %w", config.truststorePath, err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
for _, certificate := range certificates {
|
||||
roots.AddCert(certificate)
|
||||
}
|
||||
tlsConfig.RootCAs = roots
|
||||
}
|
||||
if config.keystorePath != "" {
|
||||
certificate, err := loadKeystore(config.keystorePath, config.keystorePassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Cassandra keystore %q: %w", config.keystorePath, err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{certificate}
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func loadTruststore(path string, password string) ([]*x509.Certificate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isJKS(data) {
|
||||
return loadJKSTruststore(data, password)
|
||||
}
|
||||
certificates, err := pkcs12.DecodeTrustStore(data, password)
|
||||
if err == nil && len(certificates) > 0 {
|
||||
return certificates, nil
|
||||
}
|
||||
_, certificate, chain, chainErr := pkcs12.DecodeChain(data, password)
|
||||
if chainErr != nil {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode PKCS#12 truststore: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("PKCS#12 truststore contains no trusted certificates")
|
||||
}
|
||||
return append([]*x509.Certificate{certificate}, chain...), nil
|
||||
}
|
||||
|
||||
func loadKeystore(path string, password string) (tls.Certificate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if isJKS(data) {
|
||||
return loadJKSKeystore(data, password)
|
||||
}
|
||||
privateKey, certificate, caCertificates, err := pkcs12.DecodeChain(data, password)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("decode PKCS#12 keystore: %w", err)
|
||||
}
|
||||
chain := make([][]byte, 0, 1+len(caCertificates))
|
||||
chain = append(chain, certificate.Raw)
|
||||
for _, caCertificate := range caCertificates {
|
||||
chain = append(chain, caCertificate.Raw)
|
||||
}
|
||||
return tls.Certificate{Certificate: chain, PrivateKey: privateKey, Leaf: certificate}, nil
|
||||
}
|
||||
|
||||
func isJKS(data []byte) bool {
|
||||
return len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == jksMagic
|
||||
}
|
||||
|
||||
func loadJKS(data []byte, password string) (keystore.KeyStore, error) {
|
||||
store := keystore.New()
|
||||
passwordBytes := []byte(password)
|
||||
defer clear(passwordBytes)
|
||||
if err := store.Load(bytes.NewReader(data), passwordBytes); err != nil {
|
||||
return keystore.KeyStore{}, fmt.Errorf("decode JKS: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func loadJKSTruststore(data []byte, password string) ([]*x509.Certificate, error) {
|
||||
store, err := loadJKS(data, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certificates := make([]*x509.Certificate, 0)
|
||||
for _, alias := range store.Aliases() {
|
||||
switch {
|
||||
case store.IsTrustedCertificateEntry(alias):
|
||||
entry, err := store.GetTrustedCertificateEntry(alias)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read trusted certificate %q: %w", alias, err)
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(entry.Certificate.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse trusted certificate %q: %w", alias, err)
|
||||
}
|
||||
certificates = append(certificates, certificate)
|
||||
case store.IsPrivateKeyEntry(alias):
|
||||
chain, err := store.GetPrivateKeyEntryCertificateChain(alias)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate chain %q: %w", alias, err)
|
||||
}
|
||||
for _, entry := range chain {
|
||||
certificate, err := x509.ParseCertificate(entry.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse certificate chain %q: %w", alias, err)
|
||||
}
|
||||
certificates = append(certificates, certificate)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return nil, fmt.Errorf("JKS truststore contains no trusted certificates")
|
||||
}
|
||||
return certificates, nil
|
||||
}
|
||||
|
||||
func loadJKSKeystore(data []byte, password string) (tls.Certificate, error) {
|
||||
store, err := loadJKS(data, password)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
passwordBytes := []byte(password)
|
||||
defer clear(passwordBytes)
|
||||
for _, alias := range store.Aliases() {
|
||||
if !store.IsPrivateKeyEntry(alias) {
|
||||
continue
|
||||
}
|
||||
entry, err := store.GetPrivateKeyEntry(alias, passwordBytes)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("read private key %q: %w", alias, err)
|
||||
}
|
||||
privateKey, err := parsePrivateKey(entry.PrivateKey)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("parse private key %q: %w", alias, err)
|
||||
}
|
||||
chain := make([][]byte, 0, len(entry.CertificateChain))
|
||||
for _, certificate := range entry.CertificateChain {
|
||||
chain = append(chain, certificate.Content)
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
return tls.Certificate{}, fmt.Errorf("private key %q has no certificate chain", alias)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(chain[0])
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("parse client certificate %q: %w", alias, err)
|
||||
}
|
||||
return tls.Certificate{Certificate: chain, PrivateKey: privateKey, Leaf: leaf}, nil
|
||||
}
|
||||
return tls.Certificate{}, fmt.Errorf("JKS keystore contains no private key entries")
|
||||
}
|
||||
|
||||
func parsePrivateKey(data []byte) (any, error) {
|
||||
if value, err := x509.ParsePKCS8PrivateKey(data); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
if value, err := x509.ParsePKCS1PrivateKey(data); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
if value, err := x509.ParseECPrivateKey(data); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
return nil, errors.New("unsupported private key encoding")
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
keystore "github.com/pavlo-v-chernykh/keystore-go/v4"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
const tlsStoreTestPassword = "changeit"
|
||||
|
||||
func TestLoadJKSStores(t *testing.T) {
|
||||
_, certificate, privateKeyDER := testTLSIdentity(t)
|
||||
store := keystore.New()
|
||||
if err := store.SetTrustedCertificateEntry("ca", keystore.TrustedCertificateEntry{
|
||||
CreationTime: time.Now(),
|
||||
Certificate: keystore.Certificate{Type: "X509", Content: certificate.Raw},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetPrivateKeyEntry("client", keystore.PrivateKeyEntry{
|
||||
CreationTime: time.Now(),
|
||||
PrivateKey: privateKeyDER,
|
||||
CertificateChain: []keystore.Certificate{
|
||||
{Type: "X509", Content: certificate.Raw},
|
||||
},
|
||||
}, []byte(tlsStoreTestPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := store.Store(&encoded, []byte(tlsStoreTestPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "client.jks")
|
||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
trusted, err := loadTruststore(path, tlsStoreTestPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := loadKeystore(path, tlsStoreTestPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(trusted) != 2 || identity.Leaf == nil {
|
||||
t.Fatalf("unexpected JKS contents: trusted=%d identity=%#v", len(trusted), identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPKCS12Stores(t *testing.T) {
|
||||
privateKey, certificate, _ := testTLSIdentity(t)
|
||||
truststore, err := pkcs12.Modern.EncodeTrustStore([]*x509.Certificate{certificate}, tlsStoreTestPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keystoreData, err := pkcs12.Modern.Encode(privateKey, certificate, nil, tlsStoreTestPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
truststorePath := filepath.Join(directory, "client-trust.p12")
|
||||
keystorePath := filepath.Join(directory, "client-key.p12")
|
||||
if err := os.WriteFile(truststorePath, truststore, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keystorePath, keystoreData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tlsConfig, err := buildCassandraTLSConfig(cassandraConfig{
|
||||
truststorePath: truststorePath,
|
||||
truststorePassword: tlsStoreTestPassword,
|
||||
keystorePath: keystorePath,
|
||||
keystorePassword: tlsStoreTestPassword,
|
||||
hostVerification: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tlsConfig.RootCAs == nil || len(tlsConfig.Certificates) != 1 || tlsConfig.InsecureSkipVerify {
|
||||
t.Fatalf("unexpected PKCS#12 TLS config: %#v", tlsConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCassandraConfigRejectsStorePasswordWithoutPath(t *testing.T) {
|
||||
_, err := parseCassandraConfig(connectParams{Host: "localhost", TruststorePassword: "secret"})
|
||||
if err == nil {
|
||||
t.Fatal("expected truststore path validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureConnectBundleIgnoresManualTLSStoreSettings(t *testing.T) {
|
||||
config, err := parseCassandraConfig(connectParams{
|
||||
Username: "token",
|
||||
Password: "secret",
|
||||
CACertPath: "ca.pem",
|
||||
URLParams: url.Values{
|
||||
"secureconnectbundle": []string{"bundle.zip"},
|
||||
"truststorepath": []string{"https://ignored.example/truststore"},
|
||||
"truststorepassword": []string{"ignored"},
|
||||
}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.secureConnectBundle != "bundle.zip" {
|
||||
t.Fatalf("unexpected secure connect bundle: %q", config.secureConnectBundle)
|
||||
}
|
||||
}
|
||||
|
||||
func testTLSIdentity(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate, []byte) {
|
||||
t.Helper()
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "cassandra-client"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
}
|
||||
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(certificateDER)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return privateKey, certificate, privateKeyDER
|
||||
}
|
||||
@@ -68,6 +68,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
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+)\\.");
|
||||
// Word-boundary match so a type or default merely containing the letters is not mistaken
|
||||
// for the IDENTITY keyword.
|
||||
private static final Pattern DDL_IDENTITY_KEYWORD_PATTERN =
|
||||
Pattern.compile("(?i)(?<![A-Z0-9_$#])IDENTITY(?![A-Z0-9_$#])");
|
||||
private static final JdbcAgentProfile DM6_METADATA_PROFILE = new JdbcAgentProfile(
|
||||
"dm6.jdbc.driver.DmDriver",
|
||||
"jdbc:dm6://{host}:{port}/{database}",
|
||||
@@ -109,6 +113,19 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
private URLClassLoader externalDriverLoader;
|
||||
private List<URL> externalDriverUrls;
|
||||
private String externalDriverClass;
|
||||
|
||||
@Override
|
||||
protected JdbcExecutor.ResultValueReader resultValueReader() {
|
||||
return (JdbcExecutor.ColumnAwareResultValueReader) this::readDamengValue;
|
||||
}
|
||||
|
||||
private Object readDamengValue(ResultSet resultSet, int index, int sqlType, String columnTypeName) throws SQLException {
|
||||
if ("VARCHAR2".equalsIgnoreCase(columnTypeName == null ? "" : columnTypeName.trim())) {
|
||||
String value = resultSet.getString(index);
|
||||
return resultSet.wasNull() ? null : value;
|
||||
}
|
||||
return super.resultValue(resultSet, index, sqlType);
|
||||
}
|
||||
private volatile boolean legacyJdbcMetadata;
|
||||
private volatile boolean dbmsOutputInitializationSupported = true;
|
||||
private final Map<Object, Boolean> dbmsOutputInitializedConnections =
|
||||
@@ -461,7 +478,21 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
if (legacyJdbcMetadata) {
|
||||
return unchecked(() -> listJdbcSchemas().stream().map(DatabaseInfo::new).toList());
|
||||
}
|
||||
return unchecked(() -> listVisibleUsers().stream().map(DatabaseInfo::new).toList());
|
||||
// DM8 ALL_USERS is privilege-filtered: a normal user only sees itself, so prefer the
|
||||
// full SYS.SYSOBJECTS catalog (mirroring listSchemas; newer/hardened builds require
|
||||
// the SOI role) and fall back to ALL_USERS when the catalog is not readable.
|
||||
return unchecked(() -> {
|
||||
try {
|
||||
return listVisibleSchemas().stream().map(DatabaseInfo::new).toList();
|
||||
} catch (SQLException catalogError) {
|
||||
try {
|
||||
return listVisibleUsers().stream().map(DatabaseInfo::new).toList();
|
||||
} catch (Exception fallbackError) {
|
||||
catalogError.addSuppressed(fallbackError);
|
||||
throw catalogError;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -722,7 +753,11 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
|| normalized.contains("all_views")
|
||||
|| normalized.contains("all_triggers")
|
||||
|| normalized.contains("all_sequences")
|
||||
|| normalized.contains("systexts");
|
||||
|| normalized.contains("systexts")
|
||||
// DM8 may report a view's metadata failure as an internal
|
||||
// index lookup error without mentioning DBMS_METADATA.
|
||||
|| normalized.contains("内部索引")
|
||||
|| normalized.contains("internal index");
|
||||
boolean permissionDenied = normalized.contains("权限")
|
||||
|| normalized.contains("privilege")
|
||||
|| normalized.contains("permission")
|
||||
@@ -731,6 +766,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
boolean missingObject = normalized.contains("解析失败")
|
||||
|| normalized.contains("无法解析")
|
||||
|| normalized.contains("不存在")
|
||||
|| normalized.contains("未找到")
|
||||
|| normalized.contains("not exist")
|
||||
|| normalized.contains("does not exist")
|
||||
|| normalized.contains("not found")
|
||||
@@ -1683,6 +1719,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
if (!isDamengMetadataUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
String catalogDdl = catalogTableLikeDdl(schema, table);
|
||||
if (catalogDdl != null) {
|
||||
return catalogDdl;
|
||||
}
|
||||
try {
|
||||
return super.getTableDdl(schema, table);
|
||||
} catch (RuntimeException fallbackError) {
|
||||
@@ -1692,6 +1732,58 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DBMS_METADATA.GET_DDL('TABLE', ...) is also used for views by the generic
|
||||
* table-DDL endpoint. DM8 can reject that call with an internal-index error,
|
||||
* even though the view definition is available in ALL_VIEWS.
|
||||
*/
|
||||
private String catalogTableLikeDdl(String schema, String table) throws RuntimeException {
|
||||
if (schemaMatchesConnectedUser(schema)) {
|
||||
try {
|
||||
String materializedQuery = readCatalogText(
|
||||
"SELECT QUERY FROM USER_MVIEWS WHERE MVIEW_NAME = ?",
|
||||
table
|
||||
);
|
||||
if (notBlank(materializedQuery)) {
|
||||
return buildCatalogTableLikeDdl(schema, table, "MATERIALIZED VIEW", materializedQuery);
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
if (!isDamengMetadataUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String viewText = readCatalogText(
|
||||
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?",
|
||||
schema,
|
||||
table
|
||||
);
|
||||
if (notBlank(viewText)) {
|
||||
return buildCatalogTableLikeDdl(schema, table, "VIEW", viewText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readCatalogText(String sql, String... params) {
|
||||
return unchecked(() -> {
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
stmt.setString(i + 1, params[i]);
|
||||
}
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
return rs.next() ? coalesce(readTextColumn(rs, 1)) : "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String buildCatalogTableLikeDdl(String schema, String table, String objectType, String body) {
|
||||
String normalizedBody = body.trim();
|
||||
String statement = "CREATE " + objectType + " " + qualifiedName(schema, table) + " AS " + normalizedBody;
|
||||
return ensureStatementTerminator(statement);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
if (legacyJdbcMetadata) {
|
||||
@@ -1719,6 +1811,13 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pkColumns.isEmpty()) {
|
||||
// ALL_CONS_COLUMNS/ALL_CONSTRAINTS return no rows — rather than an error — for
|
||||
// accounts that cannot see the constraint dictionary, which is indistinguishable
|
||||
// from a table that genuinely has no primary key. Re-ask through the driver,
|
||||
// which reports primary keys to the table owner regardless of dictionary grants.
|
||||
pkColumns.addAll(primaryKeyColumnsFromJdbcMetadata(schema, table));
|
||||
}
|
||||
|
||||
Set<String> identityColumns = identityColumns(schema, table);
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
@@ -1780,6 +1879,23 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private Set<String> identityColumns(String schema, String table) {
|
||||
try {
|
||||
return identityColumnsFromSystemCatalog(schema, table);
|
||||
} catch (Exception systemCatalogError) {
|
||||
// SYS.SYSCOLUMNS is not granted to PUBLIC, so ordinary (non-DBA) accounts fail here
|
||||
// with a permission error. Reporting that as an empty set is indistinguishable from
|
||||
// "this table has no identity column", and the data grid then sends the identity
|
||||
// value on INSERT — which the server rejects, while leaving it out trips the NOT NULL
|
||||
// check. Fall back to the table DDL, which the table owner can always read.
|
||||
//
|
||||
// Any failure triggers the fallback, not just a permission error: the server message
|
||||
// is localized, so matching on its text would silently stop working on a non-Chinese
|
||||
// server, and every other failure mode wants the same fallback anyway.
|
||||
return identityColumnsFromTableDdl(schema, table);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> identityColumnsFromSystemCatalog(String schema, String table) throws Exception {
|
||||
Set<String> result = new java.util.HashSet<>();
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ c.NAME
|
||||
@@ -1799,8 +1915,177 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<String> identityColumnsFromTableDdl(String schema, String table) {
|
||||
String ddl = null;
|
||||
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, "TABLE");
|
||||
stmt.setString(2, table);
|
||||
stmt.setString(3, schema);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
ddl = readTextColumn(rs, 1);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Some Dameng versions or users do not expose SYS.SYSCOLUMNS.
|
||||
// DBMS_METADATA is unavailable on some deployments (and on views); identity
|
||||
// information stays unknown, which is the best this path can do.
|
||||
}
|
||||
return identityColumnsFromDdlText(ddl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the identity columns out of a {@code CREATE TABLE} statement, e.g.
|
||||
* {@code "ID" INT IDENTITY(1, 1) NOT NULL}. Quoted names and string defaults are skipped so a
|
||||
* column called {@code "IDENTITY"} or a default of {@code 'IDENTITY(1,1)'} is not misread.
|
||||
*/
|
||||
static Set<String> identityColumnsFromDdlText(String ddl) {
|
||||
Set<String> result = new java.util.HashSet<>();
|
||||
for (String definition : splitTableDdlDefinitions(ddl)) {
|
||||
String trimmed = definition.trim();
|
||||
if (trimmed.isEmpty() || trimmed.charAt(0) != '"') {
|
||||
// Table-level constraint (PRIMARY KEY(...), CHECK(...)), not a column definition.
|
||||
continue;
|
||||
}
|
||||
StringBuilder name = new StringBuilder();
|
||||
int index = 1;
|
||||
while (index < trimmed.length()) {
|
||||
char ch = trimmed.charAt(index);
|
||||
if (ch == '"') {
|
||||
if (index + 1 < trimmed.length() && trimmed.charAt(index + 1) == '"') {
|
||||
name.append('"');
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
name.append(ch);
|
||||
index++;
|
||||
}
|
||||
if (index >= trimmed.length() || name.length() == 0) {
|
||||
continue;
|
||||
}
|
||||
String rest = stripSingleQuotedLiterals(trimmed.substring(index + 1));
|
||||
if (DDL_IDENTITY_KEYWORD_PATTERN.matcher(rest).find()) {
|
||||
result.add(name.toString());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the column list of a {@code CREATE TABLE} statement on its top-level commas, so that
|
||||
* {@code IDENTITY(1, 1)} and {@code VARCHAR(64)} stay attached to their column.
|
||||
*/
|
||||
private static List<String> splitTableDdlDefinitions(String ddl) {
|
||||
List<String> definitions = new ArrayList<>();
|
||||
if (ddl == null) {
|
||||
return definitions;
|
||||
}
|
||||
StringBuilder current = new StringBuilder();
|
||||
int depth = 0;
|
||||
boolean inLiteral = false;
|
||||
boolean inQuotedName = false;
|
||||
for (int i = 0; i < ddl.length(); i++) {
|
||||
char ch = ddl.charAt(i);
|
||||
if (inLiteral) {
|
||||
current.append(ch);
|
||||
if (ch == '\'') {
|
||||
inLiteral = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inQuotedName) {
|
||||
current.append(ch);
|
||||
if (ch == '"') {
|
||||
inQuotedName = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '\'') {
|
||||
inLiteral = true;
|
||||
current.append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inQuotedName = true;
|
||||
current.append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == '(') {
|
||||
depth++;
|
||||
if (depth == 1) {
|
||||
// Opening paren of the column list; drop the CREATE TABLE prefix before it.
|
||||
current.setLength(0);
|
||||
} else {
|
||||
current.append(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == ')') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
addTableDdlDefinition(definitions, current);
|
||||
break;
|
||||
}
|
||||
current.append(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch == ',' && depth == 1) {
|
||||
addTableDdlDefinition(definitions, current);
|
||||
continue;
|
||||
}
|
||||
if (depth > 0) {
|
||||
current.append(ch);
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static void addTableDdlDefinition(List<String> definitions, StringBuilder current) {
|
||||
String definition = current.toString().trim();
|
||||
current.setLength(0);
|
||||
if (!definition.isEmpty()) {
|
||||
definitions.add(definition);
|
||||
}
|
||||
}
|
||||
|
||||
private static String stripSingleQuotedLiterals(String text) {
|
||||
StringBuilder out = new StringBuilder(text.length());
|
||||
boolean inLiteral = false;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char ch = text.charAt(i);
|
||||
if (inLiteral) {
|
||||
if (ch == '\'') {
|
||||
inLiteral = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '\'') {
|
||||
inLiteral = true;
|
||||
out.append(' ');
|
||||
continue;
|
||||
}
|
||||
out.append(ch);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private Set<String> primaryKeyColumnsFromJdbcMetadata(String schema, String table) {
|
||||
Set<String> result = new java.util.HashSet<>();
|
||||
try (ResultSet rs = requireConnected().getMetaData().getPrimaryKeys(null, schema, table)) {
|
||||
while (rs.next()) {
|
||||
String column = rs.getString("COLUMN_NAME");
|
||||
if (notBlank(column)) {
|
||||
result.add(column);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// The table is then reported without a primary key, as before this fallback existed.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1818,16 +2103,24 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
return unchecked(() -> {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same
|
||||
// way a primary key does, and neither can be altered with index-level DDL (#7959).
|
||||
// The constraint type therefore drives two flags — IS_PK (only 'P') and
|
||||
// CONSTRAINT_BACKED ('P' or 'U'), the same split `independentIndexes` already uses to
|
||||
// keep constraint-backed indexes out of the generated table DDL. A unique index
|
||||
// created with CREATE UNIQUE INDEX has no ALL_CONSTRAINTS row, so it stays false and
|
||||
// keeps the index-level DDL path.
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
|
||||
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
|
||||
i.UNIQUENESS,
|
||||
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
|
||||
i.INDEX_TYPE
|
||||
i.INDEX_TYPE,
|
||||
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
|
||||
FROM ALL_INDEXES i
|
||||
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
|
||||
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
|
||||
AND c.CONSTRAINT_TYPE = 'P'
|
||||
AND c.CONSTRAINT_TYPE IN ('P', 'U')
|
||||
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
|
||||
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
|
||||
ORDER BY i.INDEX_NAME
|
||||
@@ -1837,7 +2130,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new IndexInfo(
|
||||
IndexInfo index = new IndexInfo(
|
||||
rs.getString(1),
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"UNIQUE".equals(rs.getString(3)),
|
||||
@@ -1846,7 +2139,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
rs.getString(5),
|
||||
null,
|
||||
null
|
||||
));
|
||||
);
|
||||
index.setConstraint_backed("1".equals(rs.getString(6)));
|
||||
result.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+387
@@ -2,6 +2,7 @@ package com.dbx.agent.dameng;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
@@ -51,6 +52,48 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(metadataCalls.contains("getTables:DBX\\_TEST"), metadataCalls.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDatabasesPrefersFullSchemaCatalogOverPrivilegeFilteredAllUsers() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, databaseListConnection(sqls, null, null));
|
||||
|
||||
List<DatabaseInfo> databases = agent.listDatabases();
|
||||
|
||||
Assertions.assertEquals(List.of("APP", "E2E_NORMAL", "SYSDBA"), databases.stream().map(DatabaseInfo::getName).toList());
|
||||
Assertions.assertEquals(1, sqls.size(), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.get(0).contains("SYS.SYSOBJECTS"), sqls.get(0));
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_USERS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDatabasesFallsBackToAllUsersWithoutSysObjectsPrivilege() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, databaseListConnection(sqls, "no SYS.SYSOBJECTS privilege", null));
|
||||
|
||||
List<DatabaseInfo> databases = agent.listDatabases();
|
||||
|
||||
// 无 SOI 角色的普通用户:ALL_USERS 受自主访问控制过滤,只能看到自己,按权限返回属正确行为。
|
||||
Assertions.assertEquals(List.of("E2E_NORMAL"), databases.stream().map(DatabaseInfo::getName).toList());
|
||||
Assertions.assertEquals(2, sqls.size(), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.get(0).contains("SYS.SYSOBJECTS"), sqls.get(0));
|
||||
Assertions.assertTrue(sqls.get(1).contains("ALL_USERS"), sqls.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDatabasesPreservesCatalogErrorWhenAllUsersFails() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
SQLException usersError = new SQLException("ALL_USERS query failed");
|
||||
TestSupport.setPrivateConnection(agent, databaseListConnection(new ArrayList<>(), "no SYS.SYSOBJECTS privilege", usersError));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(RuntimeException.class, agent::listDatabases);
|
||||
|
||||
Assertions.assertEquals("no SYS.SYSOBJECTS privilege", error.getCause().getMessage());
|
||||
Assertions.assertEquals(1, error.getCause().getSuppressed().length);
|
||||
Assertions.assertSame(usersError, error.getCause().getSuppressed()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesColumnCommentsMetadataQuery() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -81,6 +124,53 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(indexesSql.startsWith("SELECT /*+ PARALLEL(1) */"), indexesSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void identifiesUniqueConstraintBackingIndexesInIndexMetadataQuery() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
|
||||
|
||||
agent.listIndexes("APP", "USERS");
|
||||
|
||||
String indexesSql = JdbcMetadataSqlFake.statements.stream()
|
||||
.filter(sql -> sql.contains("ALL_INDEXES") && sql.contains("ALL_CONSTRAINTS"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Assertions.assertTrue(indexesSql.contains("AND c.CONSTRAINT_TYPE IN ('P', 'U')"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("AS CONSTRAINT_BACKED"), indexesSql);
|
||||
Assertions.assertTrue(indexesSql.contains("CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK"), indexesSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksOnlyConstraintBackedIndexesAsConstraintBacked() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, indexMetadataConnection(List.of(
|
||||
// UNIQUE constraint (虚索引) / standalone CREATE UNIQUE INDEX (实索引) / plain index.
|
||||
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "0", "NORMAL", "1"),
|
||||
Arrays.asList("UX_USERS_CODE", "CODE", "UNIQUE", "0", "NORMAL", "0"),
|
||||
Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"),
|
||||
Arrays.asList("PK_USERS", "ID", "UNIQUE", "1", "NORMAL", "1")
|
||||
)));
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
|
||||
|
||||
Assertions.assertEquals(
|
||||
List.of("UX_USERS_EMAIL", "UX_USERS_CODE", "IDX_USERS_NAME", "PK_USERS"),
|
||||
indexes.stream().map(IndexInfo::getName).toList()
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
List.of(true, false, false, true),
|
||||
indexes.stream().map(IndexInfo::getConstraint_backed).toList()
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
List.of(false, false, false, true),
|
||||
indexes.stream().map(IndexInfo::getIs_primary).toList()
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
List.of(true, true, false, true),
|
||||
indexes.stream().map(IndexInfo::getIs_unique).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTableCommentsMetadataQuery() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -518,6 +608,96 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(source.getSource().contains("CREATE MATERIALIZED VIEW"), source.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToViewCatalogWhenDbmsMetadataReportsInternalIndexError() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String sql = (String) args[0];
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
throw new SQLException("未找到对象或不允许查询系统定义的内部索引");
|
||||
}
|
||||
if ("close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
}
|
||||
if (sql.contains("ALL_VIEWS")) {
|
||||
return metadataStatement(List.of(List.of("SELECT 1 AS ID FROM DUAL")));
|
||||
}
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "ACTIVE_VIEW", "VIEW");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("SELECT 1 AS ID FROM DUAL"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlFallsBackToViewAndMaterializedViewCatalogDefinitions() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String sql = (String) args[0];
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
return failingMetadataStatement(new SQLException("未找到对象或不允许查询系统定义的内部索引"));
|
||||
}
|
||||
if (sql.contains("USER_MVIEWS")) {
|
||||
String[] boundTable = {null};
|
||||
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("setString".equals(statementMethod.getName())) {
|
||||
boundTable[0] = (String) statementArgs[1];
|
||||
return null;
|
||||
}
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
return metadataResultSet("ISSUE_3418_MV".equals(boundTable[0])
|
||||
? List.of(List.of("SELECT 1 AS ID FROM DUAL"))
|
||||
: List.of());
|
||||
}
|
||||
if ("close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
}
|
||||
if (sql.contains("ALL_VIEWS")) {
|
||||
return metadataStatement(List.of(List.of("SELECT 2 AS ID FROM DUAL")));
|
||||
}
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
setConnectedUsername(agent, "APP");
|
||||
|
||||
String viewDdl = agent.getTableDdl("APP", "ACTIVE_VIEW");
|
||||
String materializedViewDdl = agent.getTableDdl("APP", "ISSUE_3418_MV");
|
||||
|
||||
Assertions.assertEquals(
|
||||
"CREATE VIEW \"APP\".\"ACTIVE_VIEW\" AS SELECT 2 AS ID FROM DUAL;",
|
||||
viewDdl
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
"CREATE MATERIALIZED VIEW \"APP\".\"ISSUE_3418_MV\" AS SELECT 1 AS ID FROM DUAL;",
|
||||
materializedViewDdl
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsViewSourceWithDbmsMetadataType() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -1028,6 +1208,148 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertEquals("identity", columns.get(0).getExtra());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsIdentityColumnsFromDdlText() {
|
||||
Assertions.assertEquals(
|
||||
java.util.Set.of("ID"),
|
||||
DamengAgent.identityColumnsFromDdlText(
|
||||
"CREATE TABLE \"DBX_TEST\".\"T\"\n(\n\"ID\" INT IDENTITY(1, 1) NOT NULL,\n"
|
||||
+ "\"NAME\" VARCHAR(64),\nNOT CLUSTER PRIMARY KEY(\"ID\")) STORAGE(ON \"MAIN\") ;"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresIdentityLookalikesInDdlText() {
|
||||
// A column *named* IDENTITY whose default merely contains the word must not be reported,
|
||||
// and neither must a table-level constraint that mentions it.
|
||||
Assertions.assertEquals(
|
||||
java.util.Set.of(),
|
||||
DamengAgent.identityColumnsFromDdlText(
|
||||
"CREATE TABLE \"DBX_TEST\".\"T\"\n(\n\"IDENTITY\" VARCHAR(32) DEFAULT 'IDENTITY(1,1)',\n"
|
||||
+ "\"PLAIN\" INT,\n\"IDENTITY_KIND\" VARCHAR(8)) STORAGE(ON \"MAIN\") ;"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsEveryIdentityColumnFromDdlText() {
|
||||
Assertions.assertEquals(
|
||||
java.util.Set.of("A", "C"),
|
||||
DamengAgent.identityColumnsFromDdlText(
|
||||
"CREATE TABLE \"S\".\"T\"\n(\n\"A\" BIGINT IDENTITY(1, 1),\n\"B\" VARCHAR(9),\n"
|
||||
+ "\"C\" INT identity(100, 5) NOT NULL,\nCLUSTER PRIMARY KEY(\"A\", \"C\")) ;"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toleratesDdlTextThatCannotBeParsed() {
|
||||
Assertions.assertEquals(java.util.Set.of(), DamengAgent.identityColumnsFromDdlText(null));
|
||||
Assertions.assertEquals(java.util.Set.of(), DamengAgent.identityColumnsFromDdlText(""));
|
||||
Assertions.assertEquals(java.util.Set.of(), DamengAgent.identityColumnsFromDdlText("CREATE TABLE \"S\".\"T\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTableDdlWhenSysColumnsIsNotReadable() {
|
||||
// Ordinary Dameng accounts have no SELECT on SYS.SYSCOLUMNS. Without the fallback the
|
||||
// identity column looks like a plain NOT NULL column and the grid sends its value.
|
||||
List<String> sqls = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, restrictedAccountConnection(sqls, List.of()));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
|
||||
|
||||
Assertions.assertEquals(1, columns.size());
|
||||
Assertions.assertEquals("identity", columns.get(0).getExtra());
|
||||
Assertions.assertTrue(
|
||||
sqls.stream().anyMatch(sql -> sql.contains("DBMS_METADATA.GET_DDL")),
|
||||
sqls.toString()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToJdbcPrimaryKeysWhenConstraintDictionaryIsEmpty() {
|
||||
// ALL_CONS_COLUMNS yields no rows (not an error) for those same accounts.
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, restrictedAccountConnection(new ArrayList<>(), List.of()));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
|
||||
|
||||
Assertions.assertEquals(1, columns.size());
|
||||
Assertions.assertTrue(columns.get(0).getIs_primary_key());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsPrimaryKeysFromConstraintDictionaryWhenReadable() {
|
||||
// The JDBC fallback must not displace the dictionary answer when that one works.
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, restrictedAccountConnection(new ArrayList<>(), List.of(List.of("ID"))));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(columns.get(0).getIs_primary_key());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotReadTableDdlWhenSysColumnsIsReadableButEmpty() {
|
||||
// A readable-but-empty SYS.SYSCOLUMNS means "no identity column" and must stay cheap.
|
||||
List<String> sqls = new ArrayList<>();
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnection(null, null, false, List.of(), sqls));
|
||||
|
||||
agent.getColumns("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(
|
||||
sqls.stream().noneMatch(sql -> sql.contains("DBMS_METADATA.GET_DDL")),
|
||||
sqls.toString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection shaped like a non-DBA Dameng account: SYS.SYSCOLUMNS raises a permission error
|
||||
* and the constraint dictionary answers with no rows, while the table DDL and the driver's own
|
||||
* primary-key metadata both still work.
|
||||
*/
|
||||
private static Connection restrictedAccountConnection(List<String> sqls, List<List<Object>> constraintRows) {
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getPrimaryKeys".equals(method.getName())) {
|
||||
return metadataResultSet(List.of(List.of("ID")));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getMetaData".equals(name)) {
|
||||
return metadata;
|
||||
}
|
||||
if ("prepareStatement".equals(name)) {
|
||||
String sql = (String) args[0];
|
||||
sqls.add(sql);
|
||||
if (sql.contains("SYS.SYSCOLUMNS")) {
|
||||
return failingMetadataStatement("没有[SYSCOLUMNS]对象的查询权限");
|
||||
}
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
return metadataStatement(List.of(List.of(
|
||||
"CREATE TABLE \"APP\".\"USERS\"\n(\n\"ID\" INT IDENTITY(1, 1) NOT NULL)"
|
||||
+ " STORAGE(ON \"MAIN\", CLUSTERBTR) ;"
|
||||
)));
|
||||
}
|
||||
if (sql.contains("ALL_CONS_COLUMNS")) {
|
||||
return metadataStatement(constraintRows);
|
||||
}
|
||||
if (sql.contains("ALL_TAB_COLUMNS")) {
|
||||
return metadataStatement(defaultColumnMetadataRows("id comment"));
|
||||
}
|
||||
return metadataStatement(List.of());
|
||||
}
|
||||
if ("close".equals(name) || "isClosed".equals(name)) {
|
||||
return "isClosed".equals(name) ? Boolean.FALSE : null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesCharacterLengthUnitsFromMetadata() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -1391,12 +1713,22 @@ class DamengAgentMetadataTest {
|
||||
return metadataStatement(rows);
|
||||
}
|
||||
if (sql.contains("USER_MVIEWS")) {
|
||||
if (sql.contains("SELECT QUERY")) {
|
||||
return metadataStatement(
|
||||
includeMaterializedView
|
||||
? List.of(List.of("SELECT 1 AS ID FROM DUAL"))
|
||||
: List.of()
|
||||
);
|
||||
}
|
||||
return metadataStatement(
|
||||
includeMaterializedView
|
||||
? List.of(List.of("USER_SUMMARY_MV"))
|
||||
: List.of()
|
||||
);
|
||||
}
|
||||
if (sql.contains("ALL_VIEWS")) {
|
||||
return metadataStatement(List.of());
|
||||
}
|
||||
if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) {
|
||||
return metadataStatement(List.of());
|
||||
}
|
||||
@@ -1428,6 +1760,24 @@ class DamengAgentMetadataTest {
|
||||
));
|
||||
}
|
||||
|
||||
// Serves every metadata query with the same positional rows; the index metadata query is
|
||||
// the only one these tests run against it.
|
||||
private static Connection indexMetadataConnection(List<List<Object>> rows) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("prepareStatement".equals(name)) {
|
||||
return metadataStatement(rows);
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType) {
|
||||
return List.of(name, columns, uniqueness, indexType);
|
||||
}
|
||||
@@ -1824,6 +2174,43 @@ class DamengAgentMetadataTest {
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection databaseListConnection(
|
||||
List<String> sqls,
|
||||
String catalogError,
|
||||
SQLException usersError
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("prepareStatement".equals(name)) {
|
||||
String sql = (String) args[0];
|
||||
sqls.add(sql);
|
||||
if (sql.contains("SYS.SYSOBJECTS")) {
|
||||
if (catalogError != null) {
|
||||
return failingMetadataStatement(catalogError);
|
||||
}
|
||||
return metadataStatement(List.of(List.of("APP"), List.of("E2E_NORMAL"), List.of("SYSDBA")));
|
||||
}
|
||||
if (sql.contains("ALL_USERS")) {
|
||||
if (usersError != null) {
|
||||
return failingMetadataStatement(usersError);
|
||||
}
|
||||
return metadataStatement(List.of(List.of("E2E_NORMAL")));
|
||||
}
|
||||
throw new AssertionError("Unexpected SQL: " + sql);
|
||||
}
|
||||
if ("getMetaData".equals(name)) {
|
||||
throw new AssertionError("listDatabases must not fall back to JDBC metadata getSchemas");
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection schemaConnection(List<String> params) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.dbx.agent.dameng;
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.QueryPageResult;
|
||||
@@ -18,10 +19,12 @@ import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -60,6 +63,24 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
assertEquals(List.of("executeQuery"), JdbcAgentFake.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsVarchar2AsTextWhenDriverReportsNumericSqlType() throws Exception {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
ResultSet resultSet = (ResultSet) Proxy.newProxyInstance(
|
||||
ResultSet.class.getClassLoader(),
|
||||
new Class<?>[] {ResultSet.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "getString" -> "001";
|
||||
case "wasNull" -> false;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
|
||||
JdbcExecutor.ColumnAwareResultValueReader reader = (JdbcExecutor.ColumnAwareResultValueReader) agent.resultValueReader();
|
||||
|
||||
assertEquals("001", reader.read(resultSet, 1, Types.NUMERIC, "VARCHAR2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void physicalConnectionsDoNotEnableDbmsOutput() throws Exception {
|
||||
List<String> executedSql = new ArrayList<>();
|
||||
|
||||
@@ -19,9 +19,11 @@ import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
public final class Db2Agent extends AbstractJdbcAgent {
|
||||
@@ -46,6 +48,13 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Properties buildConnectionProperties(ConnectParams params) {
|
||||
Properties properties = super.buildConnectionProperties(params);
|
||||
properties.setProperty("db2.jcc.charsetDecoderEncoder", "3");
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return unchecked(() -> {
|
||||
@@ -218,7 +227,11 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
@Override
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
return unchecked(() -> {
|
||||
String sql = "SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?";
|
||||
String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT);
|
||||
// View definitions live in SYSCAT.VIEWS; SYSCAT.ROUTINES only covers procedures and functions.
|
||||
String sql = "VIEW".equals(normalizedType)
|
||||
? "SELECT TEXT FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? AND VIEWNAME = ?"
|
||||
: "SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?";
|
||||
String source;
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
@@ -385,6 +398,14 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
@Override
|
||||
protected Object resultValue(ResultSet rs, int index, int sqlType) {
|
||||
return unchecked(() -> {
|
||||
if (sqlType == Types.CLOB || sqlType == Types.NCLOB) {
|
||||
String value = rs.getString(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
if (sqlType == Types.BLOB || sqlType == Types.BINARY || sqlType == Types.VARBINARY || sqlType == Types.LONGVARBINARY) {
|
||||
byte[] value = rs.getBytes(index);
|
||||
return rs.wasNull() ? null : JdbcExecutor.bytesToHex(value);
|
||||
}
|
||||
Object value = rs.getObject(index);
|
||||
return rs.wasNull() ? null : value == null ? null : value.toString();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import com.dbx.agent.test.JdbcMetadataSqlFake;
|
||||
@@ -17,6 +18,7 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -49,6 +51,47 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
assertEquals("jdbc:db2://db.example.com:50000/SAMPLE:sslConnection=true;", Db2Agent.buildUrl(params));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsClobValuesAsTextInsteadOfVendorObjectNames() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
ResultSet resultSet = proxy(ResultSet.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getString".equals(method.getName())) {
|
||||
return "long text value";
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("long text value", agent.resultValue(resultSet, 1, Types.CLOB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBlobValuesAsHexInsteadOfVendorObjectNames() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
ResultSet resultSet = proxy(ResultSet.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getBytes".equals(method.getName())) {
|
||||
return new byte[] {(byte) 0x89, 0x50, 0x4e};
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.BLOB));
|
||||
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.BINARY));
|
||||
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.VARBINARY));
|
||||
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.LONGVARBINARY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsAllCatalogSchemasWithoutOwnerTypeFiltering() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
@@ -126,6 +169,69 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
assertEquals("Test view comment", objects.get(0).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewObjectSourceReadsDefinitionFromSyscatViews() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
AtomicReference<String> executedSql = new AtomicReference<>();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(
|
||||
executedSql,
|
||||
row("TEXT", "CREATE VIEW APP.EMP_VIEW AS SELECT ID, NAME FROM APP.EMPLOYEE")
|
||||
));
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "EMP_VIEW", "VIEW");
|
||||
|
||||
assertEquals("CREATE VIEW APP.EMP_VIEW AS SELECT ID, NAME FROM APP.EMPLOYEE", source.getSource());
|
||||
assertEquals("SELECT TEXT FROM SYSCAT.VIEWS WHERE VIEWSCHEMA = ? AND VIEWNAME = ?", executedSql.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void routineObjectSourceStillReadsSyscatRoutines() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
AtomicReference<String> executedSql = new AtomicReference<>();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(
|
||||
executedSql,
|
||||
row("TEXT", "BEGIN SELECT 1 FROM SYSIBM.SYSDUMMY1; END")
|
||||
));
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "MY_PROC", "PROCEDURE");
|
||||
|
||||
assertEquals("BEGIN SELECT 1 FROM SYSIBM.SYSDUMMY1; END", source.getSource());
|
||||
assertEquals("SELECT TEXT FROM SYSCAT.ROUTINES WHERE ROUTINESCHEMA = ? AND ROUTINENAME = ?", executedSql.get());
|
||||
}
|
||||
|
||||
private static Connection preparedConnection(AtomicReference<String> executedSql, Map<String, Object>... rows) {
|
||||
final ResultSet resultSet = rows(rows);
|
||||
return proxy(Connection.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
String name = method.getName();
|
||||
if ("prepareStatement".equals(name)) {
|
||||
executedSql.set(String.valueOf(args[0]));
|
||||
return proxy(PreparedStatement.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method stmtMethod, Object[] stmtArgs) {
|
||||
String m = stmtMethod.getName();
|
||||
if ("setString".equals(m) || "setObject".equals(m) || "setInt".equals(m)) {
|
||||
return null;
|
||||
}
|
||||
if ("executeQuery".equals(m)) {
|
||||
return resultSet;
|
||||
}
|
||||
if ("close".equals(m)) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(stmtMethod.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection preparedConnection(Map<String, Object>... rows) {
|
||||
final ResultSet resultSet = rows(rows);
|
||||
return proxy(Connection.class, new MethodHandler() {
|
||||
|
||||
Generated
+89
-16
@@ -954,6 +954,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc16"
|
||||
version = "0.4.0"
|
||||
@@ -1154,6 +1169,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto 0.2.9",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
@@ -1242,14 +1258,16 @@ dependencies = [
|
||||
"calamine",
|
||||
"cbc 0.1.2",
|
||||
"chrono",
|
||||
"crc",
|
||||
"csv",
|
||||
"dbx-sqlite-worker",
|
||||
"deadpool-postgres",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"fs2",
|
||||
"futures",
|
||||
"httpdate",
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"json5",
|
||||
"jsonwebtoken",
|
||||
@@ -1275,10 +1293,12 @@ dependencies = [
|
||||
"rust_decimal",
|
||||
"rustls 0.23.42",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serde_yaml_ng",
|
||||
"sha1 0.10.7",
|
||||
"sha2 0.10.9",
|
||||
"sqlparser",
|
||||
"ssfmt",
|
||||
@@ -1551,19 +1571,43 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8 0.10.2",
|
||||
"signature 2.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"signature 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek 4.1.3",
|
||||
"ed25519 2.2.3",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1573,11 +1617,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
|
||||
dependencies = [
|
||||
"curve25519-dalek 5.0.0-pre.6",
|
||||
"ed25519",
|
||||
"ed25519 3.0.0-rc.4",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1602,7 +1646,7 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
"once_cell",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"rustcrypto-ff",
|
||||
"rustcrypto-group",
|
||||
@@ -1799,6 +1843,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -2321,7 +2375,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.57.0",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2526,7 +2580,7 @@ dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"crypto-bigint",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint-dig",
|
||||
@@ -2538,7 +2592,7 @@ dependencies = [
|
||||
"sec1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -3729,6 +3783,16 @@ dependencies = [
|
||||
"spki 0.8.0-rc.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0-rc.11"
|
||||
@@ -4399,10 +4463,10 @@ dependencies = [
|
||||
"crypto-primes",
|
||||
"digest 0.11.3",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -4448,7 +4512,7 @@ dependencies = [
|
||||
"der 0.8.1",
|
||||
"digest 0.10.7",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"elliptic-curve",
|
||||
"enum_dispatch",
|
||||
"flate2",
|
||||
@@ -4477,7 +4541,7 @@ dependencies = [
|
||||
"pbkdf2 0.13.0",
|
||||
"pkcs1",
|
||||
"pkcs5",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"polyval 0.7.3",
|
||||
"rand 0.10.2",
|
||||
"rand_core 0.10.1",
|
||||
@@ -4492,7 +4556,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.0",
|
||||
"sha3",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -5063,6 +5127,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
@@ -5415,7 +5488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
legacyAgentSessionID = "__legacy__"
|
||||
maxAgentSessions = 256
|
||||
rpcTimeoutSeconds = 30
|
||||
statusProbeSeconds = 10
|
||||
readAccessCacheTTL = 15 * time.Second
|
||||
)
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ func (s *etcdSession) status(params map[string]json.RawMessage) (any, error) {
|
||||
statusRequests[index] = channel
|
||||
go func(endpoint string, channel chan *statusResult) {
|
||||
started := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), statusProbeSeconds*time.Second)
|
||||
response, err := client.Maintenance.Status(ctx, endpoint)
|
||||
cancel()
|
||||
channel <- &statusResult{response: response, err: err, started: started}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.gbasedbt:jdbc:3.6.3.2'
|
||||
implementation 'com.gbasedbt:jdbc:3.6.5.21'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,7 @@ import com.dbx.agent.TableInfo;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
@@ -25,10 +26,31 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
private static final long METADATA_CACHE_TTL_MILLIS = 10_000L;
|
||||
|
||||
// Whitelist for a JDBC locale value (language_territory.codeset). The value is concatenated
|
||||
// into the connection URL, so reject anything outside ordinary identifier characters to guard
|
||||
// against a malformed or hostile server-reported collation.
|
||||
private static final Pattern SAFE_DATABASE_LOCALE = Pattern.compile("[A-Za-z0-9_.\\-]{1,64}");
|
||||
// Upper bound on the sysmaster locale-probe connection so a hung probe cannot stall connect.
|
||||
private static final int LOCALE_PROBE_LOGIN_TIMEOUT_SECS = 10;
|
||||
|
||||
/**
|
||||
* Leading directive comment emitted by the Rust admin-SQL layer for GBase 8s / Informix
|
||||
* "Create Database" when the user picks a character set, e.g.
|
||||
* {@code -- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE mydb;}. Informix cannot express a new
|
||||
* database's codeset in {@code CREATE DATABASE} — it inherits the creating session's DB_LOCALE —
|
||||
* so the chosen locale is carried out-of-band and honored here by opening a sysmaster session
|
||||
* pinned to that DB_LOCALE.
|
||||
*/
|
||||
private static final Pattern CREATE_DATABASE_LOCALE_DIRECTIVE = Pattern.compile(
|
||||
"^\\s*--\\s*DBX_DB_LOCALE\\s*=\\s*(\\S+)\\s*\\r?\\n(.*)$", Pattern.DOTALL);
|
||||
|
||||
public static final JdbcAgentProfile GBASE8S_PROFILE = new JdbcAgentProfile(
|
||||
"com.gbasedbt.jdbc.Driver",
|
||||
"jdbc:gbasedbt-sqli://{host}:{port}/{database}:GBASEDBTSERVER=gbase8s",
|
||||
@@ -47,6 +69,7 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
private long tableCacheTimeMillis;
|
||||
private List<TableInfo> tableCache = Collections.emptyList();
|
||||
private ConnectParams databaseListParams;
|
||||
private final Map<String, String> collateByDatabase = new ConcurrentHashMap<>();
|
||||
|
||||
public Gbase8sAgent() {
|
||||
super(GBASE8S_PROFILE);
|
||||
@@ -121,6 +144,113 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
return buildUrl(params);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Connection openConnection(ConnectParams params) throws Exception {
|
||||
return super.openConnection(withResolvedDatabaseLocale(params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of {@code params} whose {@code CLIENT_LOCALE}/{@code DB_LOCALE} match the real
|
||||
* collation of the target database, so one connection profile can open every database on the
|
||||
* instance regardless of locale. Falls back to the configured parameters whenever the collation
|
||||
* cannot be resolved (custom connection string, catalog unavailable, or lookup error).
|
||||
*/
|
||||
private ConnectParams withResolvedDatabaseLocale(ConnectParams params) {
|
||||
if (!params.getConnection_string().trim().isEmpty()) {
|
||||
return params;
|
||||
}
|
||||
String database = params.getDatabase().trim().isEmpty() ? "sysmaster" : params.getDatabase().trim();
|
||||
String collate = resolveCollate(params, database);
|
||||
if (collate.isEmpty() || collate.equalsIgnoreCase(currentLocaleOf(params))) {
|
||||
return params;
|
||||
}
|
||||
ConnectParams localized = new ConnectParams(
|
||||
params.getHost(),
|
||||
params.getPort(),
|
||||
params.getDatabase(),
|
||||
params.getUsername(),
|
||||
params.getPassword(),
|
||||
overrideLocaleParams(params.getUrl_params(), collate),
|
||||
params.getConnection_string(),
|
||||
params.isMysql_compat_mode(),
|
||||
params.getJdbc_driver_class(),
|
||||
params.getJdbc_driver_paths()
|
||||
);
|
||||
localized.setGbase_server(getGbaseServer(params));
|
||||
return localized;
|
||||
}
|
||||
|
||||
private String resolveCollate(ConnectParams params, String database) {
|
||||
String key = params.getHost() + "|" + params.getPort() + "|" + getGbaseServer(params)
|
||||
+ "|" + database.toLowerCase(Locale.ROOT);
|
||||
String cached = collateByDatabase.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
String collate = "";
|
||||
try (Connection connection = super.openConnection(withProbeLoginTimeout(paramsForDatabase(params, "sysmaster")));
|
||||
PreparedStatement stmt = connection.prepareStatement(
|
||||
"SELECT dbs_collate FROM sysmaster:sysdbslocale WHERE LOWER(dbs_dbsname) = ?")) {
|
||||
stmt.setString(1, database.toLowerCase(Locale.ROOT));
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
collate = trim(rs.getString(1));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Keep the configured locale when the collation catalog is unreachable.
|
||||
}
|
||||
// Only cache a successful lookup. Caching an empty result would pin a transient failure —
|
||||
// or a database that was just created and is not yet listed in sysdbslocale — to the stale
|
||||
// fallback until disconnect, so leave failures uncached to retry on the next connect.
|
||||
if (!collate.isEmpty()) {
|
||||
collateByDatabase.put(key, collate);
|
||||
}
|
||||
return collate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of {@code params} with a bounded {@code LOGIN_TIMEOUT} appended to its JDBC
|
||||
* parameters (unless one is already configured), so the sysmaster locale probe can never hang
|
||||
* the connect path.
|
||||
*/
|
||||
private static ConnectParams withProbeLoginTimeout(ConnectParams params) {
|
||||
String urlParams = params.getUrl_params() == null ? "" : params.getUrl_params();
|
||||
if (containsIgnoreCase(urlParams, "LOGIN_TIMEOUT=")) {
|
||||
return params;
|
||||
}
|
||||
String joined = urlParams.isEmpty()
|
||||
? "LOGIN_TIMEOUT=" + LOCALE_PROBE_LOGIN_TIMEOUT_SECS
|
||||
: urlParams + ";LOGIN_TIMEOUT=" + LOCALE_PROBE_LOGIN_TIMEOUT_SECS;
|
||||
ConnectParams copy = new ConnectParams(
|
||||
params.getHost(),
|
||||
params.getPort(),
|
||||
params.getDatabase(),
|
||||
params.getUsername(),
|
||||
params.getPassword(),
|
||||
joined,
|
||||
params.getConnection_string(),
|
||||
params.isMysql_compat_mode(),
|
||||
params.getJdbc_driver_class(),
|
||||
params.getJdbc_driver_paths()
|
||||
);
|
||||
copy.setGbase_server(params.getGbase_server());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static String currentLocaleOf(ConnectParams params) {
|
||||
for (String segment : params.getUrl_params().split(";")) {
|
||||
int equals = segment.indexOf('=');
|
||||
if (equals < 0) {
|
||||
continue;
|
||||
}
|
||||
if (segment.substring(0, equals).trim().equalsIgnoreCase("DB_LOCALE")) {
|
||||
return segment.substring(equals + 1).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
@@ -131,11 +261,18 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
@Override
|
||||
protected void afterDisconnect() {
|
||||
databaseListParams = null;
|
||||
collateByDatabase.clear();
|
||||
clearMetadataCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
CreateDatabaseLocaleDirective directive = parseCreateDatabaseLocaleDirective(sql);
|
||||
if (directive != null) {
|
||||
runCreateDatabaseWithLocale(directive.statement(), directive.locale());
|
||||
clearMetadataCache();
|
||||
return new QueryResult(Collections.emptyList(), Collections.emptyList(), 0, 0);
|
||||
}
|
||||
QueryResult result = super.executeQuery(sql, schema, options);
|
||||
if (mayChangeMetadata(sql)) {
|
||||
clearMetadataCache();
|
||||
@@ -143,6 +280,71 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code CREATE DATABASE} statement plus the DB_LOCALE carried by its leading directive
|
||||
* comment. Package visible so the routing decision is directly testable without a live
|
||||
* connection.
|
||||
*/
|
||||
record CreateDatabaseLocaleDirective(String locale, String statement) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@code sql} as a directive-prefixed {@code CREATE DATABASE} (as emitted by the Rust
|
||||
* admin-SQL layer), returning {@code null} for anything else so it falls through to the
|
||||
* normal query path.
|
||||
*/
|
||||
static CreateDatabaseLocaleDirective parseCreateDatabaseLocaleDirective(String sql) {
|
||||
Matcher directive = sql == null ? null : CREATE_DATABASE_LOCALE_DIRECTIVE.matcher(sql);
|
||||
if (directive == null || !directive.matches()) {
|
||||
return null;
|
||||
}
|
||||
String statement = directive.group(2).trim();
|
||||
if (!statement.regionMatches(true, 0, "CREATE DATABASE", 0, "CREATE DATABASE".length())) {
|
||||
return null;
|
||||
}
|
||||
return new CreateDatabaseLocaleDirective(directive.group(1).trim(), statement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a {@code CREATE DATABASE} statement on a sysmaster session whose {@code DB_LOCALE} is
|
||||
* pinned to {@code locale}, so the freshly created database inherits that codeset (Informix has
|
||||
* no charset clause in {@code CREATE DATABASE}). Falls back to the configured locale when
|
||||
* {@code locale} is blank or unsafe.
|
||||
*/
|
||||
private void runCreateDatabaseWithLocale(String statement, String locale) {
|
||||
ConnectParams base = databaseListParams;
|
||||
if (base == null) {
|
||||
throw new IllegalStateException("Not connected");
|
||||
}
|
||||
ConnectParams pinned = base;
|
||||
if (locale != null && SAFE_DATABASE_LOCALE.matcher(locale).matches()) {
|
||||
pinned = new ConnectParams(
|
||||
base.getHost(),
|
||||
base.getPort(),
|
||||
base.getDatabase(),
|
||||
base.getUsername(),
|
||||
base.getPassword(),
|
||||
overrideLocaleParams(base.getUrl_params(), locale),
|
||||
base.getConnection_string(),
|
||||
base.isMysql_compat_mode(),
|
||||
base.getJdbc_driver_class(),
|
||||
base.getJdbc_driver_paths()
|
||||
);
|
||||
pinned.setGbase_server(base.getGbase_server());
|
||||
}
|
||||
try (Connection connection = super.openConnection(pinned);
|
||||
Statement stmt = connection.createStatement()) {
|
||||
stmt.execute(stripTrailingSemicolon(statement));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String stripTrailingSemicolon(String sql) {
|
||||
String trimmed = sql.trim();
|
||||
return trimmed.endsWith(";") ? trimmed.substring(0, trimmed.length() - 1).trim() : trimmed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
List<DatabaseInfo> cached = cachedDatabases();
|
||||
@@ -459,6 +661,62 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
return value.toLowerCase(Locale.ROOT).contains(needle.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Force {@code CLIENT_LOCALE} and {@code DB_LOCALE} in a {@code ;}-separated JDBC parameter
|
||||
* string to {@code collate}, leaving every other parameter (and their order) untouched.
|
||||
*
|
||||
* <p>A GBase 8s / Informix instance can host databases with different locales, and the JDBC
|
||||
* driver rejects a connection whose {@code DB_LOCALE} does not match the target database's real
|
||||
* locale with "database locale information mismatch" (数据库地点信息不匹配). A single connection
|
||||
* profile only pins one locale, so reusing it to open a differently-locale database fails. The
|
||||
* agent therefore resolves each database's real collation from {@code sysmaster.sysdbslocale}
|
||||
* and rewrites both locale parameters to it, so every database under the connection opens with
|
||||
* its own locale. Setting {@code CLIENT_LOCALE} to the same value keeps client and database
|
||||
* codesets identical, which avoids the cross-codeset conversion the bundled client cannot load.
|
||||
*/
|
||||
static String overrideLocaleParams(String jdbcParams, String collate) {
|
||||
if (collate == null || collate.trim().isEmpty()) {
|
||||
return jdbcParams;
|
||||
}
|
||||
String value = collate.trim();
|
||||
// The value is concatenated into the JDBC URL; only accept an ordinary locale token so a
|
||||
// malformed or hostile server-reported collation cannot inject extra parameters.
|
||||
if (!SAFE_DATABASE_LOCALE.matcher(value).matches()) {
|
||||
return jdbcParams;
|
||||
}
|
||||
List<String> segments = new ArrayList<>();
|
||||
if (jdbcParams != null && !jdbcParams.isEmpty()) {
|
||||
for (String segment : jdbcParams.split(";")) {
|
||||
int equals = segment.indexOf('=');
|
||||
String key = (equals >= 0 ? segment.substring(0, equals) : segment).trim();
|
||||
if (key.equalsIgnoreCase("CLIENT_LOCALE")) {
|
||||
segments.add("CLIENT_LOCALE=" + value);
|
||||
} else if (key.equalsIgnoreCase("DB_LOCALE")) {
|
||||
segments.add("DB_LOCALE=" + value);
|
||||
} else {
|
||||
segments.add(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean hasClient = false;
|
||||
boolean hasDb = false;
|
||||
for (String segment : segments) {
|
||||
String key = segment.substring(0, Math.max(segment.indexOf('='), 0)).trim();
|
||||
if (key.equalsIgnoreCase("CLIENT_LOCALE")) {
|
||||
hasClient = true;
|
||||
} else if (key.equalsIgnoreCase("DB_LOCALE")) {
|
||||
hasDb = true;
|
||||
}
|
||||
}
|
||||
if (!hasClient) {
|
||||
segments.add("CLIENT_LOCALE=" + value);
|
||||
}
|
||||
if (!hasDb) {
|
||||
segments.add("DB_LOCALE=" + value);
|
||||
}
|
||||
return String.join(";", segments);
|
||||
}
|
||||
|
||||
private List<String> queryDatabaseNamesFromSysmaster() {
|
||||
ConnectParams params = databaseListParams;
|
||||
if (params == null) {
|
||||
@@ -620,6 +878,12 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent {
|
||||
case 41 -> "BOOLEAN";
|
||||
case 43, 52 -> "BIGINT";
|
||||
case 44, 53 -> "BIGSERIAL";
|
||||
// MySQL-compat extended types (SQLMODE=mysql); codes match the
|
||||
// 3.6.5+ driver's IfxBigIntUnsigned/IfxTinyInt/IfxMediumInt/IfxBit.
|
||||
case 66 -> "BIGINT UNSIGNED";
|
||||
case 67 -> "TINYINT";
|
||||
case 68 -> "MEDIUMINT";
|
||||
case 69 -> "BIT";
|
||||
default -> "UNKNOWN(" + baseColType(coltype) + ")";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,18 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
class Gbase8sAgentTest {
|
||||
@Test
|
||||
void mapsMysqlCompatExtendedColumnTypes() {
|
||||
Assertions.assertEquals("BIGINT UNSIGNED", Gbase8sAgent.mapColType(66));
|
||||
Assertions.assertEquals("TINYINT", Gbase8sAgent.mapColType(67));
|
||||
Assertions.assertEquals("MEDIUMINT", Gbase8sAgent.mapColType(68));
|
||||
Assertions.assertEquals("BIT", Gbase8sAgent.mapColType(69));
|
||||
// Nullable flag bits above the base type must not change the mapping.
|
||||
Assertions.assertEquals("BIGINT UNSIGNED", Gbase8sAgent.mapColType(66 + 256));
|
||||
Assertions.assertEquals("BIGINT", Gbase8sAgent.mapColType(43));
|
||||
Assertions.assertEquals("UNKNOWN(99)", Gbase8sAgent.mapColType(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaresGbase8sProfile() {
|
||||
Gbase8sAgent agent = new Gbase8sAgent();
|
||||
@@ -155,6 +167,96 @@ class Gbase8sAgentTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideLocaleParamsRewritesBothLocalesPreservingOthers() {
|
||||
Assertions.assertEquals(
|
||||
"GBASEDBTSERVER=gbase01;CLIENT_LOCALE=en_US.819;DB_LOCALE=en_US.819;NEWCODESET=UTF8,utf8,57372",
|
||||
Gbase8sAgent.overrideLocaleParams(
|
||||
"GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_CN.utf8;DB_LOCALE=zh_CN.utf8;NEWCODESET=UTF8,utf8,57372",
|
||||
"en_US.819"
|
||||
)
|
||||
);
|
||||
// Appends both when neither is present.
|
||||
Assertions.assertEquals(
|
||||
"GBASEDBTSERVER=gbase01;CLIENT_LOCALE=zh_CN.57372;DB_LOCALE=zh_CN.57372",
|
||||
Gbase8sAgent.overrideLocaleParams("GBASEDBTSERVER=gbase01", "zh_CN.57372")
|
||||
);
|
||||
// Blank collate is a no-op.
|
||||
Assertions.assertEquals(
|
||||
"DB_LOCALE=zh_CN.utf8",
|
||||
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", " ")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideLocaleParamsRejectsUnsafeCollate() {
|
||||
// A server-reported collation is concatenated into the JDBC URL, so anything outside the
|
||||
// ordinary-locale whitelist must be ignored rather than injected.
|
||||
Assertions.assertEquals(
|
||||
"DB_LOCALE=zh_CN.utf8",
|
||||
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", "en_US.819;NEWCODESET=x")
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
"DB_LOCALE=zh_CN.utf8",
|
||||
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", "bad locale")
|
||||
);
|
||||
Assertions.assertEquals(
|
||||
"DB_LOCALE=zh_CN.utf8",
|
||||
Gbase8sAgent.overrideLocaleParams("DB_LOCALE=zh_CN.utf8", "a".repeat(200))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewritesLocaleToTargetDatabaseCollateSoCrossLocaleDatabaseOpens() {
|
||||
// The reported connection pins DB_LOCALE=zh_CN.utf8 for `dcss`; opening the differently
|
||||
// locale `gbase8s` database (real collate en_US.819) must rewrite the locale to en_US.819.
|
||||
ConnectParams params = new ConnectParams(
|
||||
"192.168.5.65",
|
||||
9088,
|
||||
"dcss",
|
||||
"gbasedbt",
|
||||
"secret",
|
||||
"GBASEDBTSERVER=gbaseserver;DB_LOCALE=zh_CN.utf8;CLIENT_LOCALE=zh_CN.utf8;NEWCODESET=UTF8,utf8,57372;DELIMIDENT=y",
|
||||
"",
|
||||
false
|
||||
);
|
||||
|
||||
String url = Gbase8sAgent.buildUrl(
|
||||
new ConnectParams(
|
||||
params.getHost(),
|
||||
params.getPort(),
|
||||
"gbase8s",
|
||||
params.getUsername(),
|
||||
params.getPassword(),
|
||||
Gbase8sAgent.overrideLocaleParams(params.getUrl_params(), "en_US.819"),
|
||||
params.getConnection_string(),
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
Assertions.assertEquals(
|
||||
"jdbc:gbasedbt-sqli://192.168.5.65:9088/gbase8s:GBASEDBTSERVER=gbaseserver;DB_LOCALE=en_US.819;CLIENT_LOCALE=en_US.819;NEWCODESET=UTF8,utf8,57372;DELIMIDENT=y",
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDatabaseLocaleDirectiveIsRoutedToLocaleSession() {
|
||||
// The directive branch must fire only for a leading DBX_DB_LOCALE directive on a CREATE
|
||||
// DATABASE statement; anything else returns null and falls through to the normal query
|
||||
// path. Parsing is tested directly because both paths throw identically when unconnected.
|
||||
Gbase8sAgent.CreateDatabaseLocaleDirective directive = Gbase8sAgent.parseCreateDatabaseLocaleDirective(
|
||||
"-- DBX_DB_LOCALE=zh_CN.utf8\nCREATE DATABASE app_db;"
|
||||
);
|
||||
Assertions.assertNotNull(directive);
|
||||
Assertions.assertEquals("zh_CN.utf8", directive.locale());
|
||||
Assertions.assertEquals("CREATE DATABASE app_db;", directive.statement());
|
||||
Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective("CREATE DATABASE app_db;"));
|
||||
Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective(
|
||||
"-- DBX_DB_LOCALE=zh_CN.utf8\nDROP DATABASE app_db;"));
|
||||
Assertions.assertNull(Gbase8sAgent.parseCreateDatabaseLocaleDirective(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsOwnerSchemasWhenTheDatabaseCannotUseThemInDml() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
|
||||
@@ -36,5 +36,6 @@ tasks.named('test') {
|
||||
dependsOn tasks.named('shadowJar')
|
||||
doFirst {
|
||||
systemProperty 'dbx.h2.agent.jar', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath
|
||||
systemProperty 'dbx.h2.v1.driver.jar', configurations.h2V1.asPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,20 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class H2Agent extends AbstractJdbcAgent {
|
||||
private static final String QUOTED_IDENTIFIER = "\"(?:\"\"|[^\"])*\"";
|
||||
private static final String QUALIFIED_IDENTIFIER = QUOTED_IDENTIFIER + "\\." + QUOTED_IDENTIFIER;
|
||||
private static final Pattern CREATE_SEQUENCE = Pattern.compile("^CREATE SEQUENCE (" + QUALIFIED_IDENTIFIER + ")\\s");
|
||||
private static final Pattern SEQUENCE_REFERENCE = Pattern.compile(
|
||||
"'(?:''|[^'])*'|" + QUOTED_IDENTIFIER + "|--[^\\r\\n]*|/\\*.*?\\*/|\\b(?:NEXT|CURRENT)\\s+VALUE\\s+FOR\\s+(" + QUALIFIED_IDENTIFIER + ")",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
private static final Pattern CREATE_INDEX = Pattern.compile(
|
||||
"^CREATE (?:UNIQUE(?: NULLS (?:DISTINCT|NOT DISTINCT|ALL DISTINCT))? )?(?:HASH |SPATIAL )?INDEX .*",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
private String databaseName = "";
|
||||
private H2DriverLoader.LoadedDriver loadedDriver;
|
||||
private H2DriverVersion driverVersion = H2DriverVersion.V3;
|
||||
@@ -92,7 +104,29 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
databaseName = params.getDatabase();
|
||||
databaseMajorVersion = unchecked(() -> connection.getMetaData().getDatabaseMajorVersion());
|
||||
databaseMajorVersion = detectDatabaseMajorVersion(connection);
|
||||
}
|
||||
|
||||
// For a remote (TCP/SSL) connection, JDBC metadata such as
|
||||
// getDatabaseMajorVersion() reflects the *loaded driver's* own version, not
|
||||
// the server's, whenever the driver profile could not be auto-detected from
|
||||
// a local database file (see H2FileFormatDetector) and fell back to the
|
||||
// newest bundled driver. H2VERSION() is evaluated by the server itself, so
|
||||
// it reports the real engine version regardless of which driver connected.
|
||||
private static int detectDatabaseMajorVersion(Connection connection) {
|
||||
try (java.sql.Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery("SELECT H2VERSION()")) {
|
||||
if (resultSet.next()) {
|
||||
String version = resultSet.getString(1);
|
||||
int dot = version == null ? -1 : version.indexOf('.');
|
||||
if (dot > 0) {
|
||||
return Integer.parseInt(version.substring(0, dot));
|
||||
}
|
||||
}
|
||||
} catch (SQLException | NumberFormatException ignored) {
|
||||
// Fall back to JDBC driver metadata below.
|
||||
}
|
||||
return unchecked(() -> connection.getMetaData().getDatabaseMajorVersion());
|
||||
}
|
||||
|
||||
H2DriverVersion driverVersion() {
|
||||
@@ -300,6 +334,55 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
String tableRef = JdbcIdentifiers.INSTANCE.doubleQuote(resolveSchema(schema)) + "."
|
||||
+ JdbcIdentifiers.INSTANCE.doubleQuote(table);
|
||||
List<String> statements = new ArrayList<>();
|
||||
List<String> sequences = new ArrayList<>();
|
||||
// Let H2 preserve identity/computed columns, constraints and index ordering.
|
||||
// Exclude database-wide settings, users, schemas, grants and triggers.
|
||||
try (var stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SCRIPT NODATA NOPASSWORDS NOSETTINGS TABLE " + tableRef)) {
|
||||
while (rs.next()) {
|
||||
String sql = rs.getString(1).trim();
|
||||
if (sql.startsWith("CREATE SEQUENCE ")) {
|
||||
sequences.add(sql);
|
||||
} else if (sql.startsWith("CREATE MEMORY TABLE ") || sql.startsWith("CREATE CACHED TABLE ")
|
||||
|| sql.startsWith("CREATE TABLE ") || sql.startsWith("ALTER TABLE ")
|
||||
|| CREATE_INDEX.matcher(sql).matches()) {
|
||||
statements.add(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (statements.isEmpty()) {
|
||||
throw new IllegalStateException("H2 returned no table DDL for " + tableRef);
|
||||
}
|
||||
// SCRIPT TABLE can include every sequence in the schema. Keep only real
|
||||
// references, skipping quoted strings, identifiers and comments as tokens.
|
||||
Set<String> referenced = new HashSet<>();
|
||||
for (String statement : statements) {
|
||||
var references = SEQUENCE_REFERENCE.matcher(statement);
|
||||
while (references.find()) {
|
||||
if (references.group(1) != null) {
|
||||
referenced.add(references.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> ddl = new ArrayList<>();
|
||||
for (String sequence : sequences) {
|
||||
var declaration = CREATE_SEQUENCE.matcher(sequence);
|
||||
if (declaration.find() && referenced.contains(declaration.group(1))) {
|
||||
// Several selected tables may share one explicit sequence.
|
||||
ddl.add("CREATE SEQUENCE IF NOT EXISTS " + sequence.substring("CREATE SEQUENCE ".length()));
|
||||
}
|
||||
}
|
||||
ddl.addAll(statements);
|
||||
return String.join("\n", ddl);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return unchecked(() -> {
|
||||
@@ -327,7 +410,10 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
String typeColumn = isVersion2OrLater() ? "DATA_TYPE" : "TYPE_NAME";
|
||||
String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
|
||||
String extraColumns = isVersion2OrLater()
|
||||
? "IS_GENERATED, IS_IDENTITY, IDENTITY_GENERATION, "
|
||||
: "IS_COMPUTED, SEQUENCE_NAME, ";
|
||||
String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + extraColumns
|
||||
+ "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)) {
|
||||
@@ -336,14 +422,26 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String columnName = rs.getString("COLUMN_NAME");
|
||||
String extra = null;
|
||||
if (isVersion2OrLater()) {
|
||||
if ("ALWAYS".equals(rs.getString("IS_GENERATED"))) {
|
||||
extra = "computed";
|
||||
} else if ("YES".equals(rs.getString("IS_IDENTITY"))) {
|
||||
extra = "generated " + rs.getString("IDENTITY_GENERATION").toLowerCase(Locale.ROOT) + " as identity";
|
||||
}
|
||||
} else if (rs.getBoolean("IS_COMPUTED")) {
|
||||
extra = "computed";
|
||||
} else if (rs.getString("SEQUENCE_NAME") != null) {
|
||||
extra = "identity";
|
||||
}
|
||||
result.add(new ColumnInfo(
|
||||
columnName,
|
||||
rs.getString("DBX_DATA_TYPE"),
|
||||
"YES".equals(rs.getString("IS_NULLABLE")),
|
||||
rs.getString("COLUMN_DEFAULT"),
|
||||
primaryKeys.contains(columnName),
|
||||
extra,
|
||||
rs.getString("REMARKS"),
|
||||
null,
|
||||
intOrNull(rs, "NUMERIC_PRECISION"),
|
||||
intOrNull(rs, "NUMERIC_SCALE"),
|
||||
intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH")
|
||||
@@ -565,10 +663,7 @@ public class H2Agent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private static String resolveSchema(String schema) {
|
||||
if ("PUBLIC".equalsIgnoreCase(schema) || "INFORMATION_SCHEMA".equalsIgnoreCase(schema)) {
|
||||
return schema.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
return "PUBLIC";
|
||||
return schema == null || schema.isBlank() ? "PUBLIC" : schema;
|
||||
}
|
||||
|
||||
private static Integer intOrNull(ResultSet rs, String column) throws Exception {
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -99,6 +100,123 @@ class H2AgentProcessTest {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for a legacy (H2 1.x) *remote* server: the file-format
|
||||
// auto-detector (H2FileFormatDetector) cannot inspect a TCP database's
|
||||
// storage file, so driver selection falls back to the newest bundled
|
||||
// driver (H2 2.4.240). That mismatched driver's own JDBC metadata
|
||||
// (getDatabaseMajorVersion()) then reports its own version rather than the
|
||||
// server's, which used to make the agent send an H2 2.x-only
|
||||
// INFORMATION_SCHEMA.ROUTINES query to a database that doesn't have that
|
||||
// catalog table, failing list_objects entirely (dbx#8356).
|
||||
@Test
|
||||
@Timeout(45)
|
||||
void listObjectsAutoDetectsLegacyServerOverTcp() throws Exception {
|
||||
Path h2V1Jar = Path.of(System.getProperty("dbx.h2.v1.driver.jar"));
|
||||
Assertions.assertTrue(Files.isRegularFile(h2V1Jar), () -> "Missing bundled H2 1.4.200 driver jar: " + h2V1Jar);
|
||||
Path agentJar = Path.of(System.getProperty("dbx.h2.agent.jar"));
|
||||
Assertions.assertTrue(Files.isRegularFile(agentJar), () -> "Missing shaded H2 Agent: " + agentJar);
|
||||
|
||||
try (LegacyTcpServer server = new LegacyTcpServer(h2V1Jar)) {
|
||||
try (RpcProcess rpc = new RpcProcess(agentJar)) {
|
||||
String sessionId = "session-legacy-tcp";
|
||||
JsonObject params = sessionParams(sessionId);
|
||||
params.addProperty("host", "127.0.0.1");
|
||||
params.addProperty("port", server.port());
|
||||
// A fresh, unique database per run: the TCP server process's
|
||||
// working directory (and any .mv.db file it writes) persists
|
||||
// across separate test invocations, so a fixed name would leak
|
||||
// schema objects between runs.
|
||||
params.addProperty("database", "./legacy-tcp-test-" + java.util.UUID.randomUUID());
|
||||
params.addProperty("username", "sa");
|
||||
params.addProperty("password", "");
|
||||
params.addProperty("connection_string", "");
|
||||
params.addProperty("ssl", false);
|
||||
// driver_profile intentionally omitted: exercise the "auto" path.
|
||||
|
||||
JsonObject connected = rpc.result(rpc.request("open_session", params));
|
||||
Assertions.assertTrue(connected.get("ok").getAsBoolean());
|
||||
|
||||
rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE TABLE LEGACY_TABLE (ID INT PRIMARY KEY)")));
|
||||
rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE ALIAS LEGACY_FUNC FOR \"java.lang.Integer.reverse\"")));
|
||||
|
||||
JsonArray objects = rpc.resultArray(rpc.request("list_objects", metadataParams(sessionId, "PUBLIC")));
|
||||
List<String> names = objects.asList().stream().map(value -> value.getAsJsonObject().get("name").getAsString()).toList();
|
||||
Assertions.assertTrue(names.contains("LEGACY_TABLE"), names.toString());
|
||||
Assertions.assertTrue(names.contains("LEGACY_FUNC"), names.toString());
|
||||
|
||||
Assertions.assertTrue(rpc.result(rpc.request("close_session", sessionParams(sessionId))).get("ok").getAsBoolean());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LegacyTcpServer implements AutoCloseable {
|
||||
private final Process process;
|
||||
private final int port;
|
||||
|
||||
private LegacyTcpServer(Path h2V1Jar) throws Exception {
|
||||
this.port = findFreePort();
|
||||
Path java = Path.of(System.getProperty("java.home"), "bin", "java");
|
||||
process = new ProcessBuilder(
|
||||
java.toString(), "-cp", h2V1Jar.toString(), "org.h2.tools.Server",
|
||||
"-tcp", "-tcpPort", Integer.toString(port), "-ifNotExists"
|
||||
).redirectErrorStream(true).start();
|
||||
// -tcpDaemon is intentionally omitted: it marks the *listener* thread
|
||||
// as a daemon for embedding inside a host JVM that outlives it. Here
|
||||
// the server *is* the whole process, so a daemon-only listener thread
|
||||
// would let the JVM exit (dropping sessions) the moment main() returns.
|
||||
drainOutput();
|
||||
waitUntilListening();
|
||||
}
|
||||
|
||||
private void drainOutput() {
|
||||
Thread thread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
while (reader.readLine() != null) {
|
||||
// Discard: only draining to prevent the pipe buffer from filling.
|
||||
}
|
||||
} catch (java.io.IOException ignored) {
|
||||
}
|
||||
}, "legacy-h2-server-stdout");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private int port() {
|
||||
return port;
|
||||
}
|
||||
|
||||
private static int findFreePort() throws Exception {
|
||||
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private void waitUntilListening() throws Exception {
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos();
|
||||
while (System.nanoTime() < deadline) {
|
||||
try (java.net.Socket probe = new java.net.Socket()) {
|
||||
probe.connect(new java.net.InetSocketAddress("127.0.0.1", port), 200);
|
||||
return;
|
||||
} catch (java.io.IOException notReady) {
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Legacy H2 TCP server never started listening on port " + port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
process.destroy();
|
||||
try {
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject connectParams(String sessionId, String profile) {
|
||||
JsonObject params = sessionParams(sessionId);
|
||||
params.addProperty("host", "");
|
||||
|
||||
@@ -355,6 +355,128 @@ class H2AgentMigrationTest {
|
||||
}
|
||||
|
||||
class H2VersionMatrixTest {
|
||||
@Test
|
||||
void tableDdlPreservesGeneratedColumnsConstraintsAndComments() {
|
||||
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
|
||||
ConnectParams params = new ConnectParams("", 0, "mem:ddl-" + profile, "sa", "", "", "", false);
|
||||
params.setDriver_profile(profile);
|
||||
H2Agent agent = new H2Agent();
|
||||
agent.connect(params);
|
||||
try {
|
||||
ExecuteQueryOptions options = new ExecuteQueryOptions();
|
||||
agent.executeQuery("CREATE SCHEMA \"Source\"; CREATE SCHEMA \"Target\"", null, options);
|
||||
agent.executeQuery("CREATE SEQUENCE \"Source\".UNUSED; CREATE SEQUENCE \"Source\".SHARED START WITH 10", null, options);
|
||||
agent.executeQuery("CREATE TABLE \"Source\".PARENT (ID INT PRIMARY KEY, TOKEN BIGINT DEFAULT NEXT VALUE FOR \"Source\".SHARED)", null, options);
|
||||
agent.executeQuery("CREATE TABLE \"Source\".CHILD (ID INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "
|
||||
+ "PARENT_ID INT, QTY INT DEFAULT 1 CHECK (QTY > 0), TOTAL INT AS (QTY * 2), RAW_BYTES BINARY(8), "
|
||||
+ "TOKEN BIGINT DEFAULT NEXT VALUE FOR \"Source\".SHARED, NOTE VARCHAR(128) DEFAULT 'NEXT VALUE FOR \"Source\".\"UNUSED\"', "
|
||||
+ "CONSTRAINT FK_PARENT FOREIGN KEY (PARENT_ID) REFERENCES \"Source\".PARENT(ID) ON DELETE CASCADE)", null, options);
|
||||
agent.executeQuery("COMMENT ON COLUMN \"Source\".CHILD.QTY IS 'quantity'; COMMENT ON TABLE \"Source\".CHILD IS 'child table'", null, options);
|
||||
agent.executeQuery("CREATE INDEX \"Source\".IDX_QTY ON \"Source\".CHILD(QTY DESC)", null, options);
|
||||
List<ColumnInfo> columns = agent.getColumns("Source", "CHILD");
|
||||
Assertions.assertTrue(columns.get(0).getExtra().contains("identity"), profile);
|
||||
Assertions.assertEquals("computed", columns.get(3).getExtra(), profile);
|
||||
Assertions.assertEquals("quantity", columns.get(2).getComment(), profile);
|
||||
|
||||
for (String table : List.of("PARENT", "CHILD")) {
|
||||
String ddl = agent.getTableDdl("Source", table);
|
||||
Assertions.assertFalse(ddl.contains("CREATE USER"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("CREATE SCHEMA"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("INSERT INTO"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("CREATE SEQUENCE IF NOT EXISTS \"Source\".\"UNUSED\""), ddl);
|
||||
if (table.equals("PARENT")) {
|
||||
Assertions.assertFalse(ddl.contains("SYSTEM_SEQUENCE_"), ddl);
|
||||
}
|
||||
agent.executeQuery(ddl.replace("\"Source\".", "\"Target\"."), null, options);
|
||||
}
|
||||
agent.executeQuery("INSERT INTO \"Target\".PARENT(ID) VALUES (1)", null, options);
|
||||
agent.executeQuery("INSERT INTO \"Target\".CHILD(PARENT_ID) VALUES (1)", null, options);
|
||||
Assertions.assertEquals(List.of(List.of(1, 1, 2)), agent.executeQuery("SELECT ID, QTY, TOTAL FROM \"Target\".CHILD", null, options).getRows(), profile);
|
||||
Assertions.assertEquals("11", agent.executeQuery("SELECT TOKEN FROM \"Target\".CHILD", null, options).getRows().get(0).get(0).toString(), profile);
|
||||
Assertions.assertThrows(RuntimeException.class, () -> agent.executeQuery("INSERT INTO \"Target\".CHILD(QTY) VALUES (-1)", null, options), profile);
|
||||
Assertions.assertTrue(agent.getTableDdl("Target", "CHILD").contains("\"QTY\" DESC"), profile);
|
||||
Assertions.assertEquals("quantity", agent.getColumns("Target", "CHILD").get(2).getComment(), profile);
|
||||
agent.executeQuery("DELETE FROM \"Target\".PARENT", null, options);
|
||||
Assertions.assertTrue(agent.executeQuery("SELECT * FROM \"Target\".CHILD", null, options).getRows().isEmpty(), profile);
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedAlwaysIdentityAcceptsExplicitInsertWithOverride() {
|
||||
for (String profile : List.of("h2-v2", "h2-v3")) {
|
||||
H2Agent agent = connect(profile);
|
||||
try {
|
||||
ExecuteQueryOptions options = new ExecuteQueryOptions();
|
||||
agent.executeQuery("CREATE TABLE ALWAYS_ID (ID INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, V INT)", null, options);
|
||||
Assertions.assertEquals("generated always as identity", agent.getColumns("PUBLIC", "ALWAYS_ID").get(0).getExtra());
|
||||
agent.executeQuery("INSERT INTO ALWAYS_ID(ID, V) OVERRIDING SYSTEM VALUE VALUES (42, 7)", null, options);
|
||||
Assertions.assertEquals(List.of(List.of(42, 7)), agent.executeQuery("SELECT ID, V FROM ALWAYS_ID", null, options).getRows());
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferMetadataAndWritesRespectCustomSchemasAcrossBundledVersions() {
|
||||
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
|
||||
ConnectParams params = new ConnectParams("", 0, "mem:transfer-" + profile, "sa", "", "", "", false);
|
||||
params.setDriver_profile(profile);
|
||||
H2Agent agent = new H2Agent();
|
||||
agent.connect(params);
|
||||
try {
|
||||
ExecuteQueryOptions options = new ExecuteQueryOptions();
|
||||
agent.executeQuery("CREATE SCHEMA \"TransferTarget\"", null, options);
|
||||
agent.executeQuery("CREATE TABLE PUBLIC.ITEMS (PUBLIC_ONLY INT)", null, options);
|
||||
agent.executeQuery(
|
||||
"CREATE TABLE \"TransferTarget\".ITEMS (ID INT, PART INT, NAME VARCHAR(1024), AMOUNT DECIMAL(12, 2), PRIMARY KEY (ID, PART))",
|
||||
null, options
|
||||
);
|
||||
agent.executeQuery("CREATE INDEX \"TransferTarget\".IDX_NAME ON \"TransferTarget\".ITEMS(NAME)", null, options);
|
||||
agent.executeQuery("CREATE UNIQUE HASH INDEX \"TransferTarget\".IDX_PART ON \"TransferTarget\".ITEMS(PART)", null, options);
|
||||
|
||||
Assertions.assertTrue(agent.listSchemas().contains("TransferTarget"), profile);
|
||||
Assertions.assertTrue(agent.listTables("TransferTarget").stream().anyMatch(table -> table.getName().equals("ITEMS")), profile);
|
||||
Assertions.assertTrue(agent.listTables("MISSING_SCHEMA").isEmpty(), profile);
|
||||
Assertions.assertEquals(List.of("PUBLIC_ONLY"), agent.getColumns(null, "ITEMS").stream().map(ColumnInfo::getName).toList(), profile);
|
||||
List<ColumnInfo> columns = agent.getColumns("TransferTarget", "ITEMS");
|
||||
Assertions.assertEquals(List.of("ID", "PART", "NAME", "AMOUNT"), columns.stream().map(ColumnInfo::getName).toList(), profile);
|
||||
Assertions.assertEquals(1024, columns.get(2).getCharacter_maximum_length(), profile);
|
||||
Assertions.assertEquals(12, columns.get(3).getNumeric_precision(), profile);
|
||||
Assertions.assertEquals(2, columns.get(3).getNumeric_scale(), profile);
|
||||
Assertions.assertTrue(agent.listIndexes("TransferTarget", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_NAME")), profile);
|
||||
agent.executeQuery("CREATE SCHEMA \"TransferCopy\"", null, options);
|
||||
String ddl = agent.getTableDdl("TransferTarget", "ITEMS");
|
||||
agent.executeQuery(ddl.replace("\"TransferTarget\".", "\"TransferCopy\"."), null, options);
|
||||
Assertions.assertEquals(1024, agent.getColumns("TransferCopy", "ITEMS").get(2).getCharacter_maximum_length(), profile);
|
||||
Assertions.assertTrue(agent.listIndexes("TransferCopy", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_NAME")), profile);
|
||||
Assertions.assertTrue(agent.listIndexes("TransferCopy", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_PART") && index.getIs_unique()), profile);
|
||||
agent.executeQuery("INSERT INTO \"TransferCopy\".ITEMS VALUES (1, 1, 'first', 1.00)", null, options);
|
||||
Assertions.assertThrows(RuntimeException.class, () -> agent.executeQuery("INSERT INTO \"TransferCopy\".ITEMS VALUES (2, 1, 'duplicate', 2.00)", null, options), profile);
|
||||
|
||||
agent.executeQuery("INSERT INTO \"TransferTarget\".ITEMS VALUES (1, 1, 'old', 1.00)", null, options);
|
||||
agent.executeQuery(
|
||||
"MERGE INTO \"TransferTarget\".\"ITEMS\" (\"ID\", \"PART\", \"NAME\", \"AMOUNT\") KEY (\"ID\", \"PART\") VALUES "
|
||||
+ "(1, 1, 'updated', 12.34), (1, 2, 'new', 56.78)",
|
||||
null, options
|
||||
);
|
||||
QueryResult result = agent.executeQuery("SELECT NAME, CAST(AMOUNT AS VARCHAR) FROM \"TransferTarget\".ITEMS ORDER BY PART", null, options);
|
||||
Assertions.assertEquals(List.of(List.of("updated", "12.34"), List.of("new", "56.78")), result.getRows(), profile);
|
||||
agent.executeQuery("TRUNCATE TABLE \"TransferTarget\".ITEMS", null, options);
|
||||
Assertions.assertTrue(agent.executeQuery("SELECT * FROM \"TransferTarget\".ITEMS", null, options).getRows().isEmpty(), profile);
|
||||
agent.executeQuery("CREATE TABLE \"TransferTarget\".PAYLOADS (LABEL VARCHAR(128), DATA JSON, RAW_BYTES VARBINARY)", null, options);
|
||||
agent.executeQuery("INSERT INTO \"TransferTarget\".PAYLOADS VALUES ('C:\\tmp\\o''clock', JSON '[1,2]', X'00ff')", null, options);
|
||||
QueryResult payload = agent.executeQuery("SELECT * FROM \"TransferTarget\".PAYLOADS", null, options);
|
||||
Assertions.assertEquals(List.of(List.of("C:\\tmp\\o'clock", "[1,2]", "0x00ff")), payload.getRows(), profile);
|
||||
} finally {
|
||||
agent.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadataAndExecutionWorkAcrossBundledVersions() {
|
||||
for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) {
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.dbx.agent.iris;
|
||||
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.StandardJdbcMetadata;
|
||||
|
||||
import com.intersystems.jdbc.IRISConnection;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -32,12 +35,54 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
|
||||
super(IRIS_PROFILE);
|
||||
}
|
||||
|
||||
// The InterSystems driver's default query prefetch block (32768 bytes) means
|
||||
// one server round trip per ~32KB of result data. Large result sets over
|
||||
// high-latency links then spend most of their wall time waiting on the
|
||||
// network instead of transferring rows, while other clients that negotiate
|
||||
// larger fetch blocks finish several times faster. Statement.setFetchSize
|
||||
// does not help here: intersystems-jdbc stores that value but never sends
|
||||
// it to the server. The connection-level query prefetch size is the only
|
||||
// effective knob, so raise it for every DBX IRIS connection. This is
|
||||
// advisory: servers that reject the value keep the driver default, and the
|
||||
// driver only round-trips when the value actually changes.
|
||||
static final int IRIS_QUERY_PREFETCH_SIZE = 262144;
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
applyQueryPrefetchSize(connection);
|
||||
}
|
||||
|
||||
static void applyQueryPrefetchSize(Connection connection) {
|
||||
if (!(connection instanceof IRISConnection)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
((IRISConnection) connection).setQueryPrefetchSize(IRIS_QUERY_PREFETCH_SIZE);
|
||||
} catch (Exception ignored) {
|
||||
// Performance tuning only; keep the connection usable with the
|
||||
// driver default when a server refuses the larger prefetch.
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet rs, int index, int sqlType) {
|
||||
if (sqlType == Types.OTHER) {
|
||||
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
|
||||
switch (sqlType) {
|
||||
case Types.BIGINT:
|
||||
case Types.INTEGER:
|
||||
case Types.SMALLINT:
|
||||
case Types.TINYINT:
|
||||
// InterSystems can expose values outside the Java primitive range
|
||||
// implied by its JDBC metadata. Preserve them without narrowing.
|
||||
return unchecked(() -> {
|
||||
Object value = rs.getBigDecimal(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
});
|
||||
case Types.OTHER:
|
||||
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
|
||||
default:
|
||||
return super.resultValue(rs, index, sqlType);
|
||||
}
|
||||
return super.resultValue(rs, index, sqlType);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.dbx.agent.iris;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.intersystems.jdbc.IRISConnection;
|
||||
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.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class IrisAgentQueryPrefetchTest {
|
||||
/** Records setQueryPrefetchSize calls without touching driver internals. */
|
||||
static final class RecordingIrisConnection extends IRISConnection {
|
||||
final List<Integer> prefetchSizes = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void setQueryPrefetchSize(int size) {
|
||||
prefetchSizes.add(size);
|
||||
}
|
||||
}
|
||||
|
||||
static final class FailingIrisConnection extends IRISConnection {
|
||||
@Override
|
||||
public void setQueryPrefetchSize(int size) throws SQLException {
|
||||
throw new SQLException("server rejected prefetch size");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterConnectAppliesLargerQueryPrefetch() {
|
||||
RecordingIrisConnection connection = new RecordingIrisConnection();
|
||||
|
||||
new IrisAgent().afterConnect(new ConnectParams(), connection);
|
||||
|
||||
assertEquals(Collections.singletonList(IrisAgent.IRIS_QUERY_PREFETCH_SIZE), connection.prefetchSizes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterConnectKeepsNonIrisConnectionsUntouched() {
|
||||
Connection connection = proxyConnection();
|
||||
|
||||
assertDoesNotThrow(() -> new IrisAgent().afterConnect(new ConnectParams(), connection));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefetchFailureDoesNotBreakConnect() {
|
||||
FailingIrisConnection connection = new FailingIrisConnection();
|
||||
|
||||
assertDoesNotThrow(() -> new IrisAgent().afterConnect(new ConnectParams(), connection));
|
||||
}
|
||||
|
||||
@Test
|
||||
void directApplyIsBoundedToIrisConnections() {
|
||||
// A plain java.sql.Connection (for example a test or wrapper instance)
|
||||
// must not be cast or fail when the prefetch tuning runs.
|
||||
assertDoesNotThrow(() -> IrisAgent.applyQueryPrefetchSize(proxyConnection()));
|
||||
}
|
||||
|
||||
private static Connection proxyConnection() {
|
||||
return (Connection) Proxy.newProxyInstance(
|
||||
IrisAgentQueryPrefetchTest.class.getClassLoader(),
|
||||
new Class<?>[] { Connection.class },
|
||||
new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||
Class<?> returnType = method.getReturnType();
|
||||
if (returnType == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (returnType.isPrimitive() && returnType != void.class) {
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -76,8 +77,38 @@ class IrisAgentTest {
|
||||
assertEquals(Collections.singletonList("getObject"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsOutOfRangeIntegerThroughBigDecimal() {
|
||||
BigDecimal valueOutsideIntegerRange = new BigDecimal("2147483648");
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getBigDecimal".equals(method.getName())) {
|
||||
calls.add("getBigDecimal");
|
||||
return valueOutsideIntegerRange;
|
||||
}
|
||||
if (method.getName().startsWith("get")) {
|
||||
calls.add(method.getName());
|
||||
throw new SQLException("Numeric value out of range");
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new IrisAgent().resultValue(resultSet, 1, Types.INTEGER);
|
||||
|
||||
assertEquals(valueOutsideIntegerRange, value);
|
||||
assertEquals(Collections.singletonList("getBigDecimal"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesStringPathForStandardValues() {
|
||||
assertStringPath(Types.VARCHAR);
|
||||
assertStringPath(Types.LONGVARCHAR);
|
||||
}
|
||||
|
||||
private static void assertStringPath(int sqlType) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getString".equals(method.getName())) {
|
||||
@@ -90,7 +121,7 @@ class IrisAgentTest {
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new IrisAgent().resultValue(resultSet, 1, Types.VARCHAR);
|
||||
Object value = new IrisAgent().resultValue(resultSet, 1, sqlType);
|
||||
|
||||
assertEquals("ordinary", value);
|
||||
assertEquals(Collections.singletonList("getString"), calls);
|
||||
|
||||
@@ -316,9 +316,13 @@ func runQueryBenchmark(process *agentProcess, duration time.Duration, concurrenc
|
||||
total += value
|
||||
}
|
||||
count := operations.Load()
|
||||
sampleCount := len(merged)
|
||||
if sampleCount < 1 {
|
||||
sampleCount = 1
|
||||
}
|
||||
return benchmarkResult{
|
||||
Concurrency: concurrency, Operations: count, Errors: failures.Load(), DurationMS: float64(elapsed.Microseconds()) / 1000,
|
||||
QPS: float64(count) / elapsed.Seconds(), MeanMS: total / float64(max(1, len(merged))),
|
||||
QPS: float64(count) / elapsed.Seconds(), MeanMS: total / float64(sampleCount),
|
||||
P50MS: percentile(merged, 0.50), P95MS: percentile(merged, 0.95), P99MS: percentile(merged, 0.99),
|
||||
PeakRSSKB: peakRSS.Load(),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module github.com/t8y2/dbx/agents/drivers/kingbase-go
|
||||
|
||||
go 1.22
|
||||
go 1.20
|
||||
|
||||
require gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -501,9 +502,17 @@ func (s *server) testConnection(cp connectParams) error {
|
||||
type kingbaseDBOpener func(connectParams, string) (*sql.DB, error)
|
||||
|
||||
func openAndPingDB(cp connectParams, timeout time.Duration, opener kingbaseDBOpener) (*sql.DB, error) {
|
||||
if endpoints := clusterConnectEndpoints(cp); len(endpoints) > 1 {
|
||||
return openAndPingClusterEndpoints(cp, timeout, endpoints, opener)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return pingDBWithSSLModes(ctx, cp, opener)
|
||||
}
|
||||
|
||||
// pingDBWithSSLModes runs the historical sslmode attempt sequence
|
||||
// ("prefer" degrades to require then disable) for one set of fields.
|
||||
func pingDBWithSSLModes(ctx context.Context, cp connectParams, opener kingbaseDBOpener) (*sql.DB, error) {
|
||||
sslMode := effectiveSSLMode(cp)
|
||||
attempts := []string{sslMode}
|
||||
if sslMode == "prefer" {
|
||||
@@ -528,6 +537,111 @@ func openAndPingDB(cp connectParams, timeout time.Duration, opener kingbaseDBOpe
|
||||
return nil, errors.New("kingbase connection failed")
|
||||
}
|
||||
|
||||
type kingbaseEndpoint struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
// clusterConnectEndpoints splits the host field into ordered endpoints for
|
||||
// cluster (multi-IP) configurations. DBX stores cluster hosts as
|
||||
// comma-separated entries, each optionally embedding its own `:port`
|
||||
// (mirroring the vastbase/openGauss driver semantics); semicolons are also
|
||||
// accepted because Kingbase deployments paste both separators. A native
|
||||
// connection_string builds its own DSN, so the host field is only split for
|
||||
// the field-based path, and a host without any separator yields a single
|
||||
// endpoint which keeps the legacy single-host flow untouched.
|
||||
func clusterConnectEndpoints(cp connectParams) []kingbaseEndpoint {
|
||||
if value := strings.TrimSpace(cp.ConnectionString); value != "" && !isKingbaseJDBCURL(value) {
|
||||
return nil
|
||||
}
|
||||
defaultPort := cp.Port
|
||||
if defaultPort <= 0 {
|
||||
defaultPort = 54321
|
||||
}
|
||||
return splitHostEndpoints(cp.Host, defaultPort)
|
||||
}
|
||||
|
||||
// splitHostEndpoints parses `host1[:port1][,;]host2[:port2]...`. Entries
|
||||
// without an embedded port use fallbackPort; bracketed IPv6 literals may
|
||||
// carry a port after the closing bracket.
|
||||
func splitHostEndpoints(rawHost string, fallbackPort int) []kingbaseEndpoint {
|
||||
parts := strings.FieldsFunc(rawHost, func(r rune) bool { return r == ',' || r == ';' })
|
||||
endpoints := make([]kingbaseEndpoint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
host, port, ok := parseHostEndpoint(strings.TrimSpace(part), fallbackPort)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
endpoints = append(endpoints, kingbaseEndpoint{host: host, port: port})
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func parseHostEndpoint(entry string, fallbackPort int) (string, int, bool) {
|
||||
if entry == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
if strings.HasPrefix(entry, "[") {
|
||||
if close := strings.Index(entry, "]"); close > 0 {
|
||||
host := entry[1:close]
|
||||
suffix := entry[close+1:]
|
||||
portText, hasPort := strings.CutPrefix(suffix, ":")
|
||||
if !hasPort && suffix == "" {
|
||||
return host, fallbackPort, true
|
||||
}
|
||||
if hasPort {
|
||||
if port, err := strconv.Atoi(portText); err == nil && validEndpointPort(port) {
|
||||
return host, port, true
|
||||
}
|
||||
}
|
||||
// Malformed bracketed entries such as `[::1]x` keep their raw
|
||||
// text so the eventual dial error stays honest.
|
||||
return entry, fallbackPort, true
|
||||
}
|
||||
}
|
||||
if strings.Count(entry, ":") == 1 {
|
||||
if host, rawPort, ok := strings.Cut(entry, ":"); ok && host != "" {
|
||||
if port, err := strconv.Atoi(rawPort); err == nil && validEndpointPort(port) {
|
||||
return host, port, true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bare IPv6 literals and hostnames (including entries with a non-numeric
|
||||
// port suffix) are forwarded verbatim, exactly like the single-host path.
|
||||
return entry, fallbackPort, true
|
||||
}
|
||||
|
||||
func validEndpointPort(port int) bool {
|
||||
return port > 0 && port <= 65535
|
||||
}
|
||||
|
||||
// openAndPingClusterEndpoints tries each configured endpoint in order and
|
||||
// keeps the first connection that completes the sslmode sequence, matching
|
||||
// the failover behavior the openGauss-family drivers implement natively.
|
||||
// Following libpq semantics, every endpoint gets its own full timeout
|
||||
// budget so one unreachable node cannot starve the remaining candidates.
|
||||
func openAndPingClusterEndpoints(
|
||||
cp connectParams,
|
||||
timeout time.Duration,
|
||||
endpoints []kingbaseEndpoint,
|
||||
opener kingbaseDBOpener,
|
||||
) (*sql.DB, error) {
|
||||
failures := make([]error, 0, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
endpointParams := cp
|
||||
endpointParams.Host = endpoint.host
|
||||
endpointParams.Port = endpoint.port
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
db, err := pingDBWithSSLModes(ctx, endpointParams, opener)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("%s:%d: %w", endpoint.host, endpoint.port, err))
|
||||
}
|
||||
return nil, fmt.Errorf("kingbase connection failed after trying %d endpoints: %w", len(endpoints), errors.Join(failures...))
|
||||
}
|
||||
|
||||
func shouldRetryKingbaseWithoutSSL(err error) bool {
|
||||
if errors.Is(err, gokb.ErrSSLNotSupported) {
|
||||
return true
|
||||
@@ -766,11 +880,27 @@ func (s *server) closeAllQuerySessions() {
|
||||
}
|
||||
}
|
||||
|
||||
// minInt/maxInt replace the Go 1.21 builtins so the module stays go 1.20
|
||||
// compatible for the Windows 7 toolchain build.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
capacity := min(pageSize, session.remaining)
|
||||
capacity := minInt(pageSize, session.remaining)
|
||||
result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: make([][]any, 0, capacity)}
|
||||
for len(result.Rows) < pageSize && session.remaining > 0 {
|
||||
if session.pending != nil {
|
||||
@@ -811,7 +941,7 @@ func readRows(rows *sql.Rows, maxRows int) (queryResult, error) {
|
||||
}
|
||||
columns = nonNilStrings(columns)
|
||||
columnTypes := columnTypeNames(rows)
|
||||
result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: make([][]any, 0, min(maxRows, 1024))}
|
||||
result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: make([][]any, 0, minInt(maxRows, 1024))}
|
||||
for rows.Next() {
|
||||
if len(result.Rows) >= maxRows {
|
||||
result.Truncated = true
|
||||
@@ -1700,7 +1830,7 @@ func hasTopLevelSQLKeyword(sqlText string, index int, target string) bool {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth = max(depth-1, 0)
|
||||
depth = maxInt(depth-1, 0)
|
||||
default:
|
||||
if depth == 0 && isSQLWordStartByte(sqlText[index]) {
|
||||
keyword, next := sqlKeywordAt(sqlText, index)
|
||||
|
||||
@@ -352,6 +352,75 @@ func (state *connectionAttemptState) connectionStrings() []string {
|
||||
return append([]string(nil), state.dsns...)
|
||||
}
|
||||
|
||||
// failoverState records every opener attempt (per endpoint host + sslmode)
|
||||
// and simulates ping failures keyed as "<sslMode>@<host>".
|
||||
type failoverState struct {
|
||||
mu sync.Mutex
|
||||
opened []string
|
||||
dsns []string
|
||||
pingErrors map[string]error
|
||||
}
|
||||
|
||||
type failoverOpener struct {
|
||||
state *failoverState
|
||||
}
|
||||
|
||||
type failoverConnector struct {
|
||||
state *failoverState
|
||||
host string
|
||||
sslMode string
|
||||
}
|
||||
|
||||
type failoverDriver struct{}
|
||||
|
||||
type failoverConn struct {
|
||||
state *failoverState
|
||||
host string
|
||||
sslMode string
|
||||
}
|
||||
|
||||
func (o failoverOpener) open(cp connectParams, sslMode string) (*sql.DB, error) {
|
||||
dsn := buildDSNWithSSLMode(cp, sslMode)
|
||||
o.state.mu.Lock()
|
||||
o.state.opened = append(o.state.opened, sslMode+"@"+cp.Host)
|
||||
o.state.dsns = append(o.state.dsns, dsn)
|
||||
o.state.mu.Unlock()
|
||||
return sql.OpenDB(failoverConnector{state: o.state, host: cp.Host, sslMode: sslMode}), nil
|
||||
}
|
||||
|
||||
func (connector failoverConnector) Connect(context.Context) (driver.Conn, error) {
|
||||
conn := failoverConn{state: connector.state, host: connector.host, sslMode: connector.sslMode}
|
||||
return &conn, nil
|
||||
}
|
||||
|
||||
func (failoverConnector) Driver() driver.Driver { return failoverDriver{} }
|
||||
|
||||
func (failoverDriver) Open(string) (driver.Conn, error) { return nil, driver.ErrSkip }
|
||||
|
||||
func (*failoverConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
||||
|
||||
func (*failoverConn) Close() error { return nil }
|
||||
|
||||
func (*failoverConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
||||
|
||||
func (connection failoverConn) Ping(context.Context) error {
|
||||
connection.state.mu.Lock()
|
||||
defer connection.state.mu.Unlock()
|
||||
return connection.state.pingErrors[connection.sslMode+"@"+connection.host]
|
||||
}
|
||||
|
||||
func (state *failoverState) attempts() []string {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
return append([]string(nil), state.opened...)
|
||||
}
|
||||
|
||||
func (state *failoverState) connectionStrings() []string {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
return append([]string(nil), state.dsns...)
|
||||
}
|
||||
|
||||
func openFakeDB(t *testing.T, rowCount int) (*sql.DB, *fakeDriverState) {
|
||||
t.Helper()
|
||||
registerTestDriver.Do(func() { sql.Register("kingbase-agent-test", fakeDriver{}) })
|
||||
@@ -961,6 +1030,300 @@ func TestOpenAndPingDBSSLDefaultsToVerifyFull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitHostEndpoints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
fallbackPort int
|
||||
expected []kingbaseEndpoint
|
||||
}{
|
||||
{
|
||||
name: "single host stays whole",
|
||||
host: "172.22.232.10",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{{host: "172.22.232.10", port: 54321}},
|
||||
},
|
||||
{
|
||||
name: "single host with custom fallback port",
|
||||
host: "db.example.com",
|
||||
fallbackPort: 6000,
|
||||
expected: []kingbaseEndpoint{{host: "db.example.com", port: 6000}},
|
||||
},
|
||||
{
|
||||
name: "comma separated cluster",
|
||||
host: "172.22.232.10,172.22.232.11",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "172.22.232.10", port: 54321},
|
||||
{host: "172.22.232.11", port: 54321},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "semicolon separated cluster",
|
||||
host: "172.22.232.10; 172.22.232.11",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "172.22.232.10", port: 54321},
|
||||
{host: "172.22.232.11", port: 54321},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "per entry ports",
|
||||
host: "10.0.0.1:1000,10.0.0.2:2000",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "10.0.0.1", port: 1000},
|
||||
{host: "10.0.0.2", port: 2000},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed embedded and fallback ports",
|
||||
host: "10.0.0.1:1000,10.0.0.2",
|
||||
fallbackPort: 54322,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "10.0.0.1", port: 1000},
|
||||
{host: "10.0.0.2", port: 54322},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bracketed ipv6 without port",
|
||||
host: "[2001:db8::1],[2001:db8::2]",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "2001:db8::1", port: 54321},
|
||||
{host: "2001:db8::2", port: 54321},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bracketed ipv6 with port",
|
||||
host: "[2001:db8::1]:6000,[2001:db8::2]:6001",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "2001:db8::1", port: 6000},
|
||||
{host: "2001:db8::2", port: 6001},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare ipv6 literal",
|
||||
host: "::1",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{{host: "::1", port: 54321}},
|
||||
},
|
||||
{
|
||||
name: "invalid port suffix stays part of the host",
|
||||
host: "db.example.com:notaport",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{{host: "db.example.com:notaport", port: 54321}},
|
||||
},
|
||||
{
|
||||
name: "out of range port falls back",
|
||||
host: "10.0.0.1:70000",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{{host: "10.0.0.1:70000", port: 54321}},
|
||||
},
|
||||
{
|
||||
name: "empty entries are dropped",
|
||||
host: "10.0.0.1,, ;10.0.0.2",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{
|
||||
{host: "10.0.0.1", port: 54321},
|
||||
{host: "10.0.0.2", port: 54321},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty host yields no endpoints",
|
||||
host: " ",
|
||||
fallbackPort: 54321,
|
||||
expected: []kingbaseEndpoint{},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
endpoints := splitHostEndpoints(test.host, test.fallbackPort)
|
||||
if len(endpoints) != len(test.expected) {
|
||||
t.Fatalf("unexpected endpoint count: got %#v want %#v", endpoints, test.expected)
|
||||
}
|
||||
for index, endpoint := range endpoints {
|
||||
if endpoint != test.expected[index] {
|
||||
t.Fatalf("endpoint %d mismatch: got %#v want %#v", index, endpoint, test.expected[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterConnectEndpointsIgnoresNativeConnectionString(t *testing.T) {
|
||||
if endpoints := clusterConnectEndpoints(connectParams{
|
||||
Host: "10.0.0.1,10.0.0.2",
|
||||
Port: 54321,
|
||||
ConnectionString: "host=cluster.example.com port=54321",
|
||||
}); endpoints != nil {
|
||||
t.Fatalf("native connection string must not be split: %#v", endpoints)
|
||||
}
|
||||
// The JDBC URL the host app always passes is ignored by the DSN builder,
|
||||
// so the host field must still be split for it.
|
||||
endpoints := clusterConnectEndpoints(connectParams{
|
||||
Host: "10.0.0.1,10.0.0.2",
|
||||
Port: 54321,
|
||||
ConnectionString: "jdbc:kingbase8://10.0.0.1:54321/test",
|
||||
})
|
||||
if len(endpoints) != 2 {
|
||||
t.Fatalf("jdbc url must not block cluster splitting: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBSingleHostDSNUnchanged(t *testing.T) {
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@172.22.232.10": gokb.ErrSSLNotSupported,
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "172.22.232.10", Port: 54321, Username: "system", Database: "test"}, time.Second, opener.open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
dsns := state.connectionStrings()
|
||||
if len(dsns) != 2 {
|
||||
t.Fatalf("single host must keep the prefer fallback sequence: %v", dsns)
|
||||
}
|
||||
for _, dsn := range dsns {
|
||||
if !strings.Contains(dsn, "host='172.22.232.10' port=54321 ") {
|
||||
t.Fatalf("single host DSN changed: %s", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBMultiHostFailsOverToReachableEndpoint(t *testing.T) {
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@203.0.113.10": errors.New("dial tcp 203.0.113.10:54321: connect: connection refused"),
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "203.0.113.10,198.51.100.20", Port: 54321}, time.Second, opener.open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
attempts := state.attempts()
|
||||
if strings.Join(attempts, " -> ") != "require@203.0.113.10 -> require@198.51.100.20" {
|
||||
t.Fatalf("unexpected failover order: %v", attempts)
|
||||
}
|
||||
dsns := state.connectionStrings()
|
||||
if len(dsns) != 2 {
|
||||
t.Fatalf("unexpected DSN count: %v", dsns)
|
||||
}
|
||||
if !strings.Contains(dsns[0], "host='203.0.113.10' port=54321") {
|
||||
t.Fatalf("first DSN must target the first endpoint: %s", dsns[0])
|
||||
}
|
||||
if !strings.Contains(dsns[1], "host='198.51.100.20' port=54321") {
|
||||
t.Fatalf("second DSN must target the second endpoint: %s", dsns[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBMultiHostAppliesConfiguredPortToEveryEndpoint(t *testing.T) {
|
||||
// Regression for #7885: the whole comma-joined host string used to reach
|
||||
// gokb as one hostname (`lookup ip1,ip2: no such host`) while the
|
||||
// configured port was never applied per endpoint.
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@10.10.0.1": errors.New("dial tcp: connection refused"),
|
||||
"disable@10.10.0.1": errors.New("dial tcp: connection refused"),
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "10.10.0.1,10.10.0.2", Port: 54321}, time.Second, opener.open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, dsn := range state.connectionStrings() {
|
||||
if strings.Contains(dsn, "10.10.0.1,") || strings.Contains(dsn, ",10.10.0.2") {
|
||||
t.Fatalf("comma-joined host leaked into DSN: %s", dsn)
|
||||
}
|
||||
if !strings.Contains(dsn, "port=54321") {
|
||||
t.Fatalf("configured port missing from DSN: %s", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBMultiHostPerEntryPorts(t *testing.T) {
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@10.0.0.1": errors.New("dial tcp: connection refused"),
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "10.0.0.1:1000,10.0.0.2:2000", Port: 54321}, time.Second, opener.open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
dsns := state.connectionStrings()
|
||||
if !strings.Contains(dsns[0], "host='10.0.0.1' port=1000") {
|
||||
t.Fatalf("first endpoint must use its embedded port: %s", dsns[0])
|
||||
}
|
||||
if !strings.Contains(dsns[1], "host='10.0.0.2' port=2000") {
|
||||
t.Fatalf("second endpoint must use its embedded port: %s", dsns[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBMultiHostSemicolonSeparatorFailsOver(t *testing.T) {
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@10.0.0.9": errors.New("dial tcp: connection refused"),
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "10.0.0.9;10.0.0.10", Port: 54321}, time.Second, opener.open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
attempts := state.attempts()
|
||||
if len(attempts) != 2 || attempts[1] != "require@10.0.0.10" {
|
||||
t.Fatalf("semicolon separated hosts must fail over in order: %v", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBMultiHostAllEndpointsFail(t *testing.T) {
|
||||
refused := errors.New("dial tcp: connection refused")
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@10.0.0.1": refused,
|
||||
"require@10.0.0.2": refused,
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "10.0.0.1,10.0.0.2", Port: 54321}, time.Second, opener.open)
|
||||
if db != nil {
|
||||
db.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected failure when every endpoint is down")
|
||||
}
|
||||
if !errors.Is(err, refused) {
|
||||
t.Fatalf("aggregated error must wrap endpoint failures: %v", err)
|
||||
}
|
||||
for _, endpoint := range []string{"10.0.0.1:54321", "10.0.0.2:54321"} {
|
||||
if !strings.Contains(err.Error(), endpoint) {
|
||||
t.Fatalf("error must mention %s: %v", endpoint, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(err.Error(), "trying 2 endpoints") {
|
||||
t.Fatalf("error must report the endpoint count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndPingDBMultiHostKeepsSSLFallbackPerEndpoint(t *testing.T) {
|
||||
state := &failoverState{pingErrors: map[string]error{
|
||||
"require@10.0.0.1": gokb.ErrSSLNotSupported,
|
||||
"disable@10.0.0.1": errors.New("dial tcp: connection refused"),
|
||||
"require@10.0.0.2": gokb.ErrSSLNotSupported,
|
||||
}}
|
||||
opener := failoverOpener{state: state}
|
||||
db, err := openAndPingDB(connectParams{Host: "10.0.0.1,10.0.0.2", Port: 54321}, time.Second, opener.open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
attempts := state.attempts()
|
||||
expected := "require@10.0.0.1 -> disable@10.0.0.1 -> require@10.0.0.2 -> disable@10.0.0.2"
|
||||
if strings.Join(attempts, " -> ") != expected {
|
||||
t.Fatalf("unexpected attempt sequence: %v", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectAndTestConnectionSharePreferFallback(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -1085,7 +1448,7 @@ func TestListIndexesFallsBackWhenWithOrdinalityIsUnsupported(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
indexes, err := server.listIndexes("PUBLIC", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1576,7 +1939,7 @@ func TestListTriggerDefinitionsFallsBackToSingleArgument(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
definitions, err := server.listTriggerDefinitions("PUBLIC", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1610,7 +1973,7 @@ func TestListTriggersFallsBackToV7InternalPredicate(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
triggers, err := server.listTriggers("PUBLIC", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -3079,7 +3442,7 @@ func TestObjectSourceFallsBackToV7RoutineDDLAndCachesChoice(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
source, err := server.getObjectSource("PUBLIC", "format_name", "FUNCTION")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -3911,7 +4274,7 @@ func TestSchemaConnSkipsInitialAndRepeatedEmptySchema(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = db
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
conn, err := server.schemaConn(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -4037,7 +4400,7 @@ func TestExecuteQueryReappliesSchemaForRepeatedRequests(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = db
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := server.executeQuery(queryOptions{SQL: "SELECT 1", Schema: "sdy_smartsite", MaxRows: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mongodb.ConnectionString;
|
||||
import com.mongodb.MongoBulkWriteException;
|
||||
import com.mongodb.MongoCredential;
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import com.mongodb.ServerAddress;
|
||||
import com.mongodb.bulk.BulkWriteError;
|
||||
import com.mongodb.bulk.WriteConcernError;
|
||||
import com.mongodb.client.AggregateIterable;
|
||||
import com.mongodb.client.FindIterable;
|
||||
import com.mongodb.client.MongoClient;
|
||||
import com.mongodb.client.MongoClients;
|
||||
import com.mongodb.client.MongoCollection;
|
||||
@@ -26,6 +30,7 @@ import com.mongodb.client.model.CollationMaxVariable;
|
||||
import com.mongodb.client.model.CollationStrength;
|
||||
import com.mongodb.client.model.CountOptions;
|
||||
import com.mongodb.client.model.InsertManyOptions;
|
||||
import com.mongodb.client.model.ReplaceOptions;
|
||||
import com.mongodb.client.model.UpdateOptions;
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import java.io.BufferedReader;
|
||||
@@ -57,6 +62,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -80,11 +86,22 @@ public final class MongoAgent {
|
||||
private static final JsonWriterSettings EXTENDED_JSON_SETTINGS = JsonWriterSettings.builder()
|
||||
.outputMode(JsonMode.RELAXED)
|
||||
.build();
|
||||
// Driver 3.x names the canonical Extended JSON v2 output "EXTENDED"; RELAXED is its lossy
|
||||
// sibling (plain numbers, ISO dates), so exports must use this one to keep BSON types.
|
||||
private static final JsonWriterSettings CANONICAL_JSON_SETTINGS = JsonWriterSettings.builder()
|
||||
.outputMode(JsonMode.EXTENDED)
|
||||
.build();
|
||||
private static final String LEGACY_SESSION_ID = "__legacy__";
|
||||
private static final String DEFAULT_ID_INDEX_NAME = "_id_";
|
||||
private static final int CLONE_INSERT_BATCH_SIZE = 1_000;
|
||||
private static final int DEFAULT_FIND_CURSOR_BATCH_SIZE = 1_000;
|
||||
private static final int MAX_FIND_CURSORS_PER_OWNER = 16;
|
||||
private static final int MAX_FIND_CURSORS_TOTAL = 64;
|
||||
private static final int MAX_SESSIONS = 256;
|
||||
private static final ThreadLocal<MongoClient> CURRENT_CLIENT = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Set<String>> CURRENT_FIND_CURSOR_OWNER = new ThreadLocal<>();
|
||||
private static final ConcurrentHashMap<String, MongoCursor<Document>> FIND_CURSORS = new ConcurrentHashMap<>();
|
||||
private static final Set<String> LEGACY_FIND_CURSOR_IDS = ConcurrentHashMap.newKeySet();
|
||||
private static MongoClient legacyClient;
|
||||
|
||||
private MongoAgent() {
|
||||
@@ -1488,8 +1505,174 @@ public final class MongoAgent {
|
||||
MongoClient client = requireClient();
|
||||
String database = params.get("database").getAsString();
|
||||
String collection = params.get("collection").getAsString();
|
||||
client.getDatabase(database).getCollection(collection).insertMany(documents);
|
||||
return Collections.singletonMap("affected_rows", documents.size());
|
||||
InsertManyOptions options = new InsertManyOptions();
|
||||
if (params.has("ordered") && !params.get("ordered").isJsonNull()) {
|
||||
JsonElement ordered = params.get("ordered");
|
||||
if (!ordered.isJsonPrimitive() || !ordered.getAsJsonPrimitive().isBoolean()) {
|
||||
throw new IllegalArgumentException("ordered must be a boolean");
|
||||
}
|
||||
options.ordered(ordered.getAsBoolean());
|
||||
}
|
||||
try {
|
||||
client.getDatabase(database).getCollection(collection).insertMany(documents, options);
|
||||
return Collections.singletonMap("affected_rows", documents.size());
|
||||
} catch (MongoBulkWriteException error) {
|
||||
return bulkInsertResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, Object> bulkInsertResult(MongoBulkWriteException error) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("affected_rows", error.getWriteResult().getInsertedCount());
|
||||
List<Map<String, Object>> errors = new ArrayList<>();
|
||||
for (BulkWriteError writeError : error.getWriteErrors()) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("index", writeError.getIndex());
|
||||
item.put("code", writeError.getCode());
|
||||
item.put("message", writeError.getMessage());
|
||||
errors.add(item);
|
||||
}
|
||||
// The driver raises the same exception for write concern failures only (for example a
|
||||
// wtimeout): there are no per-document errors then, but the batch must not look
|
||||
// successful, so report the write concern error as a batch-wide rejection without index.
|
||||
WriteConcernError writeConcernError = error.getWriteConcernError();
|
||||
if (errors.isEmpty() && writeConcernError != null) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("code", writeConcernError.getCode());
|
||||
item.put("message", writeConcernError.getMessage());
|
||||
errors.add(item);
|
||||
}
|
||||
result.put("errors", errors);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Object startFindCursor(JsonObject params) {
|
||||
Set<String> owner = CURRENT_FIND_CURSOR_OWNER.get();
|
||||
if (owner != null && owner.size() >= MAX_FIND_CURSORS_PER_OWNER) {
|
||||
throw new IllegalStateException("MongoDB find cursor limit reached: " + MAX_FIND_CURSORS_PER_OWNER);
|
||||
}
|
||||
// The per-owner budget keeps one session from starving the others; the process-wide cap
|
||||
// only bounds how many server cursors this agent can hold in total.
|
||||
if (FIND_CURSORS.size() >= MAX_FIND_CURSORS_TOTAL) {
|
||||
throw new IllegalStateException("MongoDB find cursor limit reached: " + MAX_FIND_CURSORS_TOTAL);
|
||||
}
|
||||
MongoClient client = requireClient();
|
||||
String database = params.get("database").getAsString();
|
||||
String collection = params.get("collection").getAsString();
|
||||
Document filterDoc = documentOrNull(params, "filter");
|
||||
Document projectionDoc = documentOrNull(params, "projection");
|
||||
Document sortDoc = documentOrNull(params, "sort");
|
||||
Collation collation = collationOrNull(documentOrNull(params, "collation"));
|
||||
int batchSize = findCursorBatchSize(params);
|
||||
|
||||
FindIterable<Document> iterable = client.getDatabase(database)
|
||||
.getCollection(collection)
|
||||
.find(filterDoc == null ? new Document() : filterDoc)
|
||||
.batchSize(batchSize);
|
||||
if (projectionDoc != null) {
|
||||
iterable = iterable.projection(projectionDoc);
|
||||
}
|
||||
if (sortDoc != null) {
|
||||
iterable = iterable.sort(sortDoc);
|
||||
}
|
||||
if (collation != null) {
|
||||
iterable = iterable.collation(collation);
|
||||
}
|
||||
|
||||
String cursorId = UUID.randomUUID().toString();
|
||||
FIND_CURSORS.put(cursorId, iterable.iterator());
|
||||
if (owner != null) {
|
||||
owner.add(cursorId);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("cursor_id", cursorId);
|
||||
result.put("batch_size", batchSize);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Object fetchFindCursor(JsonObject params) {
|
||||
String cursorId = requiredCursorId(params);
|
||||
MongoCursor<Document> cursor = FIND_CURSORS.get(cursorId);
|
||||
if (cursor == null) {
|
||||
throw new IllegalStateException("Find cursor not found");
|
||||
}
|
||||
int limit = params.has("limit") ? params.get("limit").getAsInt() : DEFAULT_FIND_CURSOR_BATCH_SIZE;
|
||||
if (limit <= 0) {
|
||||
throw new IllegalArgumentException("Find cursor limit must be a positive integer");
|
||||
}
|
||||
List<JsonObject> documents = new ArrayList<>();
|
||||
try {
|
||||
while (documents.size() < limit && cursor.hasNext()) {
|
||||
documents.add(bsonToCanonicalExtendedJson(cursor.next()));
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
// A cursor that fails mid-iteration (server-side idle timeout, lost connection) can
|
||||
// never be resumed, so drop it instead of leaking a slot in the cursor budgets.
|
||||
closeFindCursorById(cursorId);
|
||||
throw error;
|
||||
}
|
||||
boolean exhausted = !cursor.hasNext();
|
||||
if (exhausted) {
|
||||
closeFindCursorById(cursorId);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("documents", documents);
|
||||
result.put("exhausted", exhausted);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Object closeFindCursor(JsonObject params) {
|
||||
closeFindCursorById(requiredCursorId(params));
|
||||
return Collections.singletonMap("ok", true);
|
||||
}
|
||||
|
||||
private static String requiredCursorId(JsonObject params) {
|
||||
String cursorId = stringOrNull(params, "cursor_id");
|
||||
if (cursorId == null || cursorId.isBlank()) {
|
||||
throw new IllegalArgumentException("cursor_id is required");
|
||||
}
|
||||
return cursorId;
|
||||
}
|
||||
|
||||
private static int findCursorBatchSize(JsonObject params) {
|
||||
if (!params.has("batch_size") || params.get("batch_size").isJsonNull()) {
|
||||
return DEFAULT_FIND_CURSOR_BATCH_SIZE;
|
||||
}
|
||||
int batchSize = params.get("batch_size").getAsInt();
|
||||
if (batchSize <= 0) {
|
||||
throw new IllegalArgumentException("batch_size must be a positive integer");
|
||||
}
|
||||
return batchSize;
|
||||
}
|
||||
|
||||
private static void closeFindCursorById(String cursorId) {
|
||||
MongoCursor<Document> cursor = FIND_CURSORS.remove(cursorId);
|
||||
if (cursor != null) {
|
||||
try {
|
||||
cursor.close();
|
||||
} catch (RuntimeException ignored) {
|
||||
// Closing a server cursor is best-effort; the client may already be gone.
|
||||
}
|
||||
}
|
||||
Set<String> owner = CURRENT_FIND_CURSOR_OWNER.get();
|
||||
if (owner != null) {
|
||||
owner.remove(cursorId);
|
||||
}
|
||||
LEGACY_FIND_CURSOR_IDS.remove(cursorId);
|
||||
}
|
||||
|
||||
private static void closeFindCursors(Set<String> cursorIds) {
|
||||
for (String cursorId : new ArrayList<>(cursorIds)) {
|
||||
closeFindCursorById(cursorId);
|
||||
}
|
||||
cursorIds.clear();
|
||||
}
|
||||
|
||||
static void resetFindCursorsForTests() {
|
||||
for (String cursorId : new ArrayList<>(FIND_CURSORS.keySet())) {
|
||||
closeFindCursorById(cursorId);
|
||||
}
|
||||
LEGACY_FIND_CURSOR_IDS.clear();
|
||||
}
|
||||
|
||||
static Object parseId(String id) {
|
||||
@@ -1609,10 +1792,17 @@ public final class MongoAgent {
|
||||
}
|
||||
Document options = Document.parse(optionsJson);
|
||||
for (String key : options.keySet()) {
|
||||
if (!"arrayFilters".equals(key)) {
|
||||
if (!"arrayFilters".equals(key) && !"upsert".equals(key)) {
|
||||
throw new IllegalArgumentException("Unsupported update option: " + key);
|
||||
}
|
||||
}
|
||||
Object rawUpsert = options.get("upsert");
|
||||
if (rawUpsert != null) {
|
||||
if (!(rawUpsert instanceof Boolean)) {
|
||||
throw new IllegalArgumentException("upsert must be a boolean");
|
||||
}
|
||||
result.upsert((Boolean) rawUpsert);
|
||||
}
|
||||
Object rawFilters = options.get("arrayFilters");
|
||||
if (rawFilters == null) {
|
||||
return result;
|
||||
@@ -1675,6 +1865,56 @@ public final class MongoAgent {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Object replaceDocument(JsonObject params) {
|
||||
MongoClient c = requireClient();
|
||||
String database = params.get("database").getAsString();
|
||||
String collection = params.get("collection").getAsString();
|
||||
String filterJson = params.get("filter_json").getAsString();
|
||||
String replacementJson = params.get("replacement_json").getAsString();
|
||||
String optionsJson = params.has("options_json") && !params.get("options_json").isJsonNull()
|
||||
? params.get("options_json").getAsString()
|
||||
: null;
|
||||
|
||||
var col = c.getDatabase(database).getCollection(collection);
|
||||
Document filter = documentForWrite(filterJson);
|
||||
Document replacement = documentForWrite(replacementJson);
|
||||
requireReplacementDocument(replacement);
|
||||
ReplaceOptions options = replaceOptionsForWrite(optionsJson);
|
||||
var result = col.replaceOne(filter, replacement, options);
|
||||
return Collections.singletonMap("modified_count", result.getModifiedCount());
|
||||
}
|
||||
|
||||
/** replaceOne swaps the whole document; update operators here mean updateOne was intended. */
|
||||
static void requireReplacementDocument(Document doc) {
|
||||
for (String key : doc.keySet()) {
|
||||
if (key.startsWith("$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Replacement document must not contain update operators such as " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ReplaceOptions replaceOptionsForWrite(String optionsJson) {
|
||||
ReplaceOptions result = new ReplaceOptions();
|
||||
if (optionsJson == null || optionsJson.trim().isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Document options = Document.parse(optionsJson);
|
||||
for (String key : options.keySet()) {
|
||||
if (!"upsert".equals(key)) {
|
||||
throw new IllegalArgumentException("Unsupported replace option: " + key);
|
||||
}
|
||||
}
|
||||
Object rawUpsert = options.get("upsert");
|
||||
if (rawUpsert != null) {
|
||||
if (!(rawUpsert instanceof Boolean)) {
|
||||
throw new IllegalArgumentException("upsert must be a boolean");
|
||||
}
|
||||
result.upsert((Boolean) rawUpsert);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void requireBulkUpdateOperatorDocument(Document doc) {
|
||||
if (!isUpdateOperatorDocument(doc)) {
|
||||
// updateOne/updateMany are shell-style bulk updates here; replacements stay on the
|
||||
@@ -1729,6 +1969,10 @@ public final class MongoAgent {
|
||||
return preserveUnsafeLongsForJsonClients(doc, relaxed).getAsJsonObject();
|
||||
}
|
||||
|
||||
static JsonObject bsonToCanonicalExtendedJson(Document doc) {
|
||||
return JsonParser.parseString(doc.toJson(CANONICAL_JSON_SETTINGS)).getAsJsonObject();
|
||||
}
|
||||
|
||||
private static JsonElement preserveUnsafeLongsForJsonClients(Object bsonValue, JsonElement relaxedValue) {
|
||||
if (
|
||||
bsonValue instanceof Long longValue &&
|
||||
@@ -1856,12 +2100,17 @@ public final class MongoAgent {
|
||||
case AgentProtocol.MONGO_METHOD_DROP_DATABASE -> dropDatabase(params);
|
||||
case AgentProtocol.MONGO_METHOD_INSERT_DOCUMENT -> insertDocument(params);
|
||||
case AgentProtocol.MONGO_METHOD_INSERT_DOCUMENTS -> insertDocuments(params);
|
||||
case AgentProtocol.MONGO_METHOD_START_FIND_CURSOR -> startFindCursor(params);
|
||||
case AgentProtocol.MONGO_METHOD_FETCH_FIND_CURSOR -> fetchFindCursor(params);
|
||||
case AgentProtocol.MONGO_METHOD_CLOSE_FIND_CURSOR -> closeFindCursor(params);
|
||||
case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENT -> updateDocument(params);
|
||||
case AgentProtocol.MONGO_METHOD_UPDATE_DOCUMENTS -> updateDocuments(params);
|
||||
case AgentProtocol.MONGO_METHOD_REPLACE_DOCUMENT -> replaceDocument(params);
|
||||
case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENT -> deleteDocument(params);
|
||||
case AgentProtocol.MONGO_METHOD_DELETE_DOCUMENTS -> deleteDocuments(params);
|
||||
case AgentProtocol.MONGO_METHOD_RUN_COMMAND -> runCommand(params);
|
||||
case AgentProtocol.METHOD_DISCONNECT, AgentProtocol.METHOD_SHUTDOWN -> {
|
||||
closeFindCursors(LEGACY_FIND_CURSOR_IDS);
|
||||
closeLegacyClient();
|
||||
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
|
||||
System.exit(0);
|
||||
@@ -1909,6 +2158,7 @@ public final class MongoAgent {
|
||||
static String handleRequest(String line, MongoClient client) {
|
||||
if (client != null) {
|
||||
CURRENT_CLIENT.set(client);
|
||||
CURRENT_FIND_CURSOR_OWNER.set(LEGACY_FIND_CURSOR_IDS);
|
||||
}
|
||||
try {
|
||||
JsonObject req = JsonParser.parseString(line).getAsJsonObject();
|
||||
@@ -1936,6 +2186,7 @@ public final class MongoAgent {
|
||||
} finally {
|
||||
if (client != null) {
|
||||
CURRENT_CLIENT.remove();
|
||||
CURRENT_FIND_CURSOR_OWNER.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2061,6 +2312,7 @@ public final class MongoAgent {
|
||||
|
||||
private static final class Session {
|
||||
private final MongoClient client;
|
||||
private final Set<String> findCursorIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private Session(MongoClient client) {
|
||||
this.client = client;
|
||||
@@ -2068,10 +2320,12 @@ public final class MongoAgent {
|
||||
|
||||
private synchronized Object handle(String method, JsonObject params) {
|
||||
CURRENT_CLIENT.set(client);
|
||||
CURRENT_FIND_CURSOR_OWNER.set(findCursorIds);
|
||||
try {
|
||||
return dispatch(method, params);
|
||||
} finally {
|
||||
CURRENT_CLIENT.remove();
|
||||
CURRENT_FIND_CURSOR_OWNER.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2085,6 +2339,7 @@ public final class MongoAgent {
|
||||
}
|
||||
|
||||
private synchronized void close() {
|
||||
closeFindCursors(findCursorIds);
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.dbx.agent.mongodb;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -12,7 +13,12 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mongodb.MongoBulkWriteException;
|
||||
import com.mongodb.MongoClientSettings;
|
||||
import com.mongodb.ServerAddress;
|
||||
import com.mongodb.bulk.BulkWriteError;
|
||||
import com.mongodb.bulk.BulkWriteResult;
|
||||
import com.mongodb.bulk.WriteConcernError;
|
||||
import com.mongodb.client.AggregateIterable;
|
||||
import com.mongodb.client.FindIterable;
|
||||
import com.mongodb.client.ListCollectionsIterable;
|
||||
@@ -40,8 +46,12 @@ import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bson.BsonDocument;
|
||||
import org.bson.BsonRegularExpression;
|
||||
import org.bson.Document;
|
||||
import org.bson.types.Binary;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -99,6 +109,11 @@ class MongoAgentTest {
|
||||
Files.writeString(clientKeyPath, pkcs8Pem);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void resetFindCursors() {
|
||||
MongoAgent.resetFindCursorsForTests();
|
||||
}
|
||||
|
||||
// ─── existing tests ───
|
||||
|
||||
@Test
|
||||
@@ -128,6 +143,7 @@ class MongoAgentTest {
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_CLONE_COLLECTION));
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_RUN_COMMAND));
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_INSERT_DOCUMENTS));
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_FIND_CURSOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -150,6 +166,7 @@ class MongoAgentTest {
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_CLONE_COLLECTION));
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_RUN_COMMAND));
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_INSERT_DOCUMENTS));
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), AgentProtocol.CAPABILITY_MONGO_FIND_CURSOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -894,6 +911,10 @@ class MongoAgentTest {
|
||||
assertEquals("12", batches.get(0).get(1).getString("refid"));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_INSERT_DOCUMENTS));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_INSERT_DOCUMENTS));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_FIND_CURSOR));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_START_FIND_CURSOR));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_FETCH_FIND_CURSOR));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_CLOSE_FIND_CURSOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -939,6 +960,293 @@ class MongoAgentTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentForWritePreservesExtendedJsonBsonTypes() {
|
||||
Document document = MongoAgent.documentForWrite(
|
||||
"{\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},"
|
||||
+ "\"count\":{\"$numberLong\":\"9007199254740993\"},"
|
||||
+ "\"when\":{\"$date\":\"2020-01-02T03:04:05.000Z\"}}"
|
||||
);
|
||||
|
||||
assertEquals(new ObjectId("507f1f77bcf86cd799439011"), document.getObjectId("_id"));
|
||||
assertEquals(9007199254740993L, document.get("count"));
|
||||
assertTrue(document.get("when") instanceof Date);
|
||||
assertEquals(Date.from(java.time.Instant.parse("2020-01-02T03:04:05.000Z")), document.getDate("when"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertDocumentsRpcHonoursExplicitUnorderedInserts() {
|
||||
List<List<Document>> batches = new ArrayList<>();
|
||||
List<String> calls = new ArrayList<>();
|
||||
List<Boolean> ordered = new ArrayList<>();
|
||||
MongoClient client = recordingInsertMongoClient(calls, batches, ordered);
|
||||
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("database", "app");
|
||||
params.addProperty("collection", "users");
|
||||
params.addProperty("docs_json", "[{\"name\":\"Ada\"}]");
|
||||
params.addProperty("ordered", false);
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("jsonrpc", "2.0");
|
||||
request.addProperty("id", 44);
|
||||
request.addProperty("method", "insert_documents");
|
||||
request.add("params", params);
|
||||
|
||||
JsonObject response = JsonParser.parseString(MongoAgent.handleRequest(request.toString(), client)).getAsJsonObject();
|
||||
|
||||
assertFalse(response.has("error"), response.toString());
|
||||
assertEquals(List.of(false), ordered);
|
||||
assertEquals(1, response.getAsJsonObject("result").get("affected_rows").getAsInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertDocumentsRpcPreservesExtendedJsonTypesInTheInsertManyBatch() {
|
||||
List<List<Document>> batches = new ArrayList<>();
|
||||
List<String> calls = new ArrayList<>();
|
||||
MongoClient client = recordingInsertMongoClient(calls, batches);
|
||||
String documents = "[{\"_id\":{\"$oid\":\"507f1f77bcf86cd799439011\"},"
|
||||
+ "\"count\":{\"$numberLong\":\"9007199254740993\"}}]";
|
||||
|
||||
JsonObject response = insertDocumentsRpc(client, 45, "users", documents);
|
||||
|
||||
assertFalse(response.has("error"), response.toString());
|
||||
assertEquals(1, batches.size());
|
||||
Document written = batches.get(0).get(0);
|
||||
assertEquals(new ObjectId("507f1f77bcf86cd799439011"), written.getObjectId("_id"));
|
||||
assertEquals(9007199254740993L, written.get("count"));
|
||||
assertTrue(written.get("count") instanceof Long);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertDocumentsRpcParsesCanonicalBinaryAndRegularExpression() {
|
||||
List<List<Document>> batches = new ArrayList<>();
|
||||
MongoClient client = recordingInsertMongoClient(new ArrayList<>(), batches);
|
||||
String documents = "[{\"bin\":{\"$binary\":{\"base64\":\"AQID\",\"subType\":\"00\"}},"
|
||||
+ "\"re\":{\"$regularExpression\":{\"pattern\":\"^test$\",\"options\":\"i\"}}}]";
|
||||
|
||||
JsonObject response = insertDocumentsRpc(client, 46, "users", documents);
|
||||
|
||||
assertFalse(response.has("error"), response.toString());
|
||||
Document written = batches.get(0).get(0);
|
||||
assertTrue(written.get("bin") instanceof Binary, String.valueOf(written.get("bin")));
|
||||
Binary binary = (Binary) written.get("bin");
|
||||
assertEquals(0, binary.getType());
|
||||
assertArrayEquals(new byte[] {1, 2, 3}, binary.getData());
|
||||
assertTrue(written.get("re") instanceof BsonRegularExpression, String.valueOf(written.get("re")));
|
||||
BsonRegularExpression regex = (BsonRegularExpression) written.get("re");
|
||||
assertEquals("^test$", regex.getPattern());
|
||||
assertEquals("i", regex.getOptions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkInsertResultKeepsInsertedCountAndPerDocumentErrors() {
|
||||
BulkWriteResult writeResult = BulkWriteResult.acknowledged(1, 0, 0, 0, List.of());
|
||||
MongoBulkWriteException error = new MongoBulkWriteException(
|
||||
writeResult,
|
||||
List.of(new BulkWriteError(11000, "E11000 duplicate key", new BsonDocument(), 1)),
|
||||
null,
|
||||
new ServerAddress()
|
||||
);
|
||||
|
||||
Map<String, Object> result = MongoAgent.bulkInsertResult(error);
|
||||
|
||||
assertEquals(1, result.get("affected_rows"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> errors = (List<Map<String, Object>>) result.get("errors");
|
||||
assertEquals(1, errors.size());
|
||||
assertEquals(1, errors.get(0).get("index"));
|
||||
assertEquals(11000, errors.get(0).get("code"));
|
||||
assertEquals("E11000 duplicate key", errors.get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkInsertResultReportsWriteConcernFailures() {
|
||||
// The driver raises MongoBulkWriteException with no write errors when only the write
|
||||
// concern failed, so the batch must not come back looking successful.
|
||||
MongoBulkWriteException error = new MongoBulkWriteException(
|
||||
BulkWriteResult.acknowledged(0, 0, 0, 0, List.of()),
|
||||
List.of(),
|
||||
new WriteConcernError(64, "WriteConcernFailed", "waiting for replication timed out", new BsonDocument()),
|
||||
new ServerAddress()
|
||||
);
|
||||
|
||||
Map<String, Object> result = MongoAgent.bulkInsertResult(error);
|
||||
|
||||
assertEquals(0, result.get("affected_rows"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> errors = (List<Map<String, Object>>) result.get("errors");
|
||||
assertEquals(1, errors.size());
|
||||
assertEquals(64, errors.get(0).get("code"));
|
||||
assertEquals("waiting for replication timed out", errors.get(0).get("message"));
|
||||
assertFalse(errors.get(0).containsKey("index"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findCursorRpcPagesExtendedJsonDocumentsThenExhausts() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
List<Document> documents = List.of(
|
||||
new Document("_id", new ObjectId("507f1f77bcf86cd799439011"))
|
||||
.append("n", 1L)
|
||||
.append("bin", new Binary((byte) 0, new byte[] {1, 2, 3}))
|
||||
.append("re", new BsonRegularExpression("^test$", "i")),
|
||||
new Document("_id", new ObjectId("507f1f77bcf86cd799439012")).append("n", 2L)
|
||||
);
|
||||
MongoClient client = recordingFindMongoClient(calls, documents);
|
||||
|
||||
JsonObject start = findCursorRpc(client, 50, "start_find_cursor", cursorParams("users", 1, null));
|
||||
assertFalse(start.has("error"), start.toString());
|
||||
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
|
||||
assertEquals(1, start.getAsJsonObject("result").get("batch_size").getAsInt());
|
||||
|
||||
JsonObject first = findCursorRpc(client, 51, "fetch_find_cursor", cursorParams(null, 0, cursorId));
|
||||
assertFalse(first.has("error"), first.toString());
|
||||
assertEquals(1, first.getAsJsonObject("result").getAsJsonArray("documents").size());
|
||||
assertFalse(first.getAsJsonObject("result").get("exhausted").getAsBoolean());
|
||||
JsonObject firstDocument = first.getAsJsonObject("result").getAsJsonArray("documents").get(0).getAsJsonObject();
|
||||
assertEquals(
|
||||
"507f1f77bcf86cd799439011",
|
||||
firstDocument.getAsJsonObject("_id").get("$oid").getAsString()
|
||||
);
|
||||
assertEquals("1", firstDocument.getAsJsonObject("n").get("$numberLong").getAsString());
|
||||
JsonObject binary = firstDocument.getAsJsonObject("bin").getAsJsonObject("$binary");
|
||||
assertEquals("AQID", binary.get("base64").getAsString(), firstDocument.toString());
|
||||
assertEquals("00", binary.get("subType").getAsString(), firstDocument.toString());
|
||||
JsonObject regex = firstDocument.getAsJsonObject("re").getAsJsonObject("$regularExpression");
|
||||
assertEquals("^test$", regex.get("pattern").getAsString(), firstDocument.toString());
|
||||
assertEquals("i", regex.get("options").getAsString(), firstDocument.toString());
|
||||
|
||||
JsonObject second = findCursorRpc(client, 52, "fetch_find_cursor", cursorParams(null, 0, cursorId));
|
||||
assertFalse(second.has("error"), second.toString());
|
||||
assertEquals(1, second.getAsJsonObject("result").getAsJsonArray("documents").size());
|
||||
assertTrue(second.getAsJsonObject("result").get("exhausted").getAsBoolean());
|
||||
|
||||
JsonObject missing = findCursorRpc(client, 53, "fetch_find_cursor", cursorParams(null, 0, cursorId));
|
||||
assertTrue(missing.has("error"), missing.toString());
|
||||
assertEquals("Find cursor not found", missing.getAsJsonObject("error").get("message").getAsString());
|
||||
assertTrue(calls.contains("find"));
|
||||
assertTrue(calls.contains("batchSize:1"));
|
||||
assertTrue(calls.contains("iterator"));
|
||||
assertTrue(calls.contains("close"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeFindCursorRpcIsIdempotent() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
MongoClient client = recordingFindMongoClient(calls, List.of(new Document("n", 1)));
|
||||
JsonObject start = findCursorRpc(client, 54, "start_find_cursor", cursorParams("users", 10, null));
|
||||
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
|
||||
|
||||
JsonObject closed = findCursorRpc(client, 55, "close_find_cursor", cursorParams(null, 0, cursorId));
|
||||
JsonObject again = findCursorRpc(client, 56, "close_find_cursor", cursorParams(null, 0, cursorId));
|
||||
|
||||
assertFalse(closed.has("error"), closed.toString());
|
||||
assertFalse(again.has("error"), again.toString());
|
||||
assertEquals(1, calls.stream().filter("close"::equals).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bsonToCanonicalExtendedJsonKeepsInt64DistinctFromJsonNumber() {
|
||||
JsonObject json = MongoAgent.bsonToCanonicalExtendedJson(
|
||||
new Document("n", 5L)
|
||||
.append("bin", new Binary((byte) 0, new byte[] {1, 2, 3}))
|
||||
.append("re", new BsonRegularExpression("^test$", "i"))
|
||||
);
|
||||
|
||||
assertEquals("5", json.getAsJsonObject("n").get("$numberLong").getAsString());
|
||||
assertFalse(json.get("n").isJsonPrimitive(), json.toString());
|
||||
JsonObject binary = json.getAsJsonObject("bin").getAsJsonObject("$binary");
|
||||
assertEquals("AQID", binary.get("base64").getAsString(), json.toString());
|
||||
assertEquals("00", binary.get("subType").getAsString(), json.toString());
|
||||
assertFalse(json.getAsJsonObject("bin").has("$type"), json.toString());
|
||||
JsonObject regex = json.getAsJsonObject("re").getAsJsonObject("$regularExpression");
|
||||
assertEquals("^test$", regex.get("pattern").getAsString(), json.toString());
|
||||
assertEquals("i", regex.get("options").getAsString(), json.toString());
|
||||
assertFalse(json.getAsJsonObject("re").has("$regex"), json.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disconnectClosesOpenFindCursors() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
MongoClient client = recordingFindMongoClient(calls, List.of(new Document("n", 1L)));
|
||||
JsonObject start = findCursorRpc(client, 57, "start_find_cursor", cursorParams("users", 10, null));
|
||||
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
|
||||
|
||||
JsonObject disconnect = JsonParser.parseString(MongoAgent.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":58,\"method\":\"disconnect\",\"params\":{}}"
|
||||
)).getAsJsonObject();
|
||||
JsonObject fetch = findCursorRpc(client, 59, "fetch_find_cursor", cursorParams(null, 0, cursorId));
|
||||
|
||||
assertFalse(disconnect.has("error"), disconnect.toString());
|
||||
assertTrue(fetch.has("error"), fetch.toString());
|
||||
assertEquals("Find cursor not found", fetch.getAsJsonObject("error").get("message").getAsString());
|
||||
assertTrue(calls.contains("close"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchFailureReleasesTheCursorSlot() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
MongoCursor<Document> failing = new MongoCursor<>() {
|
||||
@Override
|
||||
public void close() {
|
||||
calls.add("close");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
throw new IllegalStateException("cursor id 42 not found");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Document next() {
|
||||
throw new IllegalStateException("cursor id 42 not found");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Document tryNext() {
|
||||
throw new IllegalStateException("cursor id 42 not found");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerAddress getServerAddress() {
|
||||
return new ServerAddress();
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.mongodb.ServerCursor getServerCursor() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
MongoClient client = findMongoClientWithCursor(calls, failing);
|
||||
|
||||
JsonObject start = findCursorRpc(client, 60, "start_find_cursor", cursorParams("users", 10, null));
|
||||
String cursorId = start.getAsJsonObject("result").get("cursor_id").getAsString();
|
||||
JsonObject fetch = findCursorRpc(client, 61, "fetch_find_cursor", cursorParams(null, 0, cursorId));
|
||||
JsonObject again = findCursorRpc(client, 62, "fetch_find_cursor", cursorParams(null, 0, cursorId));
|
||||
|
||||
assertTrue(fetch.has("error"), fetch.toString());
|
||||
assertTrue(calls.contains("close"));
|
||||
// A dead cursor is dropped, so the slot is free again instead of being held until disconnect.
|
||||
assertEquals("Find cursor not found", again.getAsJsonObject("error").get("message").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startFindCursorEnforcesPerOwnerBudget() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
MongoClient client = recordingFindMongoClient(calls, List.of(new Document("n", 1)));
|
||||
|
||||
for (int index = 0; index < 16; index++) {
|
||||
JsonObject started = findCursorRpc(client, 100 + index, "start_find_cursor", cursorParams("users", 100, null));
|
||||
assertFalse(started.has("error"), started.toString());
|
||||
}
|
||||
JsonObject rejected = findCursorRpc(client, 200, "start_find_cursor", cursorParams("users", 100, null));
|
||||
|
||||
assertTrue(rejected.has("error"), rejected.toString());
|
||||
assertEquals(
|
||||
"MongoDB find cursor limit reached: 16",
|
||||
rejected.getAsJsonObject("error").get("message").getAsString()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocumentsRpcUsesDocumentOverloads() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
@@ -981,13 +1289,83 @@ class MongoAgentTest {
|
||||
assertEquals(322678, ((Document) options.getArrayFilters().get(0)).getInteger("item.id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesUpsertUpdateOption() {
|
||||
assertTrue(MongoAgent.updateOptionsForWrite("{\"upsert\":true}").isUpsert());
|
||||
assertFalse(MongoAgent.updateOptionsForWrite("{\"upsert\":false}").isUpsert());
|
||||
assertFalse(MongoAgent.updateOptionsForWrite("{}").isUpsert());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesUpsertAlongsideArrayFilters() {
|
||||
UpdateOptions options = MongoAgent.updateOptionsForWrite(
|
||||
"{\"upsert\":true,\"arrayFilters\":[{\"item.id\":322678}]}"
|
||||
);
|
||||
|
||||
assertTrue(options.isUpsert());
|
||||
assertEquals(1, options.getArrayFilters().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonBooleanUpsertUpdateOption() {
|
||||
IllegalArgumentException error = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> MongoAgent.updateOptionsForWrite("{\"upsert\":\"yes\"}")
|
||||
);
|
||||
assertEquals("upsert must be a boolean", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceDocumentMethodIsRecognizedOverJsonRpc() {
|
||||
String response = MongoAgent.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"replace_document\","
|
||||
+ "\"params\":{\"database\":\"app\",\"collection\":\"orders\",\"filter_json\":\"{\\\"_id\\\":1}\","
|
||||
+ "\"replacement_json\":\"{\\\"name\\\":\\\"new\\\"}\"}}");
|
||||
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
assertEquals(11, json.get("id").getAsInt());
|
||||
assertEquals("Not connected", json.getAsJsonObject("error").get("message").getAsString());
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_METHODS.contains(AgentProtocol.MONGO_METHOD_REPLACE_DOCUMENT));
|
||||
assertTrue(AgentProtocol.MONGO_LEGACY_CAPABILITIES.contains(AgentProtocol.CAPABILITY_MONGO_REPLACE_DOCUMENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesReplaceOptions() {
|
||||
assertTrue(MongoAgent.replaceOptionsForWrite("{\"upsert\":true}").isUpsert());
|
||||
assertFalse(MongoAgent.replaceOptionsForWrite("{\"upsert\":false}").isUpsert());
|
||||
assertFalse(MongoAgent.replaceOptionsForWrite(null).isUpsert());
|
||||
assertFalse(MongoAgent.replaceOptionsForWrite("{}").isUpsert());
|
||||
|
||||
IllegalArgumentException unsupported = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> MongoAgent.replaceOptionsForWrite("{\"arrayFilters\":[]}")
|
||||
);
|
||||
assertEquals("Unsupported replace option: arrayFilters", unsupported.getMessage());
|
||||
IllegalArgumentException notBoolean = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> MongoAgent.replaceOptionsForWrite("{\"upsert\":\"yes\"}")
|
||||
);
|
||||
assertEquals("upsert must be a boolean", notBoolean.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReplacementDocumentsWithUpdateOperators() {
|
||||
IllegalArgumentException error = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> MongoAgent.requireReplacementDocument(Document.parse("{\"$set\":{\"a\":1}}"))
|
||||
);
|
||||
assertEquals("Replacement document must not contain update operators such as $set", error.getMessage());
|
||||
// A plain document, including one with nested `$`-keys inside values, is fine.
|
||||
MongoAgent.requireReplacementDocument(Document.parse("{\"name\":\"new\",\"meta\":{\"$ref\":\"x\"}}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedUpdateOptions() {
|
||||
IllegalArgumentException error = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> MongoAgent.updateOptionsForWrite("{\"upsert\":true}")
|
||||
() -> MongoAgent.updateOptionsForWrite("{\"collation\":{\"locale\":\"en\"}}")
|
||||
);
|
||||
assertEquals("Unsupported update option: upsert", error.getMessage());
|
||||
assertEquals("Unsupported update option: collation", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1388,9 +1766,140 @@ class MongoAgentTest {
|
||||
return JsonParser.parseString(MongoAgent.handleRequest(request.toString(), client)).getAsJsonObject();
|
||||
}
|
||||
|
||||
private static JsonObject cursorParams(String collection, int batchSize, String cursorId) {
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("database", "app");
|
||||
if (collection != null) {
|
||||
params.addProperty("collection", collection);
|
||||
}
|
||||
if (batchSize > 0) {
|
||||
params.addProperty("batch_size", batchSize);
|
||||
}
|
||||
if (cursorId != null) {
|
||||
params.addProperty("cursor_id", cursorId);
|
||||
params.addProperty("limit", 1);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private static JsonObject findCursorRpc(MongoClient client, int id, String method, JsonObject params) {
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("jsonrpc", "2.0");
|
||||
request.addProperty("id", id);
|
||||
request.addProperty("method", method);
|
||||
request.add("params", params);
|
||||
return JsonParser.parseString(MongoAgent.handleRequest(request.toString(), client)).getAsJsonObject();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MongoClient recordingFindMongoClient(List<String> calls, List<Document> documents) {
|
||||
MongoCursor<Document> cursor = new MongoCursor<>() {
|
||||
private int index;
|
||||
private boolean closed;
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
calls.add("close");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return !closed && index < documents.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Document next() {
|
||||
return documents.get(index++);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Document tryNext() {
|
||||
return hasNext() ? next() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.mongodb.ServerAddress getServerAddress() {
|
||||
return new com.mongodb.ServerAddress();
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.mongodb.ServerCursor getServerCursor() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return findMongoClientWithCursor(calls, cursor);
|
||||
}
|
||||
|
||||
/** Same proxy chain as [recordingFindMongoClient], for cursors that fail mid-iteration. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MongoClient findMongoClientWithCursor(List<String> calls, MongoCursor<Document> cursor) {
|
||||
FindIterable<Document> iterable = (FindIterable<Document>) Proxy.newProxyInstance(
|
||||
FindIterable.class.getClassLoader(),
|
||||
new Class<?>[] {FindIterable.class},
|
||||
(proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "batchSize" -> {
|
||||
calls.add("batchSize:" + args[0]);
|
||||
return proxy;
|
||||
}
|
||||
case "projection", "sort", "collation", "filter", "skip", "limit" -> {
|
||||
calls.add(method.getName());
|
||||
return proxy;
|
||||
}
|
||||
case "iterator" -> {
|
||||
calls.add("iterator");
|
||||
return cursor;
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
}
|
||||
);
|
||||
MongoCollection<Document> collection = (MongoCollection<Document>) Proxy.newProxyInstance(
|
||||
MongoCollection.class.getClassLoader(),
|
||||
new Class<?>[] {MongoCollection.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("find".equals(method.getName())) {
|
||||
calls.add("find");
|
||||
return iterable;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
);
|
||||
MongoDatabase database = (MongoDatabase) Proxy.newProxyInstance(
|
||||
MongoDatabase.class.getClassLoader(),
|
||||
new Class<?>[] {MongoDatabase.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getCollection".equals(method.getName())) {
|
||||
calls.add("getCollection:" + args[0]);
|
||||
return collection;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
);
|
||||
return (MongoClient) Proxy.newProxyInstance(
|
||||
MongoClient.class.getClassLoader(),
|
||||
new Class<?>[] {MongoClient.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getDatabase".equals(method.getName())) {
|
||||
calls.add("getDatabase:" + args[0]);
|
||||
return database;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MongoClient recordingInsertMongoClient(
|
||||
List<String> calls, List<List<Document>> batches
|
||||
) {
|
||||
return recordingInsertMongoClient(calls, batches, null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MongoClient recordingInsertMongoClient(
|
||||
List<String> calls, List<List<Document>> batches, List<Boolean> ordered
|
||||
) {
|
||||
MongoCollection<Document> collection = (MongoCollection<Document>) Proxy.newProxyInstance(
|
||||
MongoCollection.class.getClassLoader(),
|
||||
@@ -1400,6 +1909,13 @@ class MongoAgentTest {
|
||||
List<Document> batch = ((List<Document>) args[0]).stream().map(Document::new).toList();
|
||||
batches.add(batch);
|
||||
calls.add("insertMany:" + batch.size());
|
||||
if (ordered != null) {
|
||||
boolean isOrdered = true;
|
||||
if (args.length > 1 && args[1] instanceof InsertManyOptions options) {
|
||||
isOrdered = options.isOrdered();
|
||||
}
|
||||
ordered.add(isOrdered);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
|
||||
+136
-1
@@ -14,6 +14,7 @@ import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.OracleObjectPrivilege;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
|
||||
@@ -556,7 +557,141 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
// Table comment is optional; DDL generation should still succeed without it.
|
||||
}
|
||||
|
||||
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, java.util.Collections.emptyList(), false, true, tableComment);
|
||||
String ddl = DdlBuilder.buildTableDdl(
|
||||
schema,
|
||||
table,
|
||||
getColumns(schema, table),
|
||||
indexes,
|
||||
foreignKeys,
|
||||
java.util.Collections.emptyList(),
|
||||
false,
|
||||
true,
|
||||
tableComment
|
||||
);
|
||||
try {
|
||||
String owner = normalizeSchema(schema);
|
||||
return DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table));
|
||||
} catch (RuntimeException | SQLException ignored) {
|
||||
// Privilege metadata is optional; CREATE TABLE DDL should still succeed without it.
|
||||
return ddl;
|
||||
}
|
||||
}
|
||||
|
||||
private String queryObjectGrantSql(String owner, String table) throws SQLException {
|
||||
List<OracleObjectPrivilege> privileges = new ArrayList<>();
|
||||
privileges.addAll(queryTablePrivileges(owner, table));
|
||||
try {
|
||||
privileges.addAll(queryColumnPrivileges(owner, table));
|
||||
} catch (SQLException ignored) {
|
||||
// Column privileges are optional when table-level grants are available.
|
||||
}
|
||||
return DdlBuilder.buildOracleObjectGrantSql(owner, table, privileges);
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryTablePrivileges(String owner, String table) throws SQLException {
|
||||
// DBA_TAB_PRIVS shows every object grant (OWNER column). ALL_TAB_PRIVS is limited to
|
||||
// grants where the session user is owner/grantor/grantee (or PUBLIC), so admins
|
||||
// browsing another schema often see nothing unless we prefer the DBA view.
|
||||
return queryFirstAvailablePrivileges(tablePrivilegeQueries(), owner, table, null);
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryColumnPrivileges(String owner, String table) throws SQLException {
|
||||
return queryFirstAvailablePrivileges(columnPrivilegeQueries(), owner, table, "COLUMN_NAME");
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryFirstAvailablePrivileges(
|
||||
List<String> queries,
|
||||
String owner,
|
||||
String table,
|
||||
String columnNameField
|
||||
) throws SQLException {
|
||||
SQLException firstError = null;
|
||||
for (String sql : queries) {
|
||||
try {
|
||||
return queryPrivileges(sql, owner, table, columnNameField);
|
||||
} catch (SQLException error) {
|
||||
if (firstError == null) {
|
||||
firstError = error;
|
||||
} else {
|
||||
firstError.addSuppressed(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw firstError == null ? new SQLException("No privilege views available") : firstError;
|
||||
}
|
||||
|
||||
private static List<String> tablePrivilegeQueries() {
|
||||
return List.of(
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE
|
||||
FROM DBA_TAB_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE
|
||||
FROM SYS.DBA_TAB_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE
|
||||
FROM ALL_TAB_PRIVS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, PRIVILEGE
|
||||
""".stripIndent().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private static List<String> columnPrivilegeQueries() {
|
||||
return List.of(
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
|
||||
FROM DBA_COL_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
|
||||
FROM SYS.DBA_COL_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
|
||||
FROM ALL_COL_PRIVS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
|
||||
""".stripIndent().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryPrivileges(
|
||||
String sql,
|
||||
String owner,
|
||||
String table,
|
||||
String columnNameField
|
||||
) throws SQLException {
|
||||
List<OracleObjectPrivilege> result = new ArrayList<>();
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new OracleObjectPrivilege(
|
||||
rs.getString("GRANTEE"),
|
||||
rs.getString("PRIVILEGE"),
|
||||
isYes(rs.getString("GRANTABLE")),
|
||||
columnNameField == null ? null : rs.getString(columnNameField)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isYes(String value) {
|
||||
return value != null && "YES".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+310
-1
@@ -150,6 +150,46 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertEquals(List.of(), queryTimeouts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsCommitAndRollbackForInteractiveTransactions() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, transactionConnection(calls));
|
||||
|
||||
agent.beginManualTransaction(null);
|
||||
Assertions.assertEquals(List.of("setAutoCommit:false"), calls);
|
||||
agent.commitManualTransaction();
|
||||
Assertions.assertEquals(List.of("setAutoCommit:false", "commit", "setAutoCommit:true"), calls);
|
||||
|
||||
agent.beginManualTransaction(null);
|
||||
agent.rollbackManualTransaction();
|
||||
Assertions.assertEquals(
|
||||
List.of("setAutoCommit:false", "commit", "setAutoCommit:true", "setAutoCommit:false", "rollback", "setAutoCommit:true"),
|
||||
calls
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCommitKeepsTransactionOpenSoItCanBeRolledBack() throws SQLException {
|
||||
List<String> calls = new ArrayList<>();
|
||||
boolean[] failCommit = {true};
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
Connection connection = transactionConnection(calls, failCommit);
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
|
||||
agent.beginManualTransaction(null);
|
||||
Assertions.assertThrows(RuntimeException.class, agent::commitManualTransaction);
|
||||
Assertions.assertFalse(connection.getAutoCommit());
|
||||
|
||||
failCommit[0] = false;
|
||||
Assertions.assertDoesNotThrow(agent::rollbackManualTransaction);
|
||||
Assertions.assertTrue(connection.getAutoCommit());
|
||||
Assertions.assertEquals(
|
||||
List.of("setAutoCommit:false", "commit", "rollback", "setAutoCommit:true"),
|
||||
calls
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void synchronizesSessionTimeoutForEveryQueryEntryPoint() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
@@ -439,7 +479,15 @@ class OceanBaseOracleAgentTest {
|
||||
columnResultSet(new Object[][]{
|
||||
{"CREATED_AT", "TIMESTAMP", "N", null, null, null, null, "SYSDATE", "Created timestamp", 0},
|
||||
{"INTERNAL_NOTE", "VARCHAR2", "Y", null, null, 100, 100, null, " ", 0}
|
||||
})
|
||||
}),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{}
|
||||
)
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "AUDIT_LOG");
|
||||
@@ -451,6 +499,147 @@ class OceanBaseOracleAgentTest {
|
||||
);
|
||||
Assertions.assertTrue(ddl.contains("\"INTERNAL_NOTE\" VARCHAR2(100)"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("\"INTERNAL_NOTE\" IS"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("GRANT "), ddl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlAppendsObjectGrantsFromDictionaryViews() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql,
|
||||
resultSet(
|
||||
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"COMMENTS"},
|
||||
new Object[][]{{null}}
|
||||
),
|
||||
columnResultSet(new Object[][]{
|
||||
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1},
|
||||
{"NAME", "VARCHAR2", "Y", null, null, 100, 100, null, null, 0}
|
||||
}),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{
|
||||
{"READER", "SELECT", "NO"},
|
||||
{"READER", "INSERT", "NO"},
|
||||
{"ADMIN", "SELECT", "YES"}
|
||||
}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{
|
||||
{"ANALYST", "NAME", "UPDATE", "NO"}
|
||||
}
|
||||
)
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertTrue(ddl.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"), ddl);
|
||||
Assertions.assertTrue(
|
||||
ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"),
|
||||
ddl
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
ddl.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"),
|
||||
ddl
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_COL_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlFallsBackToAllTabPrivsWhenDbaViewUnavailable() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, dbaFallbackPrivilegeConnection(sql,
|
||||
resultSet(
|
||||
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"COMMENTS"},
|
||||
new Object[][]{{null}}
|
||||
),
|
||||
columnResultSet(new Object[][]{
|
||||
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}
|
||||
}),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{
|
||||
{"READER", "SELECT", "NO"}
|
||||
}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{}
|
||||
)
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"READER\";"), ddl);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM ALL_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM SYS.DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlKeepsCreateTableWhenPrivilegeQueriesFail() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, privilegeFailingDdlConnection(sql,
|
||||
resultSet(
|
||||
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"COMMENTS"},
|
||||
new Object[][]{{null}}
|
||||
),
|
||||
columnResultSet(new Object[][]{
|
||||
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}
|
||||
})
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertFalse(ddl.contains("GRANT "), ddl);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
}
|
||||
|
||||
private static ResultSet columnResultSet(Object[][] rows) {
|
||||
@@ -496,6 +685,95 @@ class OceanBaseOracleAgentTest {
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection privilegeFailingDdlConnection(List<String> sql, ResultSet... resultSets) {
|
||||
int[] resultSetIndex = {0};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String statementSql = String.valueOf(args[0]);
|
||||
sql.add(statementSql);
|
||||
String normalized = statementSql.toUpperCase(Locale.ROOT);
|
||||
if (normalized.contains("DBA_TAB_PRIVS")
|
||||
|| normalized.contains("ALL_TAB_PRIVS")
|
||||
|| normalized.contains("DBA_COL_PRIVS")
|
||||
|| normalized.contains("ALL_COL_PRIVS")) {
|
||||
PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
throw new SQLException("privilege view unavailable");
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return failing;
|
||||
}
|
||||
PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
int current = Math.min(resultSetIndex[0], resultSets.length - 1);
|
||||
resultSetIndex[0] += 1;
|
||||
return resultSets[current];
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection dbaFallbackPrivilegeConnection(List<String> sql, ResultSet... resultSets) {
|
||||
int[] resultSetIndex = {0};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String statementSql = String.valueOf(args[0]);
|
||||
sql.add(statementSql);
|
||||
String normalized = statementSql.toUpperCase(Locale.ROOT);
|
||||
if (normalized.contains("DBA_TAB_PRIVS") || normalized.contains("DBA_COL_PRIVS")) {
|
||||
PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
throw new SQLException("DBA privilege view unavailable");
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return failing;
|
||||
}
|
||||
PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
int current = Math.min(resultSetIndex[0], resultSets.length - 1);
|
||||
resultSetIndex[0] += 1;
|
||||
return resultSets[current];
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection schemaConnection(List<String> sql, ResultSet resultSet) {
|
||||
Statement statement = proxy(Statement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
@@ -609,6 +887,37 @@ class OceanBaseOracleAgentTest {
|
||||
return executionConnection(sql, new ArrayList<>(), List.of());
|
||||
}
|
||||
|
||||
private static Connection transactionConnection(List<String> calls) {
|
||||
return transactionConnection(calls, new boolean[] {false});
|
||||
}
|
||||
|
||||
private static Connection transactionConnection(List<String> calls, boolean[] failCommit) {
|
||||
boolean[] autoCommit = {true};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getAutoCommit":
|
||||
return autoCommit[0];
|
||||
case "setAutoCommit":
|
||||
autoCommit[0] = (Boolean) args[0];
|
||||
calls.add("setAutoCommit:" + autoCommit[0]);
|
||||
return null;
|
||||
case "commit":
|
||||
calls.add("commit");
|
||||
if (failCommit[0]) {
|
||||
throw new SQLException("commit failed");
|
||||
}
|
||||
return null;
|
||||
case "rollback":
|
||||
calls.add("rollback");
|
||||
return null;
|
||||
case "isClosed":
|
||||
return false;
|
||||
default:
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection executionConnection(
|
||||
List<String> sql,
|
||||
List<Integer> queryTimeouts,
|
||||
|
||||
@@ -30,7 +30,11 @@ import (
|
||||
const protocolVersion = 1
|
||||
const multiSessionProtocolVersion = 2
|
||||
const defaultMaxRows = 1000
|
||||
const oracleDefaultPrefetchRows = "100"
|
||||
|
||||
// A normal data-grid page contains 100 rows. Prefetching a little more than
|
||||
// one page reduces round trips while reading subsequent pages without
|
||||
// buffering the much larger export limit.
|
||||
const oracleDefaultPrefetchRows = "256"
|
||||
const oracleCharsetZHS32GB18030 = 854
|
||||
const legacyAgentSessionID = "__legacy__"
|
||||
const maxAgentSessions = 256
|
||||
@@ -636,19 +640,28 @@ func newRuntimeServer() *runtimeServer {
|
||||
return &runtimeServer{sessions: map[string]*agentSession{}}
|
||||
}
|
||||
|
||||
func (r *runtimeServer) handleLine(line string) (response, bool) {
|
||||
func (r *runtimeServer) handleLine(line string) (resp response, shutdown bool) {
|
||||
var req request
|
||||
// Last-resort guard: a panic anywhere on the request path must not kill
|
||||
// the agent process and break the RPC stream, so convert it to a readable
|
||||
// error response instead of an end-of-stream failure.
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
resp = errorResponse(req.ID, fmt.Errorf("agent request panic: %v", recovered))
|
||||
shutdown = false
|
||||
}
|
||||
}()
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
return errorResponse(nil, err), false
|
||||
}
|
||||
if len(req.ID) == 0 {
|
||||
req.ID = json.RawMessage("1")
|
||||
}
|
||||
result, shutdown, err := r.dispatch(req.Method, req.Params)
|
||||
result, shouldShutdown, err := r.dispatch(req.Method, req.Params)
|
||||
if err != nil {
|
||||
return errorResponse(req.ID, err), false
|
||||
}
|
||||
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown
|
||||
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shouldShutdown
|
||||
}
|
||||
|
||||
func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
|
||||
@@ -3388,6 +3401,15 @@ func (s *server) getViewSource(schema, name string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
viewName := strings.TrimSpace(name)
|
||||
var source string
|
||||
viewsErr := db.QueryRow(
|
||||
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2",
|
||||
schema, viewName,
|
||||
).Scan(&source)
|
||||
if viewsErr == nil && strings.TrimSpace(source) != "" {
|
||||
return strings.TrimSpace(source), nil
|
||||
}
|
||||
|
||||
var ddl string
|
||||
metadataErr := db.QueryRow(
|
||||
"SELECT DBMS_METADATA.GET_DDL('VIEW', :1, :2) FROM DUAL",
|
||||
@@ -3397,22 +3419,11 @@ func (s *server) getViewSource(schema, name string) (string, error) {
|
||||
return strings.TrimSpace(ddl), nil
|
||||
}
|
||||
|
||||
var source string
|
||||
fallbackErr := db.QueryRow(
|
||||
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2",
|
||||
schema, viewName,
|
||||
).Scan(&source)
|
||||
if fallbackErr == nil && strings.TrimSpace(source) != "" {
|
||||
return strings.TrimSpace(source), nil
|
||||
}
|
||||
if fallbackErr != nil && !errors.Is(fallbackErr, sql.ErrNoRows) {
|
||||
if metadataErr != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to load view source for %s.%s: DBMS_METADATA: %v; ALL_VIEWS: %w",
|
||||
schema, viewName, metadataErr, fallbackErr,
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf("failed to load view source for %s.%s from ALL_VIEWS: %w", schema, viewName, fallbackErr)
|
||||
if viewsErr != nil && !errors.Is(viewsErr, sql.ErrNoRows) && metadataErr != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to load view source for %s.%s: ALL_VIEWS: %v; DBMS_METADATA: %w",
|
||||
schema, viewName, viewsErr, metadataErr,
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf("view source not found: %s.%s", schema, viewName)
|
||||
}
|
||||
@@ -3762,36 +3773,72 @@ func (s *server) executeQueryPage(opts queryOptions, pageSize int) (queryPageRes
|
||||
HasMore: false,
|
||||
}, err
|
||||
}
|
||||
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
|
||||
result, session, err := s.runPagedOracleSelect(sqlText, opts, pageSize, start)
|
||||
if err != nil {
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
columnTypes := columnTypeNames(rows)
|
||||
maxRows := opts.MaxRows
|
||||
if maxRows <= 0 {
|
||||
maxRows = defaultMaxRows
|
||||
}
|
||||
session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows}
|
||||
result, err := readQuerySessionPage(session, pageSize)
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
if result.HasMore {
|
||||
if session != nil {
|
||||
sessionID := s.storeQuerySession(session)
|
||||
result.SessionID = &sessionID
|
||||
} else {
|
||||
s.closeRows(rows)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runPagedOracleSelect executes a SELECT through the value-rewrite path and
|
||||
// reads its first page. It returns the live session when more pages remain.
|
||||
// When the first page panics while decoding an unsupported column type (before
|
||||
// any row was streamed to the client), it retries once with the placeholder
|
||||
// projection so the remaining columns stay readable; panics on later pages
|
||||
// surface as errors instead.
|
||||
func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSize int, start time.Time) (queryPageResult, *querySession, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
|
||||
if err != nil {
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
columnTypes := columnTypeNames(rows)
|
||||
maxRows := opts.MaxRows
|
||||
if maxRows <= 0 {
|
||||
maxRows = defaultMaxRows
|
||||
}
|
||||
session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows}
|
||||
result, err := readQuerySessionPage(session, pageSize)
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
var panicErr oracleDriverPanicError
|
||||
if attempt == 0 && errors.As(err, &panicErr) {
|
||||
if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok {
|
||||
sqlText = placeholder
|
||||
continue
|
||||
}
|
||||
}
|
||||
return queryPageResult{}, nil, err
|
||||
}
|
||||
if result.HasMore {
|
||||
return result, session, nil
|
||||
}
|
||||
s.closeRows(rows)
|
||||
return result, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// oraclePlaceholderRetrySQL returns the placeholder projection used to retry a
|
||||
// query whose first page panicked while decoding values of an unsupported
|
||||
// column type. It reports false when no placeholder rewrite applies.
|
||||
func oraclePlaceholderRetrySQL(sqlText string, loadColumns oracleColumnMetaLoader) (string, bool) {
|
||||
placeholder, err := rewriteOracleSelectSQL(sqlText, loadColumns, true)
|
||||
if err != nil || placeholder == sqlText {
|
||||
return "", false
|
||||
}
|
||||
return placeholder, true
|
||||
}
|
||||
|
||||
func (s *server) fetchQueryPage(sessionID string, pageSize int) (queryPageResult, error) {
|
||||
session := s.sessions[sessionID]
|
||||
if session == nil {
|
||||
@@ -3828,32 +3875,13 @@ func (s *server) startTableRead(opts queryOptions, pageSize int) (queryPageResul
|
||||
if !isQuerySQL(sqlText) {
|
||||
return queryPageResult{}, errors.New("table read requires a SELECT query")
|
||||
}
|
||||
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
|
||||
result, session, err := s.runPagedOracleSelect(sqlText, opts, pageSize, start)
|
||||
if err != nil {
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
columnTypes := columnTypeNames(rows)
|
||||
maxRows := opts.MaxRows
|
||||
if maxRows <= 0 {
|
||||
maxRows = defaultMaxRows
|
||||
}
|
||||
session := &querySession{rows: rows, columns: columns, columnTypes: columnTypes, remaining: maxRows}
|
||||
result, err := readQuerySessionPage(session, pageSize)
|
||||
result.ExecutionTimeMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
return queryPageResult{}, err
|
||||
}
|
||||
if result.HasMore {
|
||||
if session != nil {
|
||||
sessionID := s.storeTableReadSession(session)
|
||||
result.SessionID = &sessionID
|
||||
} else {
|
||||
s.closeRows(rows)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -3912,11 +3940,21 @@ func (s *server) closeAllQuerySessions() {
|
||||
}
|
||||
}
|
||||
|
||||
func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) {
|
||||
func readQuerySessionPage(session *querySession, pageSize int) (result queryPageResult, err error) {
|
||||
// go-ora can panic while decoding row values for column types it does not
|
||||
// support (for example custom object types). Convert those panics to errors
|
||||
// so row iteration failures never terminate the agent process and break
|
||||
// the RPC stream.
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
result = queryPageResult{}
|
||||
err = oracleDriverPanicError{value: recovered}
|
||||
}
|
||||
}()
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultMaxRows
|
||||
}
|
||||
result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false}
|
||||
result = queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: [][]any{}, SessionID: nil, HasMore: false}
|
||||
for len(result.Rows) < pageSize && session.remaining > 0 {
|
||||
if session.pending != nil {
|
||||
result.Rows = append(result.Rows, session.pending)
|
||||
@@ -3938,6 +3976,13 @@ func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult,
|
||||
result.Truncated = true
|
||||
return result, nil
|
||||
}
|
||||
// A full page is enough evidence that another page may exist. Do not read
|
||||
// one extra row just to decide has_more: with a prefetch boundary this
|
||||
// forces another Oracle round trip before the first page can be displayed.
|
||||
if len(result.Rows) >= pageSize {
|
||||
result.HasMore = true
|
||||
return result, nil
|
||||
}
|
||||
if session.rows.Next() {
|
||||
row, err := scanRow(session.rows, len(session.columns), session.columnTypes)
|
||||
if err != nil {
|
||||
@@ -4124,7 +4169,20 @@ func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeout
|
||||
if errors.As(err, &panicErr) {
|
||||
rewritten, rewriteErr := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, false)
|
||||
if rewriteErr == nil && rewritten != sqlText {
|
||||
return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
|
||||
rewrittenRows, rewrittenErr := s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
|
||||
if rewrittenErr == nil {
|
||||
return rewrittenRows, nil
|
||||
}
|
||||
// When the server's ArcSDE extproc agent is broken, the
|
||||
// SDE.ST_AsText rewrite itself fails with ORA-28595 and would
|
||||
// hide the remaining columns; retry once with the placeholder
|
||||
// projection so the query stays readable.
|
||||
if placeholder, ok := oracleExtprocFallbackSQL(sqlText, s.loadOracleColumnMeta, rewrittenErr); ok {
|
||||
if placeholderRows, placeholderErr := s.queryRowsWithTimeout(placeholder, nil, timeoutSecs); placeholderErr == nil {
|
||||
return placeholderRows, nil
|
||||
}
|
||||
}
|
||||
return nil, rewrittenErr
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
@@ -4156,6 +4214,34 @@ func oracleColumnTypeNamesContainXMLType(typeNames []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isOracleExtprocAgentFailure reports whether err is an Oracle extproc agent
|
||||
// failure (ORA-28595, typically raised through SDE.ST_GEOMETRY_SHAPELIB_PKG or
|
||||
// SDE.ST_GEOMETRY_OPERATORS) that makes every SDE.ST_GEOMETRY function call
|
||||
// unusable on the server. Matching stays deliberately narrow: the ORA code
|
||||
// must be present; SDE package names alone never trigger it.
|
||||
func isOracleExtprocAgentFailure(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Accept both the "ORA-28595" form and a bare "28595" code.
|
||||
return strings.Contains(strings.ToUpper(err.Error()), "28595")
|
||||
}
|
||||
|
||||
// oracleExtprocFallbackSQL returns the placeholder projection to retry with
|
||||
// when the panic-fallback SDE.ST_AsText rewrite failed because of a broken
|
||||
// extproc agent (ORA-28595). It reports false when the error is unrelated or
|
||||
// no placeholder rewrite applies.
|
||||
func oracleExtprocFallbackSQL(sqlText string, loadColumns oracleColumnMetaLoader, rewriteErr error) (string, bool) {
|
||||
if !isOracleExtprocAgentFailure(rewriteErr) {
|
||||
return "", false
|
||||
}
|
||||
placeholder, err := rewriteOracleSelectSQL(sqlText, loadColumns, true)
|
||||
if err != nil || placeholder == sqlText {
|
||||
return "", false
|
||||
}
|
||||
return placeholder, true
|
||||
}
|
||||
|
||||
func (s *server) rewriteXMLTypeSelectSQL(sqlText string) (string, error) {
|
||||
return rewriteOracleXMLTypeSelectSQL(sqlText, s.loadOracleColumnMeta)
|
||||
}
|
||||
@@ -4445,8 +4531,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
|
||||
for _, column := range columns {
|
||||
columnRef := oracleColumnRef(tableRef.AliasText, column.Name)
|
||||
outputAlias := quoteIdentifier(column.Name)
|
||||
if isOracleSTGeometry(column) && !deferLOBs {
|
||||
rewritten = append(rewritten, oracleSTGeometryExpression(columnRef, outputAlias))
|
||||
if isOracleGeometryType(column) && !deferLOBs {
|
||||
rewritten = append(rewritten, oracleGeometryExpression(column, columnRef, outputAlias))
|
||||
} else if isOracleXMLType(column.DataType) && !deferLOBs {
|
||||
rewritten = append(rewritten, oracleXMLSerializeExpression(columnRef, outputAlias))
|
||||
} else if deferLOBs {
|
||||
@@ -4471,8 +4557,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
|
||||
outputAlias = quoteIdentifier(meta.Name)
|
||||
}
|
||||
columnRef := oracleColumnRef(qualifier, meta.Name)
|
||||
if isOracleSTGeometry(meta) && !deferLOBs {
|
||||
rewritten = append(rewritten, oracleSTGeometryExpression(columnRef, outputAlias))
|
||||
if isOracleGeometryType(meta) && !deferLOBs {
|
||||
rewritten = append(rewritten, oracleGeometryExpression(meta, columnRef, outputAlias))
|
||||
changed = true
|
||||
sourceIndex++
|
||||
continue
|
||||
@@ -4502,8 +4588,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
|
||||
func oracleDeferredLOBExpressions(columnRef, outputAlias string, sourceIndex int, column oracleColumnMeta) ([]string, bool) {
|
||||
kind, placeholder, ok := oracleDeferredLOBKind(column.DataType)
|
||||
valueRef := columnRef
|
||||
if isOracleSTGeometry(column) {
|
||||
kind, placeholder, ok = "C", "<ST_GEOMETRY>", true
|
||||
if isOracleGeometryType(column) {
|
||||
kind, placeholder, ok = "C", oracleGeometryPlaceholder(column), true
|
||||
}
|
||||
if !ok {
|
||||
return nil, false
|
||||
@@ -4518,6 +4604,24 @@ func oracleSTGeometryExpression(columnRef, alias string) string {
|
||||
return fmt.Sprintf("SDE.ST_AsText(%s) AS %s", columnRef, alias)
|
||||
}
|
||||
|
||||
func oracleSDOGeometryExpression(columnRef, alias string) string {
|
||||
return fmt.Sprintf("SDO_UTIL.TO_WKTGEOMETRY(%s) AS %s", columnRef, alias)
|
||||
}
|
||||
|
||||
func oracleGeometryExpression(column oracleColumnMeta, columnRef, alias string) string {
|
||||
if isOracleSDOGeometry(column) {
|
||||
return oracleSDOGeometryExpression(columnRef, alias)
|
||||
}
|
||||
return oracleSTGeometryExpression(columnRef, alias)
|
||||
}
|
||||
|
||||
func oracleGeometryPlaceholder(column oracleColumnMeta) string {
|
||||
if isOracleSDOGeometry(column) {
|
||||
return "<SDO_GEOMETRY>"
|
||||
}
|
||||
return "<ST_GEOMETRY>"
|
||||
}
|
||||
|
||||
func oracleDeferredLOBKind(dataType string) (kind, placeholder string, ok bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(dataType)) {
|
||||
case "CLOB":
|
||||
@@ -4615,7 +4719,7 @@ func oracleQualifierMatchesTable(qualifier string, tableRef oracleTableRef) bool
|
||||
|
||||
func oracleColumnsNeedValueRewrite(columns []oracleColumnMeta, deferLOBs bool) bool {
|
||||
for _, column := range columns {
|
||||
if isOracleSTGeometry(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) {
|
||||
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -4651,6 +4755,19 @@ func isOracleSTGeometry(column oracleColumnMeta) bool {
|
||||
return dataType == "ST_GEOMETRY" && strings.EqualFold(strings.TrimSpace(column.DataTypeOwner), "SDE")
|
||||
}
|
||||
|
||||
func isOracleSDOGeometry(column oracleColumnMeta) bool {
|
||||
// Oracle Spatial's MDSYS.SDO_GEOMETRY is likewise an unregistered object
|
||||
// type for go-ora and panics on scan; SDO_UTIL.TO_WKTGEOMETRY provides
|
||||
// the supported CLOB (WKT) representation for reads. Most schemas only
|
||||
// see it through the PUBLIC synonym, so DATA_TYPE_OWNER is commonly
|
||||
// "PUBLIC" rather than "MDSYS" and isn't checked here.
|
||||
return strings.ToUpper(strings.TrimSpace(column.DataType)) == "SDO_GEOMETRY"
|
||||
}
|
||||
|
||||
func isOracleGeometryType(column oracleColumnMeta) bool {
|
||||
return isOracleSTGeometry(column) || isOracleSDOGeometry(column)
|
||||
}
|
||||
|
||||
func leadingSQLSelectListStart(sqlText string) int {
|
||||
trimmed := trimLeadingSQLComments(sqlText)
|
||||
prefixLen := len(sqlText) - len(trimmed)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
@@ -1182,6 +1183,27 @@ func TestBuildDSNUsesStableDefaultPrefetchRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadQuerySessionPageDoesNotLookAheadAfterFullPage(t *testing.T) {
|
||||
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{
|
||||
queryContains: "SELECT * FROM TEST_VIEW",
|
||||
args: []driver.Value{},
|
||||
columns: []string{"ID"},
|
||||
rows: [][]driver.Value{{int64(1)}, {int64(2)}},
|
||||
}})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM TEST_VIEW"}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Rows) != 2 || !result.HasMore || result.SessionID == nil {
|
||||
t.Fatalf("full page should keep a lazy session without look-ahead: %+v", result)
|
||||
}
|
||||
if !s.closeQuerySession(*result.SessionID) {
|
||||
t.Fatal("expected the query session to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDSNPreservesConfiguredPrefetchRows(t *testing.T) {
|
||||
tests := []connectParams{
|
||||
{
|
||||
@@ -2361,6 +2383,196 @@ func TestRewriteOracleSTGeometrySkipsSetQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleSDOGeometrySelectStar(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT * FROM TEST_GEOM`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"},
|
||||
{Name: "NOTE", DataType: "VARCHAR2"},
|
||||
}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `SELECT "ID", SDO_UTIL.TO_WKTGEOMETRY("GEOM") AS "GEOM", "NOTE" FROM TEST_GEOM`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleSDOGeometryExplicitColumn(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT t.GEOM AS shape FROM TEST_GEOM t`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"}}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `SELECT SDO_UTIL.TO_WKTGEOMETRY(t."GEOM") AS shape FROM TEST_GEOM t`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleSDOGeometryAsDeferredValue(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT t.ID, t.GEOM FROM TEST_GEOM t`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"},
|
||||
}, nil
|
||||
},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `SELECT t.ID, CASE WHEN t."GEOM" IS NULL THEN NULL ELSE '<SDO_GEOMETRY>' END AS "GEOM", CASE WHEN t."GEOM" IS NULL THEN NULL ELSE 'D:1' END AS "__DBX_LARGE_VALUE_BYTES_C_1" FROM TEST_GEOM t`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleSDOGeometryDoesNotRequireOwner(t *testing.T) {
|
||||
if !isOracleSDOGeometry(oracleColumnMeta{DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"}) {
|
||||
t.Fatal("expected SDO_GEOMETRY via PUBLIC synonym to be recognized")
|
||||
}
|
||||
if !isOracleSDOGeometry(oracleColumnMeta{DataType: "sdo_geometry"}) {
|
||||
t.Fatal("expected SDO_GEOMETRY to be recognized regardless of owner or case")
|
||||
}
|
||||
if isOracleSDOGeometry(oracleColumnMeta{DataType: "VARCHAR2"}) {
|
||||
t.Fatal("unexpected rewrite for an unrelated data type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOracleExtprocAgentFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "ORA-28595 with SDE package context",
|
||||
err: errors.New(`ORA-28595: Extproc agent: Invalid DLL Path SDE.ST_GEOMETRY_SHAPELIB_PKG`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ORA-28595 with operator package context",
|
||||
err: errors.New(`ORA-28595: Extproc agent: Invalid DLL Path SDE.ST_GEOMETRY_OPERATORS`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "lowercase ORA-28595",
|
||||
err: errors.New("ora-28595: extproc agent: invalid dll path"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "bare ORA code",
|
||||
err: errors.New("28595: Extproc agent: Invalid DLL Path"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ORA-28595",
|
||||
err: fmt.Errorf("query failed: %w", errors.New("ORA-28595: Extproc agent: Invalid DLL Path")),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated ORA error",
|
||||
err: errors.New("ORA-00942: table or view does not exist"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "connection error",
|
||||
err: errors.New("ORA-12170: TNS:Connect timeout occurred"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "SDE package without ORA-28595",
|
||||
err: errors.New("SDE.ST_GEOMETRY_SHAPELIB_PKG raised an error"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isOracleExtprocAgentFailure(tc.err); got != tc.want {
|
||||
t.Fatalf("isOracleExtprocAgentFailure(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleExtprocFallbackSQL(t *testing.T) {
|
||||
sdeColumns := func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "GEOM", DataType: "ST_GEOMETRY", DataTypeOwner: "SDE"},
|
||||
}, nil
|
||||
}
|
||||
t.Run("returns placeholder projection for extproc failure", func(t *testing.T) {
|
||||
sqlText, ok := oracleExtprocFallbackSQL(
|
||||
`SELECT * FROM SDE.TEST_GEOM`,
|
||||
sdeColumns,
|
||||
errors.New("ORA-28595: Extproc agent: Invalid DLL Path"),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected placeholder fallback SQL")
|
||||
}
|
||||
if !strings.Contains(sqlText, "'<ST_GEOMETRY>'") {
|
||||
t.Fatalf("expected placeholder expression, got %s", sqlText)
|
||||
}
|
||||
if strings.Contains(sqlText, "SDE.ST_AsText") {
|
||||
t.Fatalf("placeholder projection must not call SDE.ST_AsText, got %s", sqlText)
|
||||
}
|
||||
})
|
||||
t.Run("keeps ST_AsText error for unrelated failures", func(t *testing.T) {
|
||||
sqlText, ok := oracleExtprocFallbackSQL(
|
||||
`SELECT * FROM SDE.TEST_GEOM`,
|
||||
sdeColumns,
|
||||
errors.New("ORA-12170: TNS:Connect timeout occurred"),
|
||||
)
|
||||
if ok {
|
||||
t.Fatalf("unexpected fallback SQL: %s", sqlText)
|
||||
}
|
||||
})
|
||||
t.Run("no fallback without rewriteable columns", func(t *testing.T) {
|
||||
sqlText, ok := oracleExtprocFallbackSQL(
|
||||
`SELECT * FROM SDE.TEST_GEOM`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{{Name: "ID", DataType: "NUMBER"}}, nil
|
||||
},
|
||||
errors.New("ORA-28595: Extproc agent: Invalid DLL Path"),
|
||||
)
|
||||
if ok {
|
||||
t.Fatalf("unexpected fallback SQL: %s", sqlText)
|
||||
}
|
||||
})
|
||||
t.Run("no fallback when column metadata fails", func(t *testing.T) {
|
||||
sqlText, ok := oracleExtprocFallbackSQL(
|
||||
`SELECT * FROM SDE.TEST_GEOM`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return nil, errors.New("ORA-00942: table or view does not exist")
|
||||
},
|
||||
errors.New("ORA-28595: Extproc agent: Invalid DLL Path"),
|
||||
)
|
||||
if ok {
|
||||
t.Fatalf("unexpected fallback SQL: %s", sqlText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRewriteOracleXMLTypeSkipsJoins(t *testing.T) {
|
||||
called := false
|
||||
sqlText, err := rewriteOracleXMLTypeSelectSQL(
|
||||
@@ -2545,15 +2757,15 @@ func fakeOracleColumnLoader(columns []oracleColumnMeta) oracleColumnMetaLoader {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectSourceUsesOriginalViewNameWithDBMSMetadata(t *testing.T) {
|
||||
func TestGetObjectSourceUsesOriginalViewNameWithAllViews(t *testing.T) {
|
||||
for _, viewName := range []string{"vEnginWJZ", "V_ENGINE_WJZ"} {
|
||||
t.Run(viewName, func(t *testing.T) {
|
||||
ddl := `CREATE OR REPLACE FORCE VIEW "ZTZS_ERP2"."` + viewName + `" AS SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
source := `SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{viewName, "ZTZS_ERP2"},
|
||||
rows: [][]driver.Value{{ddl}},
|
||||
queryContains: "FROM ALL_VIEWS",
|
||||
args: []driver.Value{"ZTZS_ERP2", viewName},
|
||||
rows: [][]driver.Value{{source}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
@@ -2563,7 +2775,7 @@ func TestGetObjectSourceUsesOriginalViewNameWithDBMSMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["source"] != ddl {
|
||||
if result["source"] != source {
|
||||
t.Fatalf("unexpected view source: %#v", result["source"])
|
||||
}
|
||||
if scripted.next != len(scripted.steps) {
|
||||
@@ -2770,19 +2982,19 @@ func TestGetObjectSourcePreservesQuotedSynonymName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
|
||||
func TestGetObjectSourceFallsBackToDBMSMetadataWithOriginalName(t *testing.T) {
|
||||
const viewName = "vEnginWJZ"
|
||||
const source = `SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
const ddl = `CREATE OR REPLACE FORCE VIEW "ZTZS_ERP2"."vEnginWJZ" AS SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{viewName, "ZTZS_ERP2"},
|
||||
err: errors.New("ORA-31603: object not found"),
|
||||
},
|
||||
{
|
||||
queryContains: "FROM ALL_VIEWS",
|
||||
args: []driver.Value{"ZTZS_ERP2", viewName},
|
||||
rows: [][]driver.Value{{source}},
|
||||
err: errors.New("ORA-00942: table or view does not exist"),
|
||||
},
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{viewName, "ZTZS_ERP2"},
|
||||
rows: [][]driver.Value{{ddl}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
@@ -2792,7 +3004,7 @@ func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["source"] != source {
|
||||
if result["source"] != ddl {
|
||||
t.Fatalf("unexpected fallback source: %#v", result["source"])
|
||||
}
|
||||
if scripted.next != len(scripted.steps) {
|
||||
@@ -2802,16 +3014,16 @@ func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
|
||||
|
||||
func TestGetObjectSourceRejectsMissingViewSource(t *testing.T) {
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{"vEnginWJZ", "ZTZS_ERP2"},
|
||||
err: errors.New("ORA-31603: object not found"),
|
||||
},
|
||||
{
|
||||
queryContains: "FROM ALL_VIEWS",
|
||||
args: []driver.Value{"ZTZS_ERP2", "vEnginWJZ"},
|
||||
rows: nil,
|
||||
},
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{"vEnginWJZ", "ZTZS_ERP2"},
|
||||
err: errors.New("ORA-31603: object not found"),
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
@@ -2850,12 +3062,15 @@ func contains(values []string, target string) bool {
|
||||
}
|
||||
|
||||
type oracleViewSourceQueryStep struct {
|
||||
queryContains string
|
||||
args []driver.Value
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
err error
|
||||
exec bool
|
||||
queryContains string
|
||||
args []driver.Value
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
err error
|
||||
exec bool
|
||||
panicText string
|
||||
nextPanicText string
|
||||
columnsPanicText string
|
||||
}
|
||||
|
||||
type oracleViewSourceDriver struct {
|
||||
@@ -2899,6 +3114,9 @@ func (c *oracleViewSourceConn) QueryContext(
|
||||
if !strings.Contains(query, step.queryContains) {
|
||||
return nil, errors.New("unexpected query: " + query)
|
||||
}
|
||||
if step.panicText != "" {
|
||||
panic(step.panicText)
|
||||
}
|
||||
values := make([]driver.Value, len(args))
|
||||
for index, arg := range args {
|
||||
values[index] = arg.Value
|
||||
@@ -2913,7 +3131,12 @@ func (c *oracleViewSourceConn) QueryContext(
|
||||
if len(columns) == 0 {
|
||||
columns = []string{"SOURCE"}
|
||||
}
|
||||
return &oracleViewSourceRows{columns: columns, values: step.rows}, nil
|
||||
return &oracleViewSourceRows{
|
||||
columns: columns,
|
||||
values: step.rows,
|
||||
nextPanicText: step.nextPanicText,
|
||||
columnsPanicText: step.columnsPanicText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *oracleViewSourceConn) ExecContext(
|
||||
@@ -2943,12 +3166,17 @@ func (c *oracleViewSourceConn) ExecContext(
|
||||
}
|
||||
|
||||
type oracleViewSourceRows struct {
|
||||
columns []string
|
||||
values [][]driver.Value
|
||||
next int
|
||||
columns []string
|
||||
values [][]driver.Value
|
||||
next int
|
||||
nextPanicText string
|
||||
columnsPanicText string
|
||||
}
|
||||
|
||||
func (r *oracleViewSourceRows) Columns() []string {
|
||||
if r.columnsPanicText != "" {
|
||||
panic(r.columnsPanicText)
|
||||
}
|
||||
return r.columns
|
||||
}
|
||||
|
||||
@@ -2957,6 +3185,9 @@ func (r *oracleViewSourceRows) Close() error {
|
||||
}
|
||||
|
||||
func (r *oracleViewSourceRows) Next(dest []driver.Value) error {
|
||||
if r.nextPanicText != "" {
|
||||
panic(r.nextPanicText)
|
||||
}
|
||||
if r.next >= len(r.values) {
|
||||
return io.EOF
|
||||
}
|
||||
@@ -3172,6 +3403,208 @@ func TestOracleQueryRowsRecoversDriverPanic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleQueryRowsFallsBackToPlaceholderOnExtprocFailure(t *testing.T) {
|
||||
metadataStep := oracleViewSourceQueryStep{
|
||||
queryContains: "ALL_TAB_COLUMNS",
|
||||
args: []driver.Value{"SDE", "TEST_GEOM"},
|
||||
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
|
||||
rows: [][]driver.Value{
|
||||
{"ID", "NUMBER", nil},
|
||||
{"GEOM", "ST_GEOMETRY", "SDE"},
|
||||
},
|
||||
}
|
||||
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{queryContains: "SELECT * FROM SDE.TEST_GEOM", args: []driver.Value{}, panicText: "simulated go-ora panic on SDE object column"},
|
||||
metadataStep,
|
||||
{queryContains: "SDE.ST_AsText", args: []driver.Value{}, err: errors.New("ORA-28595: Extproc agent: Invalid DLL Path")},
|
||||
metadataStep,
|
||||
{
|
||||
queryContains: "'<ST_GEOMETRY>'",
|
||||
args: []driver.Value{},
|
||||
columns: []string{"ID", "GEOM", "__DBX_LARGE_VALUE_BYTES_C_1"},
|
||||
rows: [][]driver.Value{{int64(1), "<ST_GEOMETRY>", "D:1"}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded("SELECT * FROM SDE.TEST_GEOM", 0, false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected placeholder fallback to serve rows, got %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(columns, []string{"ID", "GEOM", "__DBX_LARGE_VALUE_BYTES_C_1"}) {
|
||||
t.Fatalf("unexpected placeholder columns: %v", columns)
|
||||
}
|
||||
if !rows.Next() {
|
||||
t.Fatalf("expected one placeholder row, got %v", rows.Err())
|
||||
}
|
||||
var id int64
|
||||
var geom, marker string
|
||||
if err := rows.Scan(&id, &geom, &marker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != 1 || geom != "<ST_GEOMETRY>" || marker != "D:1" {
|
||||
t.Fatalf("unexpected placeholder row: %v %v %v", id, geom, marker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleQueryRowsKeepsSTAsTextErrorWhenPlaceholderAlsoFails(t *testing.T) {
|
||||
metadataStep := oracleViewSourceQueryStep{
|
||||
queryContains: "ALL_TAB_COLUMNS",
|
||||
args: []driver.Value{"SDE", "TEST_GEOM"},
|
||||
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
|
||||
rows: [][]driver.Value{
|
||||
{"ID", "NUMBER", nil},
|
||||
{"GEOM", "ST_GEOMETRY", "SDE"},
|
||||
},
|
||||
}
|
||||
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{queryContains: "SELECT * FROM SDE.TEST_GEOM", args: []driver.Value{}, panicText: "simulated go-ora panic on SDE object column"},
|
||||
metadataStep,
|
||||
{queryContains: "SDE.ST_AsText", args: []driver.Value{}, err: errors.New("ORA-28595: Extproc agent: Invalid DLL Path")},
|
||||
metadataStep,
|
||||
{queryContains: "'<ST_GEOMETRY>'", args: []driver.Value{}, err: errors.New("ORA-01013: user requested cancel of current operation")},
|
||||
})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded("SELECT * FROM SDE.TEST_GEOM", 0, false)
|
||||
if rows != nil {
|
||||
t.Fatal("failed fallback path must not return rows")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "ORA-28595") {
|
||||
t.Fatalf("expected the original ST_AsText ORA-28595 error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadQuerySessionPageConvertsPanicToError(t *testing.T) {
|
||||
// A nil *sql.Rows makes rows.Next() panic with a nil pointer dereference,
|
||||
// which stands in for go-ora panicking while decoding an unknown type.
|
||||
session := &querySession{rows: nil, columns: []string{"A"}, columnTypes: []string{"NUMBER"}, remaining: 1}
|
||||
result, err := readQuerySessionPage(session, 10)
|
||||
var panicErr oracleDriverPanicError
|
||||
if !errors.As(err, &panicErr) {
|
||||
t.Fatalf("expected oracle driver panic error, got %v", err)
|
||||
}
|
||||
if len(result.Rows) != 0 {
|
||||
t.Fatalf("panic path must not return rows, got %v", result.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteQueryPageRetriesWithPlaceholderOnRowIterationPanic(t *testing.T) {
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "SELECT * FROM SDE.TEST_GEOM",
|
||||
args: []driver.Value{},
|
||||
columns: []string{"ID", "GEOM"},
|
||||
rows: [][]driver.Value{{int64(1), "raw geometry bytes"}},
|
||||
nextPanicText: "simulated go-ora decode panic on custom type column",
|
||||
},
|
||||
{
|
||||
queryContains: "ALL_TAB_COLUMNS",
|
||||
args: []driver.Value{"SDE", "TEST_GEOM"},
|
||||
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
|
||||
rows: [][]driver.Value{
|
||||
{"ID", "NUMBER", nil},
|
||||
{"GEOM", "ST_GEOMETRY", "SDE"},
|
||||
},
|
||||
},
|
||||
{
|
||||
queryContains: "'<ST_GEOMETRY>'",
|
||||
args: []driver.Value{},
|
||||
columns: []string{"ID", "GEOM", "__DBX_LARGE_VALUE_BYTES_C_1"},
|
||||
rows: [][]driver.Value{{int64(1), "<ST_GEOMETRY>", "D:1"}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM SDE.TEST_GEOM"}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("expected placeholder retry to serve the first page, got %v", err)
|
||||
}
|
||||
if scripted.next != len(scripted.steps) {
|
||||
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
|
||||
}
|
||||
if len(result.Rows) != 1 {
|
||||
t.Fatalf("expected one placeholder row, got %v", result.Rows)
|
||||
}
|
||||
want := []any{int64(1), "<ST_GEOMETRY>", "D:1"}
|
||||
if !reflect.DeepEqual(result.Rows[0], want) {
|
||||
t.Fatalf("unexpected row: %v, want %v", result.Rows[0], want)
|
||||
}
|
||||
if result.SessionID != nil || result.HasMore {
|
||||
t.Fatalf("fully drained page must not keep a session: %v %v", result.SessionID, result.HasMore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteQueryPageSurfacesPanicWhenPlaceholderNotApplicable(t *testing.T) {
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "SELECT * FROM SDE.TEST_GEOM",
|
||||
args: []driver.Value{},
|
||||
columns: []string{"ID"},
|
||||
rows: [][]driver.Value{{int64(1)}},
|
||||
nextPanicText: "simulated go-ora decode panic on custom type column",
|
||||
},
|
||||
{
|
||||
queryContains: "ALL_TAB_COLUMNS",
|
||||
args: []driver.Value{"SDE", "TEST_GEOM"},
|
||||
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
|
||||
rows: [][]driver.Value{{"ID", "NUMBER", nil}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM SDE.TEST_GEOM"}, 10)
|
||||
var panicErr oracleDriverPanicError
|
||||
if !errors.As(err, &panicErr) {
|
||||
t.Fatalf("expected oracle driver panic error, got %v", err)
|
||||
}
|
||||
if scripted.next != len(scripted.steps) {
|
||||
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
|
||||
}
|
||||
if len(result.Rows) != 0 {
|
||||
t.Fatalf("failed query must not return rows, got %v", result.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHandleLineRecoversRequestPanic(t *testing.T) {
|
||||
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "SELECT * FROM SDE.TEST_GEOM",
|
||||
args: []driver.Value{},
|
||||
columnsPanicText: "simulated go-ora panic while describing columns",
|
||||
},
|
||||
})
|
||||
runtime := newRuntimeServer()
|
||||
session := newServer()
|
||||
session.db = db
|
||||
runtime.sessions["agent-session-1"] = &agentSession{server: session}
|
||||
|
||||
resp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":5,"method":"execute_query_page","params":{"agentSessionId":"agent-session-1","sql":"SELECT * FROM SDE.TEST_GEOM","pageSize":10}}`)
|
||||
if shutdown {
|
||||
t.Fatal("recovered panic must not shut down the agent")
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatalf("expected panic error response, got %+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "agent request panic") {
|
||||
t.Fatalf("unexpected error message: %s", resp.Error.Message)
|
||||
}
|
||||
if string(resp.ID) != "5" {
|
||||
t.Fatalf("panic response must keep the request id, got %s", resp.ID)
|
||||
}
|
||||
|
||||
// The RPC stream must survive the panic and keep serving requests.
|
||||
followUp, shutdown := runtime.handleLine(`{"jsonrpc":"2.0","id":6,"method":"handshake","params":{}}`)
|
||||
if shutdown || followUp.Error != nil {
|
||||
t.Fatalf("handshake after recovered panic failed: shutdown=%v error=%v", shutdown, followUp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTransactionBeginCommitRollback(t *testing.T) {
|
||||
db, driver := openOracleManualTxTestDB(t)
|
||||
s := newServer()
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const sendMessageRequestCode = 10
|
||||
const queryMessageNotFoundCode = 208
|
||||
const queryMessageNotFoundCode = 22
|
||||
|
||||
type messageQueueTarget struct {
|
||||
BrokerName string
|
||||
@@ -208,9 +208,6 @@ func (a *rocketMQAgent) queryMessageByKey(params map[string]any) (any, error) {
|
||||
messages, err := queryMessagesByKey(ctx, client, config.ConnectTimeout, topic, key, maxNum,
|
||||
int64Value(params, 0, "begin"), int64Value(params, time.Now().UnixMilli(), "end"))
|
||||
if err != nil {
|
||||
if isEmptyMessageQueryError(err) {
|
||||
return emptyMessageQuery(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return messageQueryResult(topic, messages), nil
|
||||
@@ -296,11 +293,14 @@ func (a *rocketMQAgent) queryMessageByTopic(params map[string]any) (any, error)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
|
||||
defer cancel()
|
||||
maxNum := min(max(1, intValue(params, 32, "maxNum")), 200)
|
||||
beginTimestamp := int64Value(params, 0, "begin")
|
||||
endTimestamp := int64Value(params, time.Now().UnixMilli(), "end")
|
||||
messages, err := client.QueryMessageByTime(ctx, topic,
|
||||
int64Value(params, 0, "begin"), int64Value(params, time.Now().UnixMilli(), "end"), maxNum)
|
||||
beginTimestamp, endTimestamp, maxNum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = filterMessagesByStoreTimestamp(messages, beginTimestamp, endTimestamp)
|
||||
return messageQueryResult(topic, messages), nil
|
||||
}
|
||||
|
||||
@@ -491,10 +491,6 @@ func messageQueryResult(topic string, messages []*admin.MessageExt) map[string]a
|
||||
return map[string]any{"messages": rows, "indexLastUpdateTimestamp": int64(0)}
|
||||
}
|
||||
|
||||
func emptyMessageQuery() map[string]any {
|
||||
return map[string]any{"messages": []map[string]any{}, "indexLastUpdateTimestamp": int64(0)}
|
||||
}
|
||||
|
||||
func sortMessageRows(messages []map[string]any) {
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
leftTime := anyInt64(messages[i]["timestamp"])
|
||||
@@ -556,9 +552,18 @@ func anyInt64(value any) int64 {
|
||||
}
|
||||
}
|
||||
|
||||
func isEmptyMessageQueryError(err error) bool {
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "208") || strings.Contains(message, "no message") || strings.Contains(message, "未找到消息")
|
||||
func filterMessagesByStoreTimestamp(messages []*admin.MessageExt, beginTimestamp, endTimestamp int64) []*admin.MessageExt {
|
||||
filtered := make([]*admin.MessageExt, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
if beginTimestamp > 0 && message.StoreTimestamp < beginTimestamp {
|
||||
continue
|
||||
}
|
||||
if endTimestamp > 0 && message.StoreTimestamp > endTimestamp {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, message)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isReservedMessageProperty(key string) bool {
|
||||
|
||||
@@ -54,6 +54,110 @@ func TestBuildQueryMessageCommandUsesBrokerHeaderNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeRemotingAllowCodesAcceptsQueryMessageNotFound(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
connection, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
done <- acceptErr
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
frame, readErr := readRemotingFrame(connection)
|
||||
if readErr != nil {
|
||||
done <- readErr
|
||||
return
|
||||
}
|
||||
request, decodeErr := remoting.Decode(frame[4:])
|
||||
if decodeErr != nil {
|
||||
done <- decodeErr
|
||||
return
|
||||
}
|
||||
response := &remoting.RemotingCommand{Code: queryMessageNotFoundCode, Opaque: request.Opaque, Flag: 1}
|
||||
encoded, encodeErr := response.Encode()
|
||||
if encodeErr == nil {
|
||||
_, encodeErr = connection.Write(encoded)
|
||||
}
|
||||
done <- encodeErr
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if _, err := invokeRemotingAllowCodes(ctx, listener.Addr().String(), time.Second,
|
||||
remoting.NewRequest(remoting.QueryMessage, nil), remoting.Success, queryMessageNotFoundCode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeRemotingAllowCodesPreservesOtherErrors(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
connection, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
done <- acceptErr
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
frame, readErr := readRemotingFrame(connection)
|
||||
if readErr != nil {
|
||||
done <- readErr
|
||||
return
|
||||
}
|
||||
request, decodeErr := remoting.Decode(frame[4:])
|
||||
if decodeErr != nil {
|
||||
done <- decodeErr
|
||||
return
|
||||
}
|
||||
response := &remoting.RemotingCommand{Code: remoting.ConsumerNotOnline, Opaque: request.Opaque, Flag: 1}
|
||||
encoded, encodeErr := response.Encode()
|
||||
if encodeErr == nil {
|
||||
_, encodeErr = connection.Write(encoded)
|
||||
}
|
||||
done <- encodeErr
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_, invokeErr := invokeRemotingAllowCodes(ctx, listener.Addr().String(), time.Second,
|
||||
remoting.NewRequest(remoting.QueryMessage, nil), remoting.Success, queryMessageNotFoundCode)
|
||||
if invokeErr == nil || !strings.Contains(invokeErr.Error(), "206") {
|
||||
t.Fatalf("expected ConsumerNotOnline to remain an error, got %v", invokeErr)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMessagesByStoreTimestampUsesInclusiveBounds(t *testing.T) {
|
||||
messages := []*admin.MessageExt{
|
||||
{MsgId: "before", StoreTimestamp: 99},
|
||||
{MsgId: "begin", StoreTimestamp: 100},
|
||||
{MsgId: "middle", StoreTimestamp: 150},
|
||||
{MsgId: "end", StoreTimestamp: 200},
|
||||
{MsgId: "after", StoreTimestamp: 201},
|
||||
}
|
||||
filtered := filterMessagesByStoreTimestamp(messages, 100, 200)
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("expected three messages in range, got %d", len(filtered))
|
||||
}
|
||||
for index, messageID := range []string{"begin", "middle", "end"} {
|
||||
if filtered[index].MsgId != messageID {
|
||||
t.Fatalf("unexpected message at index %d: %s", index, filtered[index].MsgId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeRemotingAllowCodes(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
|
||||
+156
-8
@@ -71,6 +71,15 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
enableLegacyTlsAlgorithms();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsConnectionPooling() {
|
||||
// The mssql-jdbc -> jTDS fallback is session state on this Agent
|
||||
// instance. A shared JDBC pool could hand a jTDS connection to another
|
||||
// session that still believes it is using mssql-jdbc, and SQL Server
|
||||
// 2000 is particularly prone to resetting those reused connections.
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
return sqlServer2000Mode ? jtdsUrl(params) : legacyTlsUrl(params);
|
||||
@@ -87,7 +96,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
return connection;
|
||||
} catch (SQLException error) {
|
||||
logConnectionEvent("mssql-jdbc failed", params, error);
|
||||
if (isSqlServer2000Unsupported(error)) {
|
||||
if (shouldFallbackToJtds(error)) {
|
||||
logConnectionEvent("switching to jTDS 1.3.1 fallback", params, null);
|
||||
try {
|
||||
super.loadDriver(jtdsDriverParams());
|
||||
@@ -194,7 +203,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
.replace('\n', ' ');
|
||||
}
|
||||
|
||||
static boolean isSqlServer2000Unsupported(Throwable error) {
|
||||
static boolean shouldFallbackToJtds(Throwable error) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
String message = current.getMessage();
|
||||
@@ -206,16 +215,29 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
// "sql server 8" substring match, so accept both shapes.
|
||||
boolean version8Rejection = (normalized.contains("sql server 8") || normalized.contains("sql server version 8"))
|
||||
&& (normalized.contains("not support") || normalized.contains("不支持"));
|
||||
// Other driver wordings name the supported floor instead.
|
||||
boolean floor2005Rejection = normalized.contains("sql server 2005 or later");
|
||||
if (version8Rejection || floor2005Rejection) {
|
||||
// Other driver wordings name the supported floor instead. The
|
||||
// localized mssql-jdbc resource keeps "SQL Server 2005" in
|
||||
// English while translating the "or later" suffix.
|
||||
boolean floor2005Rejection = normalized.contains("sql server 2005 or later")
|
||||
|| (normalized.contains("sql server 2005") && normalized.contains("更高版本"));
|
||||
// Some SQL Server 2000 installations close the TDS 7.4
|
||||
// prelogin socket before mssql-jdbc can report the server
|
||||
// version. In legacy mode, retry that handshake once with
|
||||
// jTDS, which speaks the older protocol.
|
||||
boolean preloginRejection = normalized.contains("connection reset")
|
||||
|| normalized.contains("connection was reset")
|
||||
|| normalized.contains("forcibly closed")
|
||||
|| normalized.contains("意外的登录前响应")
|
||||
|| (normalized.contains("unexpected")
|
||||
&& (normalized.contains("prelogin") || normalized.contains("pre-login")));
|
||||
if (version8Rejection || floor2005Rejection || preloginRejection) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (current instanceof SQLException) {
|
||||
SQLException next = ((SQLException) current).getNextException();
|
||||
if (next != null && next != current.getCause()) {
|
||||
if (isSqlServer2000Unsupported(next)) {
|
||||
if (shouldFallbackToJtds(next)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -320,12 +342,138 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return super.getColumns(metadataSchema(schema, table), table);
|
||||
String resolvedSchema = metadataSchema(schema, table);
|
||||
List<ColumnInfo> columns = super.getColumns(resolvedSchema, table);
|
||||
if (!sqlServer2000Mode || columns.isEmpty()) {
|
||||
return columns;
|
||||
}
|
||||
try {
|
||||
return mergeSqlServer2000ColumnComments(
|
||||
columns,
|
||||
readSqlServer2000ColumnComments(resolvedSchema, table)
|
||||
);
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
// Comments are optional metadata. Keep the table usable when the
|
||||
// legacy catalog is unavailable or the account cannot read it.
|
||||
// Legacy drivers can also throw runtime errors from their catalog
|
||||
// code, mirroring the RuntimeException guards in getTableDdl.
|
||||
System.err.println(
|
||||
"[sqlserver-legacy] SQL Server 2000 column comments unavailable: "
|
||||
+ error.getClass().getName()
|
||||
+ ": "
|
||||
+ error.getMessage()
|
||||
);
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> readSqlServer2000ColumnComments(String schema, String table) throws SQLException {
|
||||
try {
|
||||
return readColumnCommentsFromQuery(sqlServer2000ColumnCommentsSql(), schema, table);
|
||||
} catch (SQLException error) {
|
||||
System.err.println(
|
||||
"[sqlserver-legacy] SQL Server 2000 direct column comments query failed; trying compatibility function: "
|
||||
+ error.getMessage()
|
||||
);
|
||||
return readColumnCommentsFromQuery(sqlServer2000ColumnCommentsFunctionSql(), schema, table);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> readColumnCommentsFromQuery(String sql, String schema, String table) throws SQLException {
|
||||
Map<String, String> comments = new LinkedHashMap<>();
|
||||
try (PreparedStatement statement = requireConnection().prepareStatement(sql)) {
|
||||
statement.setString(1, schema);
|
||||
statement.setString(2, table);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
String column = resultSet.getString("column_name");
|
||||
String comment = resultSet.getString("column_comment");
|
||||
if (column != null && comment != null && !comment.trim().isEmpty()) {
|
||||
String key = column.trim().toLowerCase(Locale.ROOT);
|
||||
String property = resultSet.getString("property_name");
|
||||
if (!comments.containsKey(key) || "MS_Description".equalsIgnoreCase(property)) {
|
||||
comments.put(key, comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return comments;
|
||||
}
|
||||
|
||||
static List<ColumnInfo> mergeSqlServer2000ColumnComments(
|
||||
List<ColumnInfo> columns,
|
||||
Map<String, String> comments
|
||||
) {
|
||||
if (comments.isEmpty()) {
|
||||
return columns;
|
||||
}
|
||||
Map<String, String> normalizedComments = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> entry : comments.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
normalizedComments.put(entry.getKey().trim().toLowerCase(Locale.ROOT), entry.getValue());
|
||||
}
|
||||
}
|
||||
for (ColumnInfo column : columns) {
|
||||
String comment = normalizedComments.get(column.getName().trim().toLowerCase(Locale.ROOT));
|
||||
if (comment != null) {
|
||||
column.setComment(comment);
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
static String sqlServer2000ColumnCommentsSql() {
|
||||
return "SELECT c.name AS column_name, p.value AS column_comment, p.name AS property_name "
|
||||
+ "FROM sysobjects o JOIN sysusers u ON o.uid = u.uid "
|
||||
+ "JOIN syscolumns c ON c.id = o.id "
|
||||
+ "LEFT OUTER JOIN sysproperties p ON p.id = o.id AND p.smallid = c.colid "
|
||||
+ "WHERE u.name = ? AND o.name = ? AND o.xtype IN ('U', 'V') "
|
||||
+ "AND p.value IS NOT NULL "
|
||||
+ "ORDER BY c.colid, CASE WHEN p.name = 'MS_Description' THEN 0 ELSE 1 END";
|
||||
}
|
||||
|
||||
static String sqlServer2000ColumnCommentsFunctionSql() {
|
||||
return "SELECT objname AS column_name, CONVERT(nvarchar(4000), value) AS column_comment, "
|
||||
+ "'MS_Description' AS property_name "
|
||||
+ "FROM ::fn_listextendedproperty('MS_Description', 'user', ?, 'table', ?, 'column', default)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return super.listIndexes(metadataSchema(schema, table), table);
|
||||
String resolvedSchema = metadataSchema(schema, table);
|
||||
List<IndexInfo> indexes = super.listIndexes(resolvedSchema, table);
|
||||
return markPrimaryKeyIndex(indexes, resolvedSchema, table);
|
||||
}
|
||||
|
||||
// SQL Server names a primary-key index after its constraint (PK__<table>__<hex>
|
||||
// or a user-chosen name), so the shared JDBC metadata layer's "PRIMARY"
|
||||
// index-name convention never matches and is_primary stayed false. Resolve
|
||||
// the flag from DatabaseMetaData.getPrimaryKeys() PK_NAME instead, so table
|
||||
// cloning, the index tree badge, and DDL output see the real primary key.
|
||||
private List<IndexInfo> markPrimaryKeyIndex(List<IndexInfo> indexes, String schema, String table) {
|
||||
if (indexes.isEmpty()) {
|
||||
return indexes;
|
||||
}
|
||||
try {
|
||||
String primaryKeyName = null;
|
||||
try (java.sql.ResultSet rs = requireConnection().getMetaData().getPrimaryKeys(null, schema, table)) {
|
||||
if (rs.next()) {
|
||||
primaryKeyName = rs.getString("PK_NAME");
|
||||
}
|
||||
}
|
||||
if (primaryKeyName == null || primaryKeyName.trim().isEmpty()) {
|
||||
return indexes;
|
||||
}
|
||||
for (IndexInfo index : indexes) {
|
||||
if (primaryKeyName.equals(index.getName())) {
|
||||
index.setIs_primary(true);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Fail soft: keep the metadata-layer flags when primary-key lookup is unavailable.
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+332
-9
@@ -1,36 +1,61 @@
|
||||
package com.dbx.agent.sqlserverlegacy;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
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.SocketException;
|
||||
import java.security.Security;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
class SqlServerLegacyAgentTest {
|
||||
@Test
|
||||
void onlySqlServer8UnsupportedErrorsTriggerTheOldDriverFallback() {
|
||||
// Real mssql-jdbc prelogin rejection for SQL Server 2000
|
||||
// (R_unsupportedServerVersion, English-only resources).
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
void sqlServer2000PreloginFailuresTriggerTheOldDriverFallback() {
|
||||
// Real mssql-jdbc prelogin rejection for SQL Server 2000.
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("SQL Server version 8 is not supported by this driver.")
|
||||
));
|
||||
// Older driver wordings name the supported floor instead
|
||||
// (mssql-jdbc R_notSQLServer family).
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("This version of the driver can be used only with SQL Server 2005 or later.")
|
||||
));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("该驱动程序只能与 SQL Server 2005 或更高版本一起使用。")
|
||||
));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("该驱动程序不支持 SQL Server 8 版")
|
||||
));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("The driver does not support SQL Server 8")
|
||||
));
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("Connection failed", new SocketException("Connection reset"))
|
||||
));
|
||||
SQLException chained = new SQLException("Connection failed");
|
||||
chained.setNextException(new SQLException("驱动程序收到意外的登录前响应。"));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(chained));
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("TLS handshake failed")
|
||||
));
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("Login failed for user 'sa'")
|
||||
));
|
||||
}
|
||||
@@ -101,6 +126,101 @@ class SqlServerLegacyAgentTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ColumnCommentsEnrichJdbcColumnRemarks() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
|
||||
Arrays.asList(
|
||||
Arrays.asList("ID", "Primary identifier", "MS_Description"),
|
||||
Arrays.asList("NAME", "Custom label", "CUSTOM_PROP"),
|
||||
Arrays.asList("NAME", "Display name", "MS_Description")
|
||||
),
|
||||
null,
|
||||
null
|
||||
));
|
||||
setSqlServer2000Mode(agent, true);
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
|
||||
|
||||
Assertions.assertEquals(List.of("ID", "NAME", "CREATED_AT"), columns.stream().map(ColumnInfo::getName).toList());
|
||||
Assertions.assertEquals("Primary identifier", columns.get(0).getComment());
|
||||
// MS_Description outranks other extended properties on the same column.
|
||||
Assertions.assertEquals("Display name", columns.get(1).getComment());
|
||||
// Columns without an extended property keep their JDBC remark untouched.
|
||||
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
|
||||
Assertions.assertTrue(columns.get(0).getIs_nullable());
|
||||
Assertions.assertFalse(columns.get(2).getIs_nullable());
|
||||
Assertions.assertEquals("getdate()", columns.get(2).getColumn_default());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ColumnCommentsFallBackToCompatibilityFunction() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
|
||||
null,
|
||||
Arrays.asList(
|
||||
Arrays.asList("NAME", "Display name from extended property", "MS_Description")
|
||||
),
|
||||
null
|
||||
));
|
||||
setSqlServer2000Mode(agent, true);
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
|
||||
|
||||
// The sysproperties query is unavailable on this legacy catalog, so the
|
||||
// fn_listextendedproperty compatibility function supplies the comment.
|
||||
Assertions.assertEquals("Display name from extended property", columns.get(1).getComment());
|
||||
Assertions.assertNull(columns.get(0).getComment());
|
||||
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ColumnCommentsFailSoftWhenCatalogThrowsRuntimeError() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
TestSupport.setPrivateConnection(agent, sqlServer2000CommentConnection(
|
||||
null,
|
||||
null,
|
||||
new IllegalStateException("legacy catalog proxy failed")
|
||||
));
|
||||
setSqlServer2000Mode(agent, true);
|
||||
|
||||
// Comments are optional enrichment: a runtime failure inside the legacy
|
||||
// catalog must degrade to comment-less columns instead of failing the
|
||||
// whole column listing.
|
||||
List<ColumnInfo> columns = agent.getColumns("dbo", "USERS");
|
||||
|
||||
Assertions.assertEquals(List.of("ID", "NAME", "CREATED_AT"), columns.stream().map(ColumnInfo::getName).toList());
|
||||
Assertions.assertNull(columns.get(0).getComment());
|
||||
Assertions.assertNull(columns.get(1).getComment());
|
||||
Assertions.assertEquals("JDBC remark", columns.get(2).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ColumnCommentsMergeWithoutDiscardingJdbcMetadata() {
|
||||
ColumnInfo id = new ColumnInfo("ID", "int", false, null, true);
|
||||
ColumnInfo name = new ColumnInfo("NAME", "varchar", true, null, false);
|
||||
ColumnInfo untouched = new ColumnInfo("CREATED_AT", "datetime", false, "getdate()", false);
|
||||
untouched.setComment("JDBC remark");
|
||||
List<ColumnInfo> columns = List.of(id, name, untouched);
|
||||
Map<String, String> comments = new LinkedHashMap<>();
|
||||
comments.put("ID", "Primary identifier");
|
||||
comments.put("NAME", "Display name");
|
||||
|
||||
List<ColumnInfo> merged = SqlServerLegacyAgent.mergeSqlServer2000ColumnComments(columns, comments);
|
||||
|
||||
Assertions.assertSame(columns, merged);
|
||||
Assertions.assertEquals("Primary identifier", id.getComment());
|
||||
Assertions.assertEquals("Display name", name.getComment());
|
||||
Assertions.assertEquals("JDBC remark", untouched.getComment());
|
||||
Assertions.assertTrue(id.getIs_primary_key());
|
||||
Assertions.assertEquals("getdate()", untouched.getColumn_default());
|
||||
|
||||
comments.clear();
|
||||
comments.put("name", "Case-insensitive match");
|
||||
SqlServerLegacyAgent.mergeSqlServer2000ColumnComments(columns, comments);
|
||||
Assertions.assertEquals("Case-insensitive match", name.getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorRelaxesLegacyTlsPolicyBeforeDriverLoading() {
|
||||
String key = "jdk.tls.disabledAlgorithms";
|
||||
@@ -131,6 +251,11 @@ class SqlServerLegacyAgentTest {
|
||||
Assertions.assertEquals("SELECT 1", new SqlServerLegacyAgent().connectionValidationQuery());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotShareJdbcConnectionsAcrossLegacyAgentSessions() {
|
||||
Assertions.assertFalse(new SqlServerLegacyAgent().supportsConnectionPooling());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000AllNulCharacterPaddingBecomesEmptyString() {
|
||||
Assertions.assertEquals(
|
||||
@@ -320,4 +445,202 @@ class SqlServerLegacyAgentTest {
|
||||
SqlServerLegacyAgent.appendTableCommentDdl(baseDdl, "dbo", "Users", " ")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection shaped like a jTDS SQL Server 2000 session: the driver's own
|
||||
* JDBC metadata still reports the table columns, while column comments are
|
||||
* served by the legacy sysproperties catalog or, when that query fails, by
|
||||
* the fn_listextendedproperty compatibility function. Comment rows carry
|
||||
* (column_name, column_comment, property_name).
|
||||
*/
|
||||
private static Connection sqlServer2000CommentConnection(
|
||||
List<List<Object>> primaryCommentRows,
|
||||
List<List<Object>> fallbackCommentRows,
|
||||
RuntimeException runtimeError
|
||||
) {
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getColumns".equals(method.getName())) {
|
||||
return metadataResultSet(Arrays.asList(
|
||||
Arrays.asList("ID", "int", 1, null, null),
|
||||
Arrays.asList("NAME", "varchar", 1, null, null),
|
||||
Arrays.asList("CREATED_AT", "datetime", 0, "getdate()", "JDBC remark")
|
||||
), JDBC_COLUMN_LABELS);
|
||||
}
|
||||
if ("getPrimaryKeys".equals(method.getName())) {
|
||||
return metadataResultSet(List.of(), JDBC_COLUMN_LABELS);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getMetaData".equals(name)) {
|
||||
return metadata;
|
||||
}
|
||||
if ("prepareStatement".equals(name)) {
|
||||
String sql = (String) args[0];
|
||||
if (runtimeError != null) {
|
||||
return failingStatement(runtimeError);
|
||||
}
|
||||
if (sql.toUpperCase(Locale.ROOT).contains("SYSPROPERTIES")) {
|
||||
return primaryCommentRows == null
|
||||
? failingStatement(new SQLException("sysproperties catalog unavailable"))
|
||||
: commentStatement(primaryCommentRows);
|
||||
}
|
||||
return commentStatement(fallbackCommentRows == null ? List.of() : fallbackCommentRows);
|
||||
}
|
||||
if ("close".equals(name) || "isClosed".equals(name)) {
|
||||
return "isClosed".equals(name) ? Boolean.FALSE : null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement commentStatement(List<List<Object>> rows) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
return metadataResultSet(rows, COMMENT_LABELS);
|
||||
}
|
||||
if ("close".equals(method.getName()) || "setMaxRows".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement failingStatement(Throwable error) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
throw error;
|
||||
}
|
||||
if ("close".equals(method.getName()) || "setMaxRows".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static final Map<String, Integer> JDBC_COLUMN_LABELS = Map.of(
|
||||
"COLUMN_NAME", 0,
|
||||
"TYPE_NAME", 1,
|
||||
"NULLABLE", 2,
|
||||
"COLUMN_DEF", 3,
|
||||
"REMARKS", 4
|
||||
);
|
||||
|
||||
private static final Map<String, Integer> COMMENT_LABELS = Map.of(
|
||||
"COLUMN_NAME", 0,
|
||||
"COLUMN_COMMENT", 1,
|
||||
"PROPERTY_NAME", 2
|
||||
);
|
||||
|
||||
private static ResultSet metadataResultSet(List<List<Object>> rows, Map<String, Integer> labels) {
|
||||
int[] index = {-1};
|
||||
return proxy(ResultSet.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("next".equals(name)) {
|
||||
index[0] += 1;
|
||||
return index[0] < rows.size();
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("getString".equals(name) || "getInt".equals(name) || "getObject".equals(name)) {
|
||||
Integer position = args[0] instanceof String label ? labels.get(label.toUpperCase(Locale.ROOT)) : null;
|
||||
Object value = position == null || index[0] < 0 || index[0] >= rows.size()
|
||||
? null
|
||||
: rows.get(index[0]).get(position);
|
||||
if ("getString".equals(name)) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
if ("getInt".equals(name)) {
|
||||
return value instanceof Number number ? number.intValue() : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static void setSqlServer2000Mode(SqlServerLegacyAgent agent, boolean value) {
|
||||
try {
|
||||
Field field = SqlServerLegacyAgent.class.getDeclaredField("sqlServer2000Mode");
|
||||
field.setAccessible(true);
|
||||
field.set(agent, value);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException("Unable to set SQL Server 2000 mode", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
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);
|
||||
}
|
||||
};
|
||||
return (T) 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;
|
||||
}
|
||||
|
||||
@Test
|
||||
void listIndexesMarksPrimaryKeyByConstraintName() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getIndexInfo".equals(method.getName())) {
|
||||
return metadataResultSet(Arrays.asList(
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "id"),
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "tenant_id"),
|
||||
Arrays.asList("IX_orders_name", "name")
|
||||
), Map.of("INDEX_NAME", 0, "COLUMN_NAME", 1));
|
||||
}
|
||||
if ("getPrimaryKeys".equals(method.getName())) {
|
||||
return metadataResultSet(Arrays.asList(
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "id"),
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "tenant_id")
|
||||
), Map.of("PK_NAME", 0, "COLUMN_NAME", 1));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) ->
|
||||
"getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType())));
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("dbo", "orders");
|
||||
|
||||
List<IndexInfo> primary = indexes.stream().filter(IndexInfo::getIs_primary).toList();
|
||||
Assertions.assertEquals(1, primary.size(), indexes.toString());
|
||||
Assertions.assertEquals("PK__orders__3213E83F5A9632C8", primary.get(0).getName());
|
||||
Assertions.assertEquals(List.of("id", "tenant_id"), primary.get(0).getColumns());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user