mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
Compare commits
579
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42eafa6d6b | ||
|
|
95f4511b0d | ||
|
|
cd77b06a59 | ||
|
|
f5f58d4ae5 | ||
|
|
5ae344409a | ||
|
|
e3dfe324e8 | ||
|
|
0feb065e48 | ||
|
|
838c115a2c | ||
|
|
61939e5e46 | ||
|
|
bcbc64c635 | ||
|
|
c65a627045 | ||
|
|
440625949b | ||
|
|
f70a9b2937 | ||
|
|
a1824c9079 | ||
|
|
d022f068cd | ||
|
|
85fc95bac8 | ||
|
|
b115002d07 | ||
|
|
3deb4bda7b | ||
|
|
e797340466 | ||
|
|
fdfdd25d23 | ||
|
|
4740942e8b | ||
|
|
d219f2b8ba | ||
|
|
18980ecdc2 | ||
|
|
fad22512cd | ||
|
|
083e05d412 | ||
|
|
88ea213c35 | ||
|
|
85b18a94d6 | ||
|
|
7670e04879 | ||
|
|
fc84e09738 | ||
|
|
177b9a3b39 | ||
|
|
4a4a7603b5 | ||
|
|
6a1778eaf3 | ||
|
|
72a86b56ac | ||
|
|
9dd7d1f044 | ||
|
|
cdb4ab5c80 | ||
|
|
97e3045a7c | ||
|
|
f43a99b4fe | ||
|
|
ccac7ba14f | ||
|
|
d7df7fe3c0 | ||
|
|
2bf6af1ff0 | ||
|
|
860d901115 | ||
|
|
524a93203b | ||
|
|
a6b26ff1a6 | ||
|
|
6f6cf2f57c | ||
|
|
6b293e9eaa | ||
|
|
a7dac3434d | ||
|
|
653bf84829 | ||
|
|
ba1d5ec943 | ||
|
|
994f520df1 | ||
|
|
380f6a548b | ||
|
|
a88f8dabe7 | ||
|
|
885ccf43ba | ||
|
|
7cbef302ea | ||
|
|
76afdb2e89 | ||
|
|
ea8c2da594 | ||
|
|
1b958c4b3f | ||
|
|
0c9330e4e6 | ||
|
|
da281de1d1 | ||
|
|
f73114a381 | ||
|
|
b3fc86ac3b | ||
|
|
018fea60fa | ||
|
|
29cab5c0b8 | ||
|
|
169fc05087 | ||
|
|
69a1a4f2d4 | ||
|
|
06c3bbaa7c | ||
|
|
0d12877d89 | ||
|
|
8aca3975d1 | ||
|
|
bc6dcf8395 | ||
|
|
f5806ba961 | ||
|
|
ed3ed28d2b | ||
|
|
36aa8425e6 | ||
|
|
9c069c9cfe | ||
|
|
b612dec12c | ||
|
|
c91342ac9d | ||
|
|
ad643fcf78 | ||
|
|
f85fe496e6 | ||
|
|
03db4cad8c | ||
|
|
9f41cdc3f5 | ||
|
|
6cd2dc1c9d | ||
|
|
74aaa4f643 | ||
|
|
e7d9bd4ea8 | ||
|
|
7b1c29f3cf | ||
|
|
ac0cecd1d1 | ||
|
|
cdcefb97d5 | ||
|
|
3edafe58dd | ||
|
|
56ec883fb7 | ||
|
|
a7090cea7e | ||
|
|
03a2cd1bd7 | ||
|
|
2826bb5fc5 | ||
|
|
63adea29c1 | ||
|
|
739e805b83 | ||
|
|
c7fa862792 | ||
|
|
58e2d2978f | ||
|
|
4f56ad408d | ||
|
|
8e52e61c39 | ||
|
|
c80ef798e5 | ||
|
|
528ad2f62b | ||
|
|
33adceae7d | ||
|
|
06de88a367 | ||
|
|
4f666d9f8f | ||
|
|
7dea1ede4f | ||
|
|
ead4190584 | ||
|
|
21d6a5f719 | ||
|
|
aab0526f52 | ||
|
|
41b771a185 | ||
|
|
66e020f615 | ||
|
|
38ca03ef57 | ||
|
|
b2eb0c5f17 | ||
|
|
f39cd023f6 | ||
|
|
12b16cb026 | ||
|
|
1f4e31deef | ||
|
|
c3f1af5157 | ||
|
|
dc3f6d0fa9 | ||
|
|
c8816364f7 | ||
|
|
f468b99f76 | ||
|
|
a93f118d03 | ||
|
|
adedde8e46 | ||
|
|
d84539fc42 | ||
|
|
0453ac83e5 | ||
|
|
a486628e18 | ||
|
|
6894bb026e | ||
|
|
34fbe3510b | ||
|
|
972519ba2d | ||
|
|
42faf6ad34 | ||
|
|
67847fed2a | ||
|
|
5cfdf10e5c | ||
|
|
124ae27c0b | ||
|
|
0f87ce0b6a | ||
|
|
25feadbe37 | ||
|
|
c744598425 | ||
|
|
39505896a6 | ||
|
|
035fecd17b | ||
|
|
b799cf38df | ||
|
|
a21880ea13 | ||
|
|
d918125a04 | ||
|
|
baf5cf101f | ||
|
|
5f6e983364 | ||
|
|
bb408d7f0e | ||
|
|
13601ebe1d | ||
|
|
5eaccd3062 | ||
|
|
56ea5714af | ||
|
|
8786c76ff4 | ||
|
|
dfe7510ca0 | ||
|
|
507b6d355f | ||
|
|
19c2d6d054 | ||
|
|
55942342d5 | ||
|
|
7fee71e702 | ||
|
|
11974accdb | ||
|
|
6dd4d40234 | ||
|
|
9a49b5306c | ||
|
|
6254399add | ||
|
|
f6f88de508 | ||
|
|
9f4205be32 | ||
|
|
9262df465a | ||
|
|
66187eef70 | ||
|
|
8d61a7e944 | ||
|
|
7653e860dd | ||
|
|
6748009181 | ||
|
|
e7787b8bb8 | ||
|
|
2532e0ff59 | ||
|
|
f899004140 | ||
|
|
8f418c59a5 | ||
|
|
2540c5211c | ||
|
|
e725226f4e | ||
|
|
7c15e38b43 | ||
|
|
3825120b57 | ||
|
|
fa7c9ef49e | ||
|
|
20fc49d7db | ||
|
|
36f69f4da9 | ||
|
|
6eb8242cf7 | ||
|
|
29d7d0f62a | ||
|
|
2684e0f316 | ||
|
|
68d05ea27c | ||
|
|
4f0ce82f4a | ||
|
|
e1e0614199 | ||
|
|
eda4d3e2d5 | ||
|
|
e6ae58e989 | ||
|
|
786ccad3c4 | ||
|
|
2982646dee | ||
|
|
6c57b0ca9b | ||
|
|
b281fbd5fa | ||
|
|
d926a5cc00 | ||
|
|
53914d2af2 | ||
|
|
84758d60b3 | ||
|
|
1f7623e7f5 | ||
|
|
54b302683a | ||
|
|
26a579ba7c | ||
|
|
28b12df340 | ||
|
|
37ea6d4fc8 | ||
|
|
0d2293525e | ||
|
|
5c6030f413 | ||
|
|
1b703526ea | ||
|
|
4af7777f49 | ||
|
|
0c95c816b5 | ||
|
|
14eb349212 | ||
|
|
a812c94f67 | ||
|
|
7406cb4aac | ||
|
|
9a128d38b4 | ||
|
|
3377bf96a6 | ||
|
|
884eb047f9 | ||
|
|
235842e6a9 | ||
|
|
caa43b6f2a | ||
|
|
e9b6e5fcb8 | ||
|
|
73692376ef | ||
|
|
7407f12d07 | ||
|
|
3ad44a1811 | ||
|
|
ec42060ddf | ||
|
|
268dad2018 | ||
|
|
d170a7e205 | ||
|
|
eb6532e573 | ||
|
|
9380e98865 | ||
|
|
069adbc5ab | ||
|
|
2a09c1c2b2 | ||
|
|
c90a523e95 | ||
|
|
4489ebfcf3 | ||
|
|
381b4a9dd5 | ||
|
|
cbe30ef3a2 | ||
|
|
192eecb1ec | ||
|
|
a3fed878ca | ||
|
|
517f939b66 | ||
|
|
d6777a5ce4 | ||
|
|
75e112be4c | ||
|
|
c8123aaa5b | ||
|
|
c109522318 | ||
|
|
5076569a0e | ||
|
|
845fe37b86 | ||
|
|
9bdf6fb0a0 | ||
|
|
692c63b787 | ||
|
|
ffc2a5b008 | ||
|
|
8ad26fd0ae | ||
|
|
96eca282b6 | ||
|
|
f62f75d585 | ||
|
|
894684b1e5 | ||
|
|
952b15deca | ||
|
|
08a8d9ed77 | ||
|
|
73aee8e976 | ||
|
|
f61e216c30 | ||
|
|
274a944fa2 | ||
|
|
3d87ea5797 | ||
|
|
f0e80bc97f | ||
|
|
6f0c84556a | ||
|
|
a3e368733a | ||
|
|
786ecff8ce | ||
|
|
246df32553 | ||
|
|
04bf3be779 | ||
|
|
2ca2dda15b | ||
|
|
9f4e5643ce | ||
|
|
4ad6410ca3 | ||
|
|
7ca1b011ee | ||
|
|
de5f9a4b73 | ||
|
|
99c5ecf737 | ||
|
|
d80c8c7326 | ||
|
|
ee8cba7daa | ||
|
|
428e6bd69c | ||
|
|
9026e2de17 | ||
|
|
0818de9f9f | ||
|
|
76d34a56ac | ||
|
|
75d2570524 | ||
|
|
aa2cac1948 | ||
|
|
3fa23a6e47 | ||
|
|
b3acccac7c | ||
|
|
2f56bc7802 | ||
|
|
21f90fd0fd | ||
|
|
d70c637541 | ||
|
|
a53ff2cc73 | ||
|
|
d56bb2138f | ||
|
|
ae1804daba | ||
|
|
94f8970efd | ||
|
|
fd0e6ca314 | ||
|
|
c17fbdc8ea | ||
|
|
e63faca4cd | ||
|
|
607d3a9070 | ||
|
|
f95344367c | ||
|
|
4411e41e42 | ||
|
|
95030c353a | ||
|
|
26085752a1 | ||
|
|
3b20148a14 | ||
|
|
3883b96c14 | ||
|
|
1d3ee9e09b | ||
|
|
e32ff2ed5a | ||
|
|
bcf5313f3c | ||
|
|
8b30a42b65 | ||
|
|
b755a5ca1e | ||
|
|
e5ed89083d | ||
|
|
4d5943a784 | ||
|
|
ea8797c03c | ||
|
|
cfdad51e8e | ||
|
|
5204b43077 | ||
|
|
98970ea7d4 | ||
|
|
24523d44f6 | ||
|
|
76c4f346d1 | ||
|
|
7ca34d76a9 | ||
|
|
b06c499902 | ||
|
|
732e081ee4 | ||
|
|
7c5e9dcda0 | ||
|
|
9d8a5efedf | ||
|
|
c1948bff0a | ||
|
|
c243261125 | ||
|
|
89ed17d645 | ||
|
|
be2831eafd | ||
|
|
641a7bd71a | ||
|
|
b351c0f3b8 | ||
|
|
7be6bb59aa | ||
|
|
95e571b687 | ||
|
|
849d831ae2 | ||
|
|
f262f5c80c | ||
|
|
05a57cbfde | ||
|
|
8c20043234 | ||
|
|
bb1628187b | ||
|
|
89ea786e62 | ||
|
|
54cbb4c409 | ||
|
|
2189baacae | ||
|
|
8d19034af9 | ||
|
|
886c30aa3f | ||
|
|
720fad47e5 | ||
|
|
3353411fb6 | ||
|
|
d3dfcc9d3d | ||
|
|
504ef7e1c6 | ||
|
|
92ad833181 | ||
|
|
e67fdc6337 | ||
|
|
83e3513572 | ||
|
|
f37ce68a6b | ||
|
|
e013fffb8a | ||
|
|
64b5682660 | ||
|
|
9aa2d40aab | ||
|
|
9541aeb257 | ||
|
|
8bf9068383 | ||
|
|
ad425d66b1 | ||
|
|
67e8bf8cd3 | ||
|
|
9f26db0278 | ||
|
|
cd07675e83 | ||
|
|
e65f3eec66 | ||
|
|
ba6ca64e5d | ||
|
|
f84137cdd9 | ||
|
|
527b56ac41 | ||
|
|
026e0b539d | ||
|
|
5b2772c444 | ||
|
|
488504f636 | ||
|
|
8a8770066c | ||
|
|
c7403444ea | ||
|
|
5630f8b7f8 | ||
|
|
46366b00ae | ||
|
|
2c48928936 | ||
|
|
a597a65c86 | ||
|
|
609f6805a3 | ||
|
|
d43ba11164 | ||
|
|
74960d9fc8 | ||
|
|
33f7c19675 | ||
|
|
934d731d00 | ||
|
|
380f869e50 | ||
|
|
787899f201 | ||
|
|
63d996b48d | ||
|
|
33b4652d39 | ||
|
|
dab39c690a | ||
|
|
262a538310 | ||
|
|
9f1e297c76 | ||
|
|
376a106bc6 | ||
|
|
79dca4bea9 | ||
|
|
f995e060a8 | ||
|
|
83efdfb65e | ||
|
|
1943484a16 | ||
|
|
d69facab23 | ||
|
|
658a7a405d | ||
|
|
09c47b1851 | ||
|
|
7e324bdd01 | ||
|
|
0fce8f445f | ||
|
|
0f59ecd18d | ||
|
|
4f229a8554 | ||
|
|
4e111225ee | ||
|
|
3d9e590e94 | ||
|
|
03b50e167e | ||
|
|
7530777bac | ||
|
|
1da95288c6 | ||
|
|
090e9037a8 | ||
|
|
7f60b43b43 | ||
|
|
4e144035b1 | ||
|
|
d9338d1a35 | ||
|
|
b1ee471fb3 | ||
|
|
124093eca9 | ||
|
|
ca1ef942fa | ||
|
|
88755811ba | ||
|
|
9ca4ab8c6c | ||
|
|
ae1ee25a76 | ||
|
|
f9ee05fd7d | ||
|
|
c6159213aa | ||
|
|
0503d3b9d1 | ||
|
|
53606ffd63 | ||
|
|
f9680e4a8d | ||
|
|
45c23b1521 | ||
|
|
8025519039 | ||
|
|
32789b1d7d | ||
|
|
8124247657 | ||
|
|
c87351e0df | ||
|
|
ae86a8c73c | ||
|
|
6c380269e7 | ||
|
|
1ed598308a | ||
|
|
f1a7cdb461 | ||
|
|
1539b6233c | ||
|
|
220dfa2170 | ||
|
|
e983f2432c | ||
|
|
55ca94bd30 | ||
|
|
d58a79d6c8 | ||
|
|
f8b7ffe516 | ||
|
|
7c28b6789e | ||
|
|
67b9565979 | ||
|
|
1a57e54f17 | ||
|
|
5067514e8d | ||
|
|
38ce7b5dd2 | ||
|
|
de265287e0 | ||
|
|
25e1c6d25d | ||
|
|
3f3482525a | ||
|
|
36b372891f | ||
|
|
c3c53fee9a | ||
|
|
f19b62be0e | ||
|
|
558b22b717 | ||
|
|
4778aa05b4 | ||
|
|
760423873a | ||
|
|
9a6a2049bf | ||
|
|
fcb239abf4 | ||
|
|
b25154466a | ||
|
|
25931c1063 | ||
|
|
eafde81af1 | ||
|
|
508fd00776 | ||
|
|
2729d83eb8 | ||
|
|
a919333418 | ||
|
|
95138e5a68 | ||
|
|
bf68b93b05 | ||
|
|
1d9223eeed | ||
|
|
7fef9698ac | ||
|
|
d757238b1b | ||
|
|
ae205a318f | ||
|
|
75c9122799 | ||
|
|
320e37aa42 | ||
|
|
a90b00e77a | ||
|
|
30e86367ca | ||
|
|
235a2d2dbb | ||
|
|
c2d9610d1c | ||
|
|
dee12449d9 | ||
|
|
1bd9b821d3 | ||
|
|
a24a559160 | ||
|
|
971a95aa80 | ||
|
|
4dbbfec86f | ||
|
|
c7c54c8239 | ||
|
|
37b1c061d5 | ||
|
|
3f70763531 | ||
|
|
b10c5433da | ||
|
|
8df09c779f | ||
|
|
99d603fe15 | ||
|
|
87e3c0634a | ||
|
|
12479faf00 | ||
|
|
e224690049 | ||
|
|
36ac001d70 | ||
|
|
22c384517e | ||
|
|
90221ee3ac | ||
|
|
2c31c6cf32 | ||
|
|
02bc0cf47e | ||
|
|
2e98a5bb9f | ||
|
|
86659553a6 | ||
|
|
4fadbb9801 | ||
|
|
827c934b18 | ||
|
|
7af2ee1fce | ||
|
|
09a7d21510 | ||
|
|
b12e71455c | ||
|
|
a28b5d5280 | ||
|
|
ee2573f7db | ||
|
|
b66d7313f1 | ||
|
|
8ca612d1f7 | ||
|
|
500c0ae692 | ||
|
|
7b47b64391 | ||
|
|
024dc757a8 | ||
|
|
7bb0431470 | ||
|
|
6606a98af5 | ||
|
|
f951983914 | ||
|
|
9419e18b79 | ||
|
|
8a2241c36e | ||
|
|
e7a48bbcf5 | ||
|
|
645c351789 | ||
|
|
977ca7bd38 | ||
|
|
139281ce71 | ||
|
|
88c006ee37 | ||
|
|
c13af204dd | ||
|
|
89ecc63b9e | ||
|
|
4b48809c84 | ||
|
|
db150dfcab | ||
|
|
ce57e8d9c0 | ||
|
|
e08eda972b | ||
|
|
f47c209b15 | ||
|
|
eeeea648c1 | ||
|
|
69687c1a84 | ||
|
|
b10ecd096c | ||
|
|
72a25f6e9f | ||
|
|
a3a5f4f9bd | ||
|
|
53cd0c516e | ||
|
|
397bff26c8 | ||
|
|
fbd50acf00 | ||
|
|
01320286e8 | ||
|
|
5f0ad9217a | ||
|
|
92a29cb715 | ||
|
|
a9990d5fdb | ||
|
|
238cf81370 | ||
|
|
56b887d346 | ||
|
|
5887722a49 | ||
|
|
2edd41710d | ||
|
|
f8fb682aef | ||
|
|
252f8a0d1f | ||
|
|
951f0a21a0 | ||
|
|
1edbb7b935 | ||
|
|
e3ea4740c0 | ||
|
|
7aa69a0e5e | ||
|
|
c737fde538 | ||
|
|
a9369cdae9 | ||
|
|
cb795b94b0 | ||
|
|
080dc37ee2 | ||
|
|
d5093505af | ||
|
|
fa7dde7c03 | ||
|
|
da1094df93 | ||
|
|
aa2fe831b6 | ||
|
|
fc28c4a41e | ||
|
|
ff3c036f79 | ||
|
|
233f7f7716 | ||
|
|
4c7f8e8196 | ||
|
|
895aaa0944 | ||
|
|
6f0d34c2ba | ||
|
|
37d5ea0bd4 | ||
|
|
b498b1e63a | ||
|
|
f991d118cd | ||
|
|
81850df0d1 | ||
|
|
037d5b1ab8 | ||
|
|
615b8605a4 | ||
|
|
84f1aba6ba | ||
|
|
83c2ddaee8 | ||
|
|
bad9f2f9bd | ||
|
|
6bbb4f64fe | ||
|
|
b3707d4224 | ||
|
|
886f3283aa | ||
|
|
b634e9cd59 | ||
|
|
94f36c57d3 | ||
|
|
4cab7abf83 | ||
|
|
6a2bcdaf65 | ||
|
|
0b1d3fa5c0 | ||
|
|
11ef109e5c | ||
|
|
d9594db3fa | ||
|
|
e5b1a93359 | ||
|
|
03c02018af | ||
|
|
d3571e715e | ||
|
|
0b3d82fed5 | ||
|
|
3a3eb7dd7c | ||
|
|
30a4131bb2 | ||
|
|
8dc1297f6a | ||
|
|
1bd38f1d6e | ||
|
|
d4b4cf8279 | ||
|
|
a95762a536 | ||
|
|
41feb0ab20 | ||
|
|
0ef9388b81 | ||
|
|
87b030d626 | ||
|
|
1b5bf5066b | ||
|
|
9338e69fb7 | ||
|
|
ef9bbe2405 | ||
|
|
31a4a3a1a8 | ||
|
|
5dc11f26ca | ||
|
|
4a9620a720 | ||
|
|
2a0242ec1d | ||
|
|
59b6de2cfb | ||
|
|
6d1c3c8373 | ||
|
|
c30b198455 | ||
|
|
e3c14e8de6 | ||
|
|
b4ce02395f | ||
|
|
83651c46d6 | ||
|
|
06918c06c8 | ||
|
|
7e732a985a | ||
|
|
03fc00c33c | ||
|
|
fdb8e37a48 | ||
|
|
ef16749339 | ||
|
|
92da1bcbee | ||
|
|
f75f5eb159 | ||
|
|
c059a78bb5 | ||
|
|
247eb2b0b0 | ||
|
|
26cefca7e9 | ||
|
|
9a0355cc1e |
@@ -44,3 +44,6 @@ crates/dbx-core/assets/docs-export.css -text -diff linguist-generated
|
||||
# parser must not depend on the contributor's core.autocrlf: a CRLF checkout
|
||||
# silently rewrites all three files.
|
||||
src-tauri/tests/fixtures/** text eol=lf
|
||||
|
||||
# Preserve the upstream SQL attachment bytes used by the live MySQL regression.
|
||||
crates/dbx-core/tests/fixtures/qrtz.sql -text
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ko_fi: dbxio
|
||||
custom: ['https://t8y2.com/sponsors/', 'https://afdian.com/a/t8y2o']
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { detectIssueCommand, handleIssueCommand } from "./issue-commands.mjs";
|
||||
import { writeContributorSnapshot } from "../../docs/scripts/contributor-snapshot.mjs";
|
||||
|
||||
function workflow(name) {
|
||||
return readFileSync(new URL(`../workflows/${name}.yml`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function job(content, name) {
|
||||
const jobs = content.slice(content.indexOf("\njobs:\n") + 7);
|
||||
const definitions = [...jobs.matchAll(/^ ([\w-]+):\s*$/gm)];
|
||||
const index = definitions.findIndex((match) => match[1] === name);
|
||||
assert.ok(index >= 0, `missing job ${name}`);
|
||||
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
|
||||
}
|
||||
|
||||
function evaluateCondition(content, github) {
|
||||
const folded = content.match(/^ if: >-\n((?: [^\n]*\n)+)/m);
|
||||
const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim();
|
||||
assert.ok(expression, "missing job condition");
|
||||
const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`);
|
||||
return evaluate(github, (search, item) => String(search ?? "").toLowerCase().includes(String(item).toLowerCase()));
|
||||
}
|
||||
|
||||
function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) {
|
||||
return { repository_owner: "t8y2", event: {
|
||||
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
|
||||
comment: { body, user: { type: userType, login } },
|
||||
} };
|
||||
}
|
||||
|
||||
test("issue command prefilter admits every supported command and skips unrelated comments", async () => {
|
||||
const content = job(workflow("issue-claim"), "claim");
|
||||
for (const body of ["/claim", "please /claim this", "\t/claim\n", "/unclaim", " /unclaimed\n", "\t/close\r\n"]) {
|
||||
assert.ok(detectIssueCommand(body), body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), false, body);
|
||||
}
|
||||
for (const body of ["Thanks!", "Fixed in abc123", "", null]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), false);
|
||||
}
|
||||
for (const body of ["/claimable", "please /close", "/unclaim more", "/CLAIM"]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true);
|
||||
assert.deepEqual(await handleIssueCommand({ commentBody: body, commentUserType: "User", client: {} }), { status: "ignored" });
|
||||
}
|
||||
});
|
||||
|
||||
test("spam prefilter retains all existing malicious-link cases and never bypasses the matcher", () => {
|
||||
const content = job(workflow("spam-comment-guard"), "delete-malicious-link");
|
||||
const pattern = content.match(/grep -qiP '([^']+)'/)[1];
|
||||
const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i");
|
||||
for (const body of ["https://evil.example/update.zip", "https://evil.example/update.ZIP?download=1",
|
||||
"[update.zip](//evil.example/download)", "[update.ZIP](https://evil%2Eexample/download)",
|
||||
"[update.7z](https://evil.example/download)", "[update.exe](https://evil.example/download)",
|
||||
"[update.SCR](//evil.example/download)"]) {
|
||||
assert.equal(matcher.test(body), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { login: "t8y2" })), false);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), true);
|
||||
}
|
||||
for (const body of ["Thanks!", "https://example.com/docs", "", null]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), false);
|
||||
}
|
||||
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip",
|
||||
"https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)",
|
||||
"[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(matcher.test(body), false, body);
|
||||
}
|
||||
});
|
||||
|
||||
test("PR notification automation stays disabled while its configuration is retained", () => {
|
||||
const content = workflow("notify");
|
||||
assert.match(content, /# pull_request_target:\s+#\s+types: \[closed\]/);
|
||||
assert.match(content, /^ workflow_dispatch:/m);
|
||||
assert.doesNotMatch(content, /^ (?:pull_request_target|pull_request|issues|release|push|schedule):/m);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: true } } }), true);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: false } } }), false);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: {} } }), false);
|
||||
});
|
||||
|
||||
test("plugin checks run once before unchanged cross-platform verification", () => {
|
||||
const content = workflow("plugin-dev-host");
|
||||
const checks = job(content, "checks");
|
||||
const tests = job(content, "test");
|
||||
assert.match(checks, /runs-on: ubuntu-24\.04/);
|
||||
assert.match(checks, /npm run lint --prefix plugins\/sdk\/dev-host/);
|
||||
assert.match(checks, /npm run fmt:check --prefix plugins\/sdk\/dev-host/);
|
||||
assert.doesNotMatch(checks, /cargo|setup-go|rust-toolchain/);
|
||||
assert.match(tests, /needs: checks/);
|
||||
assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/);
|
||||
assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/);
|
||||
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc",
|
||||
"npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
|
||||
assert.ok(tests.includes(command), command);
|
||||
}
|
||||
assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/);
|
||||
});
|
||||
|
||||
test("contributor refresh is weekly and remains manually available", () => {
|
||||
const content = workflow("contributors");
|
||||
assert.match(content, /cron: "0 18 \* \* 0"/);
|
||||
assert.match(content, /workflow_dispatch:/);
|
||||
});
|
||||
|
||||
test("GitHub script selection excludes planner tests only when fast checks own them", () => {
|
||||
const content = job(workflow("ci"), "github-scripts");
|
||||
const script = content.match(/run: \|\n((?: [^\n]*\n)+)/)[1].replace(/^ /gm, "");
|
||||
const repository = new URL("../../", import.meta.url);
|
||||
const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs"));
|
||||
for (const fast of ["true", "false", ""]) {
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], {
|
||||
cwd: repository, encoding: "utf8", env: { ...process.env, FAST_CHECKS: fast },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const selected = result.stdout.trim().split("\n").slice(1).sort();
|
||||
const expected = files.filter((file) => fast !== "true" || !file.startsWith("ci-")).map((file) => `.github/scripts/${file}`).sort();
|
||||
assert.deepEqual(selected, expected);
|
||||
assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs"));
|
||||
}
|
||||
});
|
||||
|
||||
async function snapshotFixture(context) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "dbx-contributor-snapshot-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
return join(directory, "data", "contributors.json");
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10,
|
||||
contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
|
||||
}
|
||||
|
||||
test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
const original = snapshot();
|
||||
assert.equal(await writeContributorSnapshot(outputPath, original), true);
|
||||
const originalBytes = await readFile(outputPath, "utf8");
|
||||
assert.equal(originalBytes, `${JSON.stringify(original, null, 2)}\n`);
|
||||
const refreshed = { ...original, generatedAt: "2026-10-08T18:00:00.000Z" };
|
||||
assert.equal(await writeContributorSnapshot(outputPath, refreshed), false);
|
||||
assert.equal(await readFile(outputPath, "utf8"), originalBytes);
|
||||
assert.equal(refreshed.generatedAt, "2026-10-08T18:00:00.000Z");
|
||||
});
|
||||
|
||||
test("actual contributor, star and repository changes update the snapshot", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
const original = snapshot();
|
||||
for (const updated of [{ ...original, stars: 11 }, { ...original, repository: "other/dbx" },
|
||||
{ ...original, contributors: [{ ...original.contributors[0], commits: 4 }] },
|
||||
{ ...original, contributors: [] }]) {
|
||||
await writeContributorSnapshot(outputPath, original);
|
||||
const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" };
|
||||
assert.equal(await writeContributorSnapshot(outputPath, refreshed), true);
|
||||
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), refreshed);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid contributor snapshots and filesystem errors are not silently overwritten", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
await writeContributorSnapshot(outputPath, snapshot());
|
||||
await writeFile(outputPath, "invalid JSON");
|
||||
await assert.rejects(writeContributorSnapshot(outputPath, snapshot()), SyntaxError);
|
||||
assert.equal(await readFile(outputPath, "utf8"), "invalid JSON");
|
||||
await assert.rejects(writeContributorSnapshot(join(outputPath, "invalid"), snapshot()), { code: "ENOTDIR" });
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function changeBase({ eventName, before, root = process.cwd() }) {
|
||||
if (eventName === "pull_request") {
|
||||
const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], {
|
||||
cwd: root, encoding: "utf8", stdio: "pipe",
|
||||
}).trim().split(/\s+/);
|
||||
if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents");
|
||||
return commits[1];
|
||||
}
|
||||
if (!before || /^0+$/.test(before)) return null;
|
||||
if (!/^[0-9a-f]{40}$/.test(before)) throw new Error("Invalid push base SHA");
|
||||
return before;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const base = changeBase({ eventName: process.env.GITHUB_EVENT_NAME, before: process.env.BEFORE_SHA });
|
||||
if (base) console.log(base);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { changeBase } from "./ci-change-base.mjs";
|
||||
import { changedPaths, planCi } from "./ci-plan.mjs";
|
||||
|
||||
function mergeFixture(context) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-change-base-"));
|
||||
context.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "source");
|
||||
mkdirSync(root);
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
|
||||
const write = (file, content) => {
|
||||
const filename = path.join(root, file);
|
||||
mkdirSync(path.dirname(filename), { recursive: true });
|
||||
writeFileSync(filename, content);
|
||||
};
|
||||
const commit = (message) => {
|
||||
git("add", ".");
|
||||
git("-c", "core.hooksPath=/dev/null", "commit", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
};
|
||||
git("init", "--initial-branch=main");
|
||||
git("config", "user.email", "fixture@example.invalid");
|
||||
git("config", "user.name", "CI Fixture");
|
||||
write("crates/dbx-core/tests/old.rs", "original source\n");
|
||||
write("crates/dbx-core/tests/deleted.rs", "removed source\n");
|
||||
write("crates/dbx-web/src/routes/transfer.rs", "original route\n");
|
||||
const staleBase = commit("baseline");
|
||||
git("checkout", "-b", "pull-request");
|
||||
git("mv", "crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/renamed\nfile.rs");
|
||||
git("rm", "crates/dbx-core/tests/deleted.rs");
|
||||
write("crates/dbx-core/tests/runtime_diagnostic_history.rs", "diagnostic test\n");
|
||||
write("crates/dbx-web/src/routes/transfer.rs", "updated route\n");
|
||||
const pullRequestHead = commit("PR diagnostic changes");
|
||||
git("checkout", "main");
|
||||
write(".github/workflows/ci.yml", "main CI changes\n");
|
||||
write("agents/drivers/zookeeper/main.go", "main native Agent changes\n");
|
||||
write("agents/drivers/oceanbase-oracle/src/main/java/Agent.java", "main JDBC changes\n");
|
||||
const actualBase = commit("main advances while PR remains open");
|
||||
git("-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "pull-request", "-m", "synthetic PR test merge");
|
||||
return { directory, root, staleBase, actualBase, pullRequestHead };
|
||||
}
|
||||
|
||||
test("PR routing uses the exact test merge first parent, excluding unrelated main changes", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const base = changeBase({ eventName: "pull_request", before: fixture.staleBase, root: fixture.root });
|
||||
assert.equal(base, fixture.actualBase);
|
||||
assert.notEqual(base, fixture.pullRequestHead);
|
||||
const files = changedPaths(base, fixture.root);
|
||||
assert.deepEqual(files.toSorted(), [
|
||||
"crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs",
|
||||
"crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs",
|
||||
].toSorted());
|
||||
const metadata = {
|
||||
workspace_members: ["dbx-core", "dbx-web", "dbx"],
|
||||
packages: [
|
||||
{ id: "dbx-core", name: "dbx-core", manifest_path: path.join(fixture.root, "crates/dbx-core/Cargo.toml"), dependencies: [] },
|
||||
{ id: "dbx-web", name: "dbx-web", manifest_path: path.join(fixture.root, "crates/dbx-web/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
|
||||
{ id: "dbx", name: "dbx", manifest_path: path.join(fixture.root, "src-tauri/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
|
||||
],
|
||||
};
|
||||
const corrected = planCi({ files, metadata, root: fixture.root });
|
||||
assert.equal(corrected.rust_full, false);
|
||||
assert.deepEqual(corrected.rust_matrix.include.map((entry) => entry.group), ["application"]);
|
||||
assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]);
|
||||
assert.equal(corrected.agent_java, false);
|
||||
assert.deepEqual(corrected.agent_go.include, []);
|
||||
assert.deepEqual(corrected.agent_integration.include, []);
|
||||
assert.equal(corrected.windows_standard, true);
|
||||
assert.equal(corrected.windows_win7_bundle, false);
|
||||
const staleFiles = changedPaths(fixture.staleBase, fixture.root);
|
||||
assert.ok(staleFiles.includes(".github/workflows/ci.yml"));
|
||||
assert.ok(staleFiles.includes("agents/drivers/zookeeper/main.go"));
|
||||
const polluted = planCi({ files: staleFiles, metadata, root: fixture.root });
|
||||
assert.equal(polluted.rust_full, true);
|
||||
assert.equal(polluted.agent_java, true);
|
||||
assert.ok(polluted.agent_go.include.length > 0);
|
||||
assert.ok(polluted.agent_integration.include.length > 0);
|
||||
});
|
||||
|
||||
test("a depth-two checkout preserves the PR parents and supports the base CLI", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const checkout = path.join(fixture.directory, "depth-two");
|
||||
execFileSync("git", ["clone", "--depth=2", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
|
||||
assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase);
|
||||
assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root));
|
||||
const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], {
|
||||
cwd: checkout, encoding: "utf8", env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
|
||||
});
|
||||
assert.equal(output.trim(), fixture.actualBase);
|
||||
});
|
||||
|
||||
test("PR base detection rejects a nonmerge commit and a depth-one checkout", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const checkout = path.join(fixture.directory, "depth-one");
|
||||
execFileSync("git", ["clone", "--depth=1", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
|
||||
assert.throws(() => changeBase({ eventName: "pull_request", root: checkout }), /merge commit and both parents/);
|
||||
execFileSync("git", ["checkout", "--detach", fixture.pullRequestHead], { cwd: fixture.root, stdio: "pipe" });
|
||||
assert.throws(() => changeBase({ eventName: "pull_request", root: fixture.root }), /merge commit and both parents/);
|
||||
});
|
||||
|
||||
test("push routing preserves the event before SHA even when HEAD is a merge", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
assert.equal(changeBase({ eventName: "push", before: fixture.staleBase, root: fixture.root }), fixture.staleBase);
|
||||
assert.equal(changeBase({ eventName: "push", before: fixture.pullRequestHead, root: fixture.root }), fixture.pullRequestHead);
|
||||
for (const before of [undefined, "", "0".repeat(40)]) {
|
||||
assert.equal(changeBase({ eventName: "push", before }), null);
|
||||
}
|
||||
for (const before of ["not-a-ref", "HEAD", "a".repeat(39), "a".repeat(41)]) {
|
||||
assert.throws(() => changeBase({ eventName: "push", before }), /Invalid push base SHA/);
|
||||
}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ export const rustGroups = {
|
||||
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
|
||||
"dbx-sqlite-worker",
|
||||
],
|
||||
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp"],
|
||||
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
|
||||
};
|
||||
|
||||
export const goAgents = [
|
||||
|
||||
@@ -9,7 +9,7 @@ export function gateFailures(needs, mode) {
|
||||
const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"];
|
||||
if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"];
|
||||
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
|
||||
if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) {
|
||||
return ["missing or invalid job selection outputs"];
|
||||
|
||||
+94
-11
@@ -2,19 +2,48 @@
|
||||
|
||||
Paths in this guide are relative to `.github/`.
|
||||
|
||||
`workflows/ci.yml` retains one workflow and one cancellation group. Splitting jobs
|
||||
does not change release workflows or branch-protection settings. The existing
|
||||
`workflows/ci.yml` retains one workflow. Splitting jobs does not change release
|
||||
workflows or branch-protection settings. The existing
|
||||
`rust` and `agents` aggregate check names remain; `ci` additionally summarizes all
|
||||
selected jobs. None of these gates accepts a failed, cancelled, missing, or
|
||||
unexpectedly skipped prerequisite. The gates are guarded by
|
||||
`if: always() && !cancelled()`: on a superseded commit a newer push cancels the
|
||||
run, and `always()` alone would still execute the gate and report those
|
||||
cancellations as a failure of the commit that no longer matters.
|
||||
`if: always() && !cancelled()`: on a cancelled run, `always()` alone would still
|
||||
execute the gate and report cancelled prerequisites as a failure.
|
||||
|
||||
## Contributor concurrency
|
||||
|
||||
PRs from anyone other than the repository owner share main `CI` concurrency
|
||||
groups keyed by the PR author's stable numeric user ID and the parity of the PR
|
||||
number (`pr-author-<id>-even` / `pr-author-<id>-odd`). At most two runs per author
|
||||
execute at a time — one per parity group — and other authors have independent
|
||||
groups. Actions expressions have no modulo, so parity is read from the PR number's
|
||||
last digit with `endsWith`. Two PRs of the same parity still queue behind each other.
|
||||
The PR author, not the trigger actor or author association, determines this
|
||||
policy, so a maintainer retrigger cannot bypass contributor limits.
|
||||
`cancel-in-progress: false` preserves the executing PR, and `queue: max` retains
|
||||
up to 100 pending runs rather than replacing earlier pending PRs. Further runs
|
||||
beyond that limit are cancelled by GitHub. Updates to the same contributor PR
|
||||
also enqueue separate runs; this policy does not deduplicate superseded commits.
|
||||
|
||||
The repository owner (`t8y2` here, compared with `github.repository_owner`) is
|
||||
exempt from author-level limits. Owner PRs retain one cancellation group per PR,
|
||||
so separate owner PRs run independently and a new commit cancels that PR's old
|
||||
run. Main pushes retain their independent ref-based cancellation group. These
|
||||
paths use `queue: single`, never `queue: max` with in-progress cancellation.
|
||||
|
||||
This limits main CI workflow runs, not the runner count inside one selected PR.
|
||||
Other PR workflows and all release workflows remain unchanged. Existing runs
|
||||
retain their original workflow configuration until a new event uses this policy.
|
||||
|
||||
## Selection
|
||||
|
||||
`scripts/ci-plan.mjs` reads Cargo workspace metadata and the exact event base/head
|
||||
diff. Renames include both paths and deleted paths remain visible. Rust package
|
||||
`scripts/ci-change-base.mjs` uses the first parent of the checked-out PR test merge
|
||||
as its base, not the potentially stale base SHA in the PR event. A depth-two
|
||||
checkout preserves both parents; a missing merge parent fails change detection.
|
||||
Pushes use the exact event `before` SHA. `paths-filter` uses Git, with an empty API
|
||||
token and explicit base/head, so its selection matches `scripts/ci-plan.mjs`
|
||||
rather than a mutable PR file list. An unavailable push diff keeps conservative
|
||||
planner coverage. Renames include both paths and deleted paths remain visible. Rust package
|
||||
selection follows reverse dependencies, including build and test dependencies.
|
||||
Connection-type descriptors belong to `dbx-types`; dialect descriptors belong to
|
||||
`dbx-sql`. A changed core dependency also selects the standalone DuckDB Agent tests,
|
||||
@@ -22,7 +51,10 @@ whose development dependency is `dbx-core`.
|
||||
|
||||
- Ordinary Rust PRs select foundation, driver, and application test groups from
|
||||
`scripts/ci-config.mjs`. Each group contains several packages, not one runner per
|
||||
crate. At most three Rust test groups run concurrently.
|
||||
crate. The application group includes `dbx-tauri-consul` and `dbx-tauri-schema`.
|
||||
At most three Rust test groups run concurrently; a contract checks the groups
|
||||
against actual workspace metadata so extracted crates cannot silently force
|
||||
every PR into the full-workspace fallback.
|
||||
- Main pushes, shared Cargo manifests/locks, toolchain/config/vendor changes, CI
|
||||
infrastructure changes, an unknown workspace member, or an unavailable diff
|
||||
select the original full-workspace test lane instead of duplicating it with all
|
||||
@@ -34,8 +66,33 @@ whose development dependency is `dbx-core`.
|
||||
cases. Shared Agent inputs conservatively select all existing Agent checks.
|
||||
Windows DuckDB packaging is selected by its own inputs or shared build/lock
|
||||
changes, not every change to its development dependency's source.
|
||||
- Existing frontend, package, Windows, JDBC, offline-payload and Nix jobs retain
|
||||
their commands and path filters.
|
||||
- Standard Windows compilation follows desktop reverse dependencies, including
|
||||
ordinary core/driver source changes. Full Win7 packaging runs for Cargo
|
||||
manifests/locks, toolchain/config/vendor changes, platform/update/installer
|
||||
code, Tauri configuration, Win7 fixtures/scripts and release/CI workflow inputs.
|
||||
Unknown Rust paths, unassigned Rust workspace members and unavailable diffs
|
||||
retain both Windows jobs. Other desktop source and test changes keep standard
|
||||
Windows compilation without repeating the full Win7 installer build. The final
|
||||
gate validates each Windows selection independently; release builds are unchanged.
|
||||
- Existing frontend, package, JDBC, offline-payload and Nix jobs retain their
|
||||
commands and path filters, except for the snapshot-only routing below.
|
||||
|
||||
Contributor snapshots refresh weekly, at 02:00 Monday in Asia/Shanghai, and can
|
||||
still be refreshed manually. An unchanged snapshot keeps its existing timestamp
|
||||
and creates no commit. A diff containing only `docs/data/contributors.json` skips
|
||||
desktop frontend validation, but the docs deployment still rebuilds the site.
|
||||
Mixed diffs and unavailable diffs keep the existing validation routing.
|
||||
|
||||
When `fast-checks` is selected, it owns the CI planner and gate tests before
|
||||
expensive dependent jobs start. `github-scripts` omits that subset in these runs,
|
||||
but runs every GitHub script test when the fast-check lane is not selected.
|
||||
|
||||
Issue command and spam jobs reject irrelevant comments before allocating a
|
||||
runner. Their existing command parser and malicious-link matcher remain the
|
||||
authoritative checks. PR notification triggers are commented out; release
|
||||
notifications in the publishing workflow are unchanged. Plugin development
|
||||
host lint and formatting run once on Linux before the Linux, macOS and Windows
|
||||
runtime, Rust and package checks; cross-platform coverage is unchanged.
|
||||
|
||||
Changes to the planner itself select the broad checks. Missing/invalid planning
|
||||
data fails the aggregate gates rather than producing a successful skip.
|
||||
@@ -70,7 +127,9 @@ incompatible SQLite backends.
|
||||
| `agent-integration` | Sixteen existing engine/version/authentication cases | 4 |
|
||||
|
||||
Matrix jobs set `fail-fast: false`, so one failure does not conceal results from
|
||||
the other selected cases. Live test predicates, readiness checks, time limits,
|
||||
the other selected cases. Go and live matrix parallelism is bounded at four each
|
||||
to reduce bursts competing with other workflows; this is not a reservation or a
|
||||
priority guarantee for release jobs. Live test predicates, readiness checks, time limits,
|
||||
credentials for disposable fixtures, and failure cleanup are retained.
|
||||
Native, Java and live jobs wait for fast checks and Agent source validation before
|
||||
starting expensive builds or containers.
|
||||
@@ -86,6 +145,30 @@ and Java rather than Docker; those matrix entries explicitly retain Java 21.
|
||||
| Cassandra | 3.11.19, 5.0.6 |
|
||||
| RabbitMQ | 3.13, 4.3 |
|
||||
|
||||
## Rust caches and build timings
|
||||
|
||||
Linux package, clippy, Rust test and native Agent jobs use the same pinned sccache
|
||||
compiler wrappers on main and fork PRs. Main uses the existing S3 backend; jobs
|
||||
without repository secrets use sccache's local disk backend. Only backend settings
|
||||
are conditional: changing `CC` or `CXX` invalidates native build-script fingerprints
|
||||
even if a target archive was restored. The cache key retains the real compiler
|
||||
environment; only the CI selection variables are excluded. Full/fast features and
|
||||
test-group selection remain unchanged and can still require additional builds.
|
||||
|
||||
All Rust target caches use the `v1-rust-complete` namespace and save only after a
|
||||
successful job, from main. This bypasses old partial snapshots without deleting
|
||||
caches or changing the S3 namespace. A cancelled or failed first build must not
|
||||
publish a shared snapshot: rust-cache does not update an exact cache hit, so later
|
||||
successful builds would otherwise keep restoring the unfinished snapshot.
|
||||
The first successful main run must populate the new namespace before fork PRs can
|
||||
reuse it. Target caching is not a per-commit snapshot of workspace artifacts.
|
||||
|
||||
Clippy, nextest and native Agent nextest builds emit Cargo HTML timing reports.
|
||||
Their `DBX-*-cargo-timings` artifacts are retained for three days, including after
|
||||
failures when a report exists, but uploads do not delay cancelled runs. Inspect
|
||||
these reports alongside sccache statistics: its hit rate excludes unsupported
|
||||
crate types and does not measure the time spent compiling and linking test binaries.
|
||||
|
||||
## Validation
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
+14
-15
@@ -17,12 +17,14 @@ const win7InfrastructureFiles = new Set([
|
||||
"src-tauri/build.rs",
|
||||
]);
|
||||
const win7InfrastructurePrefixes = [
|
||||
".cargo/",
|
||||
"rust-toolchain",
|
||||
".github/fixtures/win7-",
|
||||
"src-tauri/windows/nsis/",
|
||||
"vendor/wry/",
|
||||
"vendor/webview2-com-sys/",
|
||||
"vendor/ctor/",
|
||||
"vendor/dirs-sys/",
|
||||
"vendor/pageant/",
|
||||
"src-tauri/src/commands/update",
|
||||
"crates/dbx-core/src/host/update",
|
||||
"crates/dbx-platform/",
|
||||
"vendor/",
|
||||
];
|
||||
|
||||
function expandAffectedPackages(packages, members, affected) {
|
||||
@@ -93,7 +95,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
if (full) for (const pkg of packages) affected.add(pkg.name);
|
||||
expandAffectedPackages(packages, members, affected);
|
||||
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file)
|
||||
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)));
|
||||
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix))
|
||||
|| /^src-tauri\/tauri.*\.json$/.test(file));
|
||||
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock")
|
||||
|| files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
|
||||
const windowsWin7Reasons = {
|
||||
@@ -101,6 +104,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
dependency_input: win7DependencyChanged,
|
||||
desktop_dependency: win7Affected.has("dbx"),
|
||||
unknown_rust: unknownRust,
|
||||
unknown_member: rust && unknownMember,
|
||||
};
|
||||
const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean);
|
||||
const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups)
|
||||
@@ -128,6 +132,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
const java = allAgents || javaDriverChanges;
|
||||
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
|
||||
return {
|
||||
contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"),
|
||||
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
|
||||
affected_packages: [...affected].sort(),
|
||||
agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix },
|
||||
@@ -135,6 +140,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0,
|
||||
duckdb_changed: nativeChanges.has("duckdb"),
|
||||
duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")),
|
||||
windows_standard: windowsWin7Candidate,
|
||||
windows_win7_bundle: win7InfrastructureChanged || win7DependencyChanged || unknownRust || (rust && unknownMember),
|
||||
windows_win7_candidate: windowsWin7Candidate,
|
||||
windows_win7_affected_packages: [...win7Affected].sort(),
|
||||
windows_win7_reasons: windowsWin7Reasons,
|
||||
@@ -153,13 +160,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
|
||||
const outputs = { ...plan, plan };
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join(""));
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
...plan,
|
||||
win7_routing_comparison: {
|
||||
current: process.env.WIN7_CURRENT === "true",
|
||||
candidate: plan.windows_win7_candidate,
|
||||
affected_packages: plan.windows_win7_affected_packages,
|
||||
reasons: plan.windows_win7_reasons,
|
||||
},
|
||||
}, null, 2));
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ const graph = {
|
||||
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
|
||||
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
|
||||
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
|
||||
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp"],
|
||||
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
|
||||
"dbx-tauri-consul": ["dbx-core"], "dbx-tauri-schema": ["dbx-core"],
|
||||
};
|
||||
const metadata = {
|
||||
workspace_members: Object.keys(graph),
|
||||
@@ -52,12 +53,22 @@ test("the planner uses the runner stable Cargo only for metadata", () => {
|
||||
assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]);
|
||||
assert.equal(invocation.options.cwd, "/workspace");
|
||||
});
|
||||
// Keep the gate fixtures in sync with ci-gate.mjs routedJobs. Both Windows jobs
|
||||
// share the windows_win7_bundle routing output.
|
||||
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
|
||||
|
||||
test("only an exact contributor snapshot diff skips desktop frontend validation", () => {
|
||||
for (const eventName of ["pull_request", "push"]) {
|
||||
assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true);
|
||||
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"],
|
||||
["docs/data/contributors.json", "apps/desktop/src/main.ts"],
|
||||
["docs/data/contributors.json", "docs/lib/contributorActivity.ts"],
|
||||
["docs/data/contributors.json", "Cargo.lock"]]) {
|
||||
assert.equal(plan(files, { eventName }).contributor_snapshot_only, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("foundation changes select transitive consumers and standalone DuckDB", () => {
|
||||
const result = plan(["crates/dbx-types/src/lib.rs"]);
|
||||
assert.deepEqual(groups(result), ["foundation", "drivers", "application"]);
|
||||
@@ -85,6 +96,21 @@ test("Win7 candidate follows the desktop package dependency graph", () => {
|
||||
assert.equal(plan(["crates/dbx-cli/src/main.rs"]).windows_win7_candidate, false);
|
||||
});
|
||||
|
||||
test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => {
|
||||
for (const file of ["crates/dbx-core/src/query/mod.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs",
|
||||
"crates/dbx-drivers/src/lib.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-driver-mysql/tests/query.rs",
|
||||
"src-tauri/src/commands/query.rs", "crates/dbx-tauri-consul/src/lib.rs", "crates/dbx-tauri-schema/src/lib.rs"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_standard, true, file);
|
||||
assert.equal(result.windows_win7_bundle, false, file);
|
||||
}
|
||||
for (const file of ["crates/dbx-cli/src/main.rs", "crates/dbx-web/src/routes/transfer.rs", "docs/README.md"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_standard, false, file);
|
||||
assert.equal(result.windows_win7_bundle, false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
|
||||
for (const file of [
|
||||
"src-tauri/src/commands/update.rs",
|
||||
@@ -114,20 +140,45 @@ test("Win7 infrastructure changes remain candidates outside the Cargo graph", ()
|
||||
"vendor/ctor/src/lib.rs",
|
||||
"vendor/dirs-sys/src/lib.rs",
|
||||
"vendor/pageant/src/lib.rs",
|
||||
".cargo/config.toml",
|
||||
"rust-toolchain.toml",
|
||||
".github/fixtures/win7-imports/dll.json",
|
||||
"src-tauri/tauri.conf.json",
|
||||
"src-tauri/tauri.windows.conf.json",
|
||||
"src-tauri/src/commands/update.rs",
|
||||
"crates/dbx-core/src/host/update.rs",
|
||||
"crates/dbx-platform/src/lib.rs",
|
||||
]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_win7_candidate, true, file);
|
||||
assert.equal(result.windows_standard, true, file);
|
||||
assert.equal(result.windows_win7_bundle, true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("Win7 dependency inputs fail open", () => {
|
||||
for (const file of ["Cargo.toml", "Cargo.lock", "src-tauri/Cargo.toml", "crates/dbx-cli/Cargo.toml"]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
assert.equal(plan([file]).windows_standard, true, file);
|
||||
assert.equal(plan([file]).windows_win7_bundle, true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown Rust changes and unknown diffs remain Win7 candidates", () => {
|
||||
assert.equal(plan(["crates/new-engine/src/lib.rs"]).windows_win7_candidate, true);
|
||||
assert.equal(plan(null).windows_win7_candidate, true);
|
||||
for (const files of [["crates/new-engine/src/lib.rs"], null]) {
|
||||
const result = plan(files);
|
||||
assert.equal(result.windows_win7_candidate, true);
|
||||
assert.equal(result.windows_standard, true);
|
||||
assert.equal(result.windows_win7_bundle, true);
|
||||
}
|
||||
const fixture = structuredClone(metadata);
|
||||
fixture.workspace_members.push("new-engine");
|
||||
fixture.packages.push({ id: "new-engine", name: "new-engine", manifest_path: `${root}/crates/new-engine/Cargo.toml`, dependencies: [] });
|
||||
const result = plan(["crates/dbx-web/src/lib.rs"], { metadata: fixture });
|
||||
assert.equal(result.windows_standard, true);
|
||||
assert.equal(result.windows_win7_bundle, true);
|
||||
assert.equal(result.windows_win7_reasons.unknown_member, true);
|
||||
assert.equal(plan(["docs/README.md"], { metadata: fixture }).windows_win7_bundle, false);
|
||||
});
|
||||
|
||||
test("unrelated CI inputs do not become Win7 candidates through full Rust coverage", () => {
|
||||
@@ -152,6 +203,7 @@ test("Win7 candidate reports each routing reason", () => {
|
||||
dependency_input: false,
|
||||
desktop_dependency: false,
|
||||
unknown_rust: false,
|
||||
unknown_member: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +304,22 @@ test("test groups partition every current workspace package exactly once", () =>
|
||||
assert.deepEqual(selected.toSorted(), Object.keys(graph).toSorted());
|
||||
});
|
||||
|
||||
test("Rust groups cover the actual workspace metadata, including extracted Tauri crates", () => {
|
||||
const repository = path.resolve(import.meta.dirname, "../..");
|
||||
const actual = cargoMetadata(repository);
|
||||
const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name);
|
||||
assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted());
|
||||
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"],
|
||||
metadata: actual, root: repository });
|
||||
assert.equal(result.rust_groups_known, true);
|
||||
assert.equal(result.rust_full, false);
|
||||
assert.deepEqual(groups(result), ["application"]);
|
||||
assert.deepEqual(result.agent_rust.include, [{ driver: "duckdb" }]);
|
||||
assert.equal(result.agent_java, false);
|
||||
assert.equal(result.agent_go.include.length, 0);
|
||||
assert.equal(result.agent_integration.include.length, 0);
|
||||
});
|
||||
|
||||
test("workspace Cargo flags preserve full versus fast coverage and strict clippy", () => {
|
||||
for (const mode of ["fast", "full"]) {
|
||||
const command = rustCommand("test", "workspace", mode);
|
||||
@@ -277,8 +345,11 @@ test("nextest, doctests and coverage select identical packages and features in e
|
||||
const nextest = rustCommand("test", group, mode);
|
||||
const doctest = rustCommand("doctest", group, mode);
|
||||
assert.deepEqual(nextest.slice(0, 3), ["nextest", "run", "--no-fail-fast"]);
|
||||
assert.deepEqual(doctest, ["test", "--doc", ...nextest.slice(3)]);
|
||||
assert.deepEqual(rustCommand("tree", group, mode), ["tree", ...nextest.slice(3)]);
|
||||
const selection = nextest.slice(3).filter((argument) => argument !== "--timings");
|
||||
assert.deepEqual(doctest, ["test", "--doc", ...selection]);
|
||||
assert.deepEqual(rustCommand("tree", group, mode), ["tree", ...selection]);
|
||||
assert.ok(nextest.includes("--timings"));
|
||||
assert.ok(rustCommand("clippy", "workspace", mode).includes("--timings"));
|
||||
assert.ok(nextest.includes("--no-default-features"));
|
||||
assert.ok(nextest.includes("--locked"));
|
||||
}
|
||||
@@ -379,6 +450,7 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
|
||||
needs.changes.outputs[output] = "false";
|
||||
}
|
||||
needs.changes.outputs.windows_win7_bundle = "true";
|
||||
needs.changes.outputs.windows_standard = "true";
|
||||
needs["windows-standard-check"].result = "success";
|
||||
needs["windows-win7-bundle"].result = "success";
|
||||
assert.deepEqual(gateFailures(needs, "all"), []);
|
||||
@@ -390,6 +462,26 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
|
||||
assert.ok(gateFailures(needs, "all").length);
|
||||
});
|
||||
|
||||
test("the final gate independently requires standard Windows compilation without Win7 packaging", () => {
|
||||
const needs = results(["crates/dbx-core/src/query/mod.rs"]);
|
||||
const selected = plan(["crates/dbx-core/src/query/mod.rs"]);
|
||||
needs.rust = needs.agents = { result: "success" };
|
||||
for (const [job, output] of Object.entries(routedJobs)) {
|
||||
needs.changes.outputs[output] = String(selected[output] ?? false);
|
||||
needs[job] = { result: selected[output] ? "success" : "skipped" };
|
||||
}
|
||||
assert.equal(needs.changes.outputs.windows_standard, "true");
|
||||
assert.equal(needs.changes.outputs.windows_win7_bundle, "false");
|
||||
assert.deepEqual(gateFailures(needs, "all"), []);
|
||||
for (const result of ["failure", "skipped", "cancelled"]) {
|
||||
needs["windows-standard-check"].result = result;
|
||||
assert.ok(gateFailures(needs, "all").some((failure) => failure.includes("windows-standard-check")));
|
||||
}
|
||||
needs["windows-standard-check"].result = "success";
|
||||
delete needs.changes.outputs.windows_standard;
|
||||
assert.ok(gateFailures(needs, "all").length);
|
||||
});
|
||||
|
||||
test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => {
|
||||
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])),
|
||||
...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
|
||||
|
||||
@@ -8,21 +8,21 @@ export function rustCommand(action, group, mode) {
|
||||
|| (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
|
||||
throw new Error(`Unknown Rust CI configuration: ${action}/${group}/${mode}`);
|
||||
}
|
||||
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher"];
|
||||
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "sqlserver-native-attention"];
|
||||
if (mode === "full") capabilityFeatures.push("system-fonts");
|
||||
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
|
||||
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
|
||||
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
|
||||
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
|
||||
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
|
||||
"dbx-driver-support/test-support",
|
||||
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
|
||||
] : appFeatures;
|
||||
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
|
||||
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
|
||||
return [...command, ...packages, "--locked", ...(action === "clippy" ? ["--all-targets"] : []),
|
||||
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []),
|
||||
"--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const workflow = readFileSync(new URL("../workflows/ci.yml", import.meta.url), "utf8");
|
||||
@@ -12,6 +15,65 @@ function job(name, content = workflow) {
|
||||
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
|
||||
}
|
||||
|
||||
function concurrencySettings(github) {
|
||||
const block = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1];
|
||||
assert.ok(block, "missing workflow concurrency");
|
||||
const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)]));
|
||||
const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase());
|
||||
return Object.fromEntries(block.trim().split("\n").map((line) => {
|
||||
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
|
||||
const evaluate = (expression) =>
|
||||
new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
|
||||
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
|
||||
return [setting, expressions.length === 1 && expressions[0][0] === value
|
||||
? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
|
||||
}));
|
||||
}
|
||||
|
||||
function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) {
|
||||
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName,
|
||||
event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
|
||||
}
|
||||
|
||||
test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => {
|
||||
const even = concurrencySettings(concurrencyContext());
|
||||
assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" });
|
||||
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }),
|
||||
concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
|
||||
assert.deepEqual(concurrencySettings(context), even);
|
||||
}
|
||||
for (const number of [101, 11849, 11853, 7]) {
|
||||
assert.deepEqual(concurrencySettings(concurrencyContext({ number })), { ...even, group: "CI-pr-author-101-odd" }, String(number));
|
||||
}
|
||||
for (const number of [2, 4, 6, 8, 11850]) assert.equal(concurrencySettings(concurrencyContext({ number })).group, even.group);
|
||||
assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, even.group);
|
||||
});
|
||||
|
||||
test("repository-owner PRs remain independent and superseded runs are cancelled only within a PR", () => {
|
||||
const first = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2" }));
|
||||
const second = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", ref: "refs/pull/200/merge" }));
|
||||
assert.deepEqual(first, { group: "CI-refs/pull/100/merge", "cancel-in-progress": true, queue: "single" });
|
||||
assert.notEqual(first.group, second.group);
|
||||
assert.deepEqual(concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", actor: "another-contributor" })), first);
|
||||
});
|
||||
|
||||
test("main pushes retain their independent cancellation group without PR context", () => {
|
||||
const main = concurrencySettings(concurrencyContext({ eventName: "push", ref: "refs/heads/main", actor: "github-actions[bot]" }));
|
||||
assert.deepEqual(main, { group: "CI-refs/heads/main", "cancel-in-progress": true, queue: "single" });
|
||||
assert.notEqual(main.group, concurrencySettings(concurrencyContext()).group);
|
||||
});
|
||||
|
||||
test("expanded author queues never combine max queuing with in-progress cancellation", () => {
|
||||
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }),
|
||||
concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
|
||||
const settings = concurrencySettings(context);
|
||||
assert.ok(settings.queue === "single" || settings.queue === "max");
|
||||
assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false);
|
||||
}
|
||||
const concurrency = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)[1];
|
||||
assert.doesNotMatch(concurrency, /github\.actor|author_association/);
|
||||
});
|
||||
|
||||
test("fast checks run format and contracts before graph resolution or compilation", () => {
|
||||
const fast = job("fast-checks");
|
||||
for (const command of ["cargo fmt --check", "node --test scripts/core-architecture.test.mjs", "node scripts/sync-connection-types.mjs --check", "node .github/scripts/ci-lockfiles.mjs", "node .github/scripts/ci-rust-coverage.mjs"]) assert.ok(fast.includes(command));
|
||||
@@ -20,12 +82,31 @@ test("fast checks run format and contracts before graph resolution or compilatio
|
||||
assert.ok(fast.includes("needs.changes.outputs.rust_groups_known == 'true'"));
|
||||
});
|
||||
|
||||
test("fast checks retain ownership of the planner subset before expensive jobs", () => {
|
||||
assert.match(job("github-scripts"), /FAST_CHECKS: \$\{\{ needs\.changes\.outputs\.fast \}\}/);
|
||||
assert.match(job("github-scripts"), /for file in \.github\/scripts\/\*\.test\.mjs/);
|
||||
assert.match(job("fast-checks"), /name: CI planner and gate tests\s+run: node --test \.github\/scripts\/ci-\*\.test\.mjs/);
|
||||
});
|
||||
|
||||
test("contributor snapshot routing preserves docs deployment and mixed frontend validation", () => {
|
||||
assert.ok(job("changes").includes("frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}"));
|
||||
assert.match(job("changes"), /frontend:\s+- 'apps\/desktop\/\*\*'\s+- 'docs\/\*\*'/);
|
||||
const docsWorkflow = readFileSync(new URL("../workflows/docs.yml", import.meta.url), "utf8");
|
||||
assert.match(docsWorkflow, /paths: \["docs\/\*\*"\]/);
|
||||
for (const file of ["contributors", "issue-claim", "notify", "plugin-dev-host", "spam-comment-guard"]) {
|
||||
assert.ok(job("changes").includes(`- '.github/workflows/${file}.yml'`));
|
||||
}
|
||||
for (const file of ["contributor-snapshot", "update-contributors"]) {
|
||||
assert.ok(job("changes").includes(`- 'docs/scripts/${file}.mjs'`));
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent and Rust matrices are bounded and do not cancel sibling failures", () => {
|
||||
for (const [name, output, parallel] of [
|
||||
["rust-test", "rust_matrix", 3],
|
||||
["agent-rust", "agent_rust", 2],
|
||||
["agent-go", "agent_go", 8],
|
||||
["agent-integration", "agent_integration", 8],
|
||||
["agent-go", "agent_go", 4],
|
||||
["agent-integration", "agent_integration", 4],
|
||||
]) {
|
||||
const content = job(name);
|
||||
assert.match(content, /fail-fast: false/);
|
||||
@@ -83,10 +164,101 @@ test("every old Agent stage has an independent owner and Java packaging remains
|
||||
for (const name of ["agent-rust", "agent-go", "agent-integration", "agent-java"]) assert.doesNotMatch(job(name), /continue-on-error: true/);
|
||||
});
|
||||
|
||||
test("native Rust driver caches exclude failed build artifacts", () => {
|
||||
const content = job("agent-rust");
|
||||
assert.ok(content.includes("shared-key: ci-agent-rust-v2-${{ matrix.driver }}"));
|
||||
assert.ok(content.includes("cache-on-failure: false"));
|
||||
test("shared Rust caches publish only complete builds outside the old cache namespace", () => {
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust", "agent-integration"]) {
|
||||
const content = job(name);
|
||||
assert.ok(content.includes("prefix-key: v1-rust-complete"), name);
|
||||
assert.ok(content.includes("cache-on-failure: false"), name);
|
||||
assert.doesNotMatch(content, /cache-on-failure: true/);
|
||||
assert.ok(content.includes("save-if: ${{ github.ref == 'refs/heads/main'"), name);
|
||||
}
|
||||
});
|
||||
|
||||
test("main and fork Rust jobs use the same rust-cache fingerprint", () => {
|
||||
const stableCacheEnvironment = /- name: Rust cache\s+env:\s+RUST_FEATURE_MODE: ""\s+RUST_TEST_GROUP: ""\s+uses: swatinem\/rust-cache@v2/;
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const content = job(name);
|
||||
assert.match(content, stableCacheEnvironment);
|
||||
assert.match(content, /^ RUSTC_WRAPPER: sccache$/m);
|
||||
assert.ok(content.includes('version: "v0.16.0"'));
|
||||
assert.doesNotMatch(content, /RUSTC_WRAPPER: ""|CC: ""|CXX: ""/);
|
||||
}
|
||||
});
|
||||
|
||||
test("S3 and secretless Rust jobs export identical compiler wrappers", (context) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-sccache-"));
|
||||
context.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const compiler = "/opt/hostedtoolcache/sccache/0.16.0/x64/sccache";
|
||||
const backend = {
|
||||
SCCACHE_BUCKET: "test-bucket",
|
||||
SCCACHE_ENDPOINT: "https://cache.example.test",
|
||||
SCCACHE_REGION: "test-region",
|
||||
SCCACHE_S3_KEY_PREFIX: "test-prefix",
|
||||
SCCACHE_S3_USE_SSL: "true",
|
||||
AWS_ACCESS_KEY_ID: "test-access-key",
|
||||
AWS_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
};
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const setup = job(name).match(/- name: Configure sccache\n([\s\S]*?)(?=\n - name: Rust cache)/)?.[1];
|
||||
assert.ok(setup, name);
|
||||
assert.doesNotMatch(setup, /^\s+if:/m);
|
||||
const script = setup.match(/ run: \|\n([\s\S]*)/)[1].split("\n").map((line) => line.slice(10)).join("\n");
|
||||
for (const remote of [true, false]) {
|
||||
const output = path.join(directory, `${name}-${remote}.env`);
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
GITHUB_ENV: output,
|
||||
SCCACHE_PATH: compiler,
|
||||
CACHE_BUCKET: remote ? backend.SCCACHE_BUCKET : "",
|
||||
CACHE_ENDPOINT: remote ? backend.SCCACHE_ENDPOINT : "",
|
||||
CACHE_REGION: remote ? backend.SCCACHE_REGION : "",
|
||||
CACHE_KEY_PREFIX: remote ? backend.SCCACHE_S3_KEY_PREFIX : "",
|
||||
CACHE_ACCESS_KEY_ID: remote ? backend.AWS_ACCESS_KEY_ID : "",
|
||||
CACHE_SECRET_ACCESS_KEY: remote ? backend.AWS_SECRET_ACCESS_KEY : "",
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "");
|
||||
const exported = Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||
}));
|
||||
assert.deepEqual(exported, {
|
||||
...(remote ? backend : {}),
|
||||
SCCACHE_IDLE_TIMEOUT: "0",
|
||||
CC: `${compiler} cc`,
|
||||
CXX: `${compiler} c++`,
|
||||
}, `${name}: ${remote ? "S3" : "local"}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Rust build timings remain available after failures without waiting for cancelled runs", () => {
|
||||
for (const [name, artifact, location] of [
|
||||
["rust-fmt-clippy", "DBX-clippy-cargo-timings", "target/cargo-timings/"],
|
||||
["rust-test", "DBX-rust-${{ matrix.group }}-cargo-timings", "target/cargo-timings/"],
|
||||
["agent-rust", "DBX-agent-${{ matrix.driver }}-cargo-timings", "agents/drivers/${{ matrix.driver }}/target/cargo-timings/"],
|
||||
]) {
|
||||
const content = job(name);
|
||||
assert.match(content, /name: Upload Rust Cargo timings\s+if: always\(\) && !cancelled\(\)\s+uses: actions\/upload-artifact@v6/);
|
||||
assert.ok(content.includes(`name: ${artifact}`));
|
||||
assert.ok(content.includes(`path: ${location}`));
|
||||
assert.ok(content.includes("retention-days: 3"));
|
||||
assert.ok(content.includes("if-no-files-found: ignore"));
|
||||
}
|
||||
assert.ok(job("agent-rust").includes('--manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast --timings'));
|
||||
});
|
||||
|
||||
test("Linux Rust dependency installs bound apt mirror stalls", () => {
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const content = job(name);
|
||||
assert.equal((content.match(/sudo timeout --kill-after=30s 10m apt-get/g) ?? []).length, 2);
|
||||
for (const option of ["Acquire::Retries=3", "Acquire::http::Timeout=30", "Acquire::https::Timeout=30"]) {
|
||||
assert.ok(content.includes(option));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Rust test jobs install pinned nextest and retain separate doctests", () => {
|
||||
@@ -94,7 +266,7 @@ test("Rust test jobs install pinned nextest and retain separate doctests", () =>
|
||||
const pluginRelease = readFileSync(new URL("../workflows/plugin-cli-release.yml", import.meta.url), "utf8");
|
||||
const consumers = [job("packages"), job("rust-test"), job("agent-rust"), job("agent-integration"), job("test", pluginDevHost), job("prepare", pluginRelease)];
|
||||
for (const content of consumers) {
|
||||
assert.ok(content.includes("uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3"));
|
||||
assert.ok(content.includes("uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b"));
|
||||
assert.ok(content.includes("tool: cargo-nextest@0.9.137"));
|
||||
assert.doesNotMatch(content, /cargo test (?!.*--doc)/);
|
||||
const testCommand = content.indexOf("cargo nextest run") >= 0 ? "cargo nextest run" : content.includes("pnpm test:packages") ? "pnpm test:packages" : content.includes("ci-rust.mjs test") ? "ci-rust.mjs test" : "ci-agent-integration.sh";
|
||||
@@ -130,9 +302,8 @@ test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
|
||||
});
|
||||
|
||||
test("Windows compatibility jobs cache Rust compilation without wrapping C or C++", () => {
|
||||
assert.ok(job("changes").includes("'vendor/webview2-com-sys/**'"));
|
||||
const standard = job("windows-standard-check");
|
||||
assert.ok(standard.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.ok(standard.includes("needs.changes.outputs.windows_standard == 'true'"));
|
||||
assert.ok(standard.includes("RUSTC_WRAPPER: sccache"));
|
||||
assert.ok(standard.includes('SCCACHE_GHA_ENABLED: "true"'));
|
||||
assert.ok(standard.includes("SCCACHE_GHA_VERSION: windows-standard-v1"));
|
||||
@@ -142,6 +313,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
assert.ok(standard.includes("sccache --show-stats"));
|
||||
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
|
||||
for (const setting of ["RUSTC_WRAPPER: sccache", 'SCCACHE_GHA_ENABLED: "true"', "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
|
||||
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
|
||||
@@ -182,20 +354,21 @@ test("Win7 TLS cache keys ignore the workspace lockfile", () => {
|
||||
assert.doesNotMatch(win7, /key: win7-(?:aws-lc|openssl).*hashFiles\('Cargo\.lock'/);
|
||||
});
|
||||
|
||||
test("the planner uses the exact event base and preserves a single workflow cancellation scope", () => {
|
||||
test("path filtering and the planner use the exact test merge first parent", () => {
|
||||
const changes = job("changes");
|
||||
assert.ok(changes.includes("github.event.pull_request.base.sha || github.event.before"));
|
||||
assert.ok(changes.includes("BEFORE_SHA: ${{ github.event.before }}"));
|
||||
assert.ok(changes.includes('BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"'));
|
||||
assert.ok(changes.includes("fetch-depth: 2"));
|
||||
assert.ok(changes.includes('git fetch --no-tags --depth=1 origin "$BASE_SHA"'));
|
||||
assert.ok(changes.includes("base: ${{ steps.change-base.outputs.sha }}"));
|
||||
assert.match(changes, /with:\s+token: ''\s+base: \$\{\{ steps\.change-base\.outputs\.sha \}\}\s+ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.ok(changes.includes("BASE_SHA: ${{ steps.change-base.outputs.sha }}"));
|
||||
assert.ok(changes.includes("node .github/scripts/ci-plan.mjs"));
|
||||
assert.doesNotMatch(changes, /fetch-depth:\s*0/);
|
||||
assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/);
|
||||
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration",
|
||||
"windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
|
||||
"windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
|
||||
assert.ok(changes.includes(`steps.plan.outputs.${flag}`));
|
||||
}
|
||||
assert.ok(changes.includes("WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}"));
|
||||
assert.ok(workflow.includes("group: ${{ github.workflow }}-${{ github.ref }}"));
|
||||
assert.ok(workflow.includes("cancel-in-progress: true"));
|
||||
assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add Google's public Docker Hub cache without replacing daemon settings."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MIRROR = "https://mirror.gcr.io"
|
||||
|
||||
|
||||
def configure(path, validate=None):
|
||||
path = Path(path)
|
||||
config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("Docker daemon config must be a JSON object")
|
||||
mirrors = config.get("registry-mirrors", [])
|
||||
if not isinstance(mirrors, list) or any(not isinstance(item, str) for item in mirrors):
|
||||
raise ValueError("registry-mirrors must be an array of strings")
|
||||
if any(item.rstrip("/") == MIRROR for item in mirrors):
|
||||
return
|
||||
config["registry-mirrors"] = [*mirrors, MIRROR]
|
||||
original_stat = path.stat() if path.exists() else None
|
||||
candidate = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
||||
prefix="daemon-mirror-", suffix=".json", delete=False) as output:
|
||||
candidate = Path(output.name)
|
||||
output.write(json.dumps(config, indent=2) + "\n")
|
||||
candidate.chmod(stat.S_IMODE(original_stat.st_mode) if original_stat else 0o644)
|
||||
if original_stat and hasattr(os, "chown"):
|
||||
os.chown(candidate, original_stat.st_uid, original_stat.st_gid)
|
||||
if validate:
|
||||
validate(candidate)
|
||||
if original_stat:
|
||||
backup = path.with_name(path.name + ".before-dbx-mirror")
|
||||
if not backup.exists():
|
||||
descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IMODE(original_stat.st_mode))
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
output.write(path.read_bytes())
|
||||
shutil.copystat(path, backup)
|
||||
if hasattr(os, "chown"):
|
||||
os.chown(backup, original_stat.st_uid, original_stat.st_gid)
|
||||
os.replace(candidate, path)
|
||||
finally:
|
||||
if candidate and candidate.exists():
|
||||
candidate.unlink()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("config", type=Path)
|
||||
parser.add_argument("--validate", action="store_true", help="Validate with dockerd before replacing config")
|
||||
args = parser.parse_args()
|
||||
|
||||
def validate(candidate):
|
||||
subprocess.run(["dockerd", "--validate", "--config-file", str(candidate)], check=True, timeout=15)
|
||||
|
||||
configure(args.config, validate if args.validate else None)
|
||||
@@ -0,0 +1,115 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"docker_mirror", Path(__file__).with_name("configure-docker-mirror.py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class DockerMirrorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.path = Path(self.directory.name) / "daemon.json"
|
||||
|
||||
def test_missing_config(self):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(json.loads(self.path.read_text()), {"registry-mirrors": [module.MIRROR]})
|
||||
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
|
||||
|
||||
def test_preserves_settings_and_existing_mirror_order(self):
|
||||
original = {"log-driver": "json-file", "features": {"containerd-snapshotter": True},
|
||||
"registry-mirrors": ["https://existing.example", "https://second.example/"]}
|
||||
self.path.write_text(json.dumps(original))
|
||||
module.configure(self.path)
|
||||
expected = {**original, "registry-mirrors": [*original["registry-mirrors"], module.MIRROR]}
|
||||
self.assertEqual(json.loads(self.path.read_text()), expected)
|
||||
|
||||
def test_second_run_does_not_write(self):
|
||||
module.configure(self.path)
|
||||
before = self.path.read_bytes()
|
||||
modified = self.path.stat().st_mtime_ns
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(self.path.stat().st_mtime_ns, modified)
|
||||
|
||||
def test_existing_mirror_with_trailing_slash_does_not_write(self):
|
||||
before = '{"registry-mirrors": ["https://mirror.gcr.io/"], "debug": false}\n'
|
||||
self.path.write_text(before)
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_text(), before)
|
||||
|
||||
def test_invalid_json_fails_without_writing(self):
|
||||
before = b'{"debug":'
|
||||
self.path.write_bytes(before)
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_invalid_structure_fails_without_writing(self):
|
||||
for value in [[], {"registry-mirrors": "https://existing.example"},
|
||||
{"registry-mirrors": [None]}]:
|
||||
with self.subTest(value=value):
|
||||
before = json.dumps(value).encode()
|
||||
self.path.write_bytes(before)
|
||||
with self.assertRaises(ValueError):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_existing_file_permissions_preserved(self):
|
||||
self.path.write_text('{"debug": false}')
|
||||
self.path.chmod(0o600)
|
||||
mode = self.path.stat().st_mode
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.stat().st_mode, mode)
|
||||
|
||||
def test_backup_preserves_original_bytes_and_mode(self):
|
||||
before = b'{ "debug": false, "log-driver": "json-file" }\n'
|
||||
self.path.write_bytes(before)
|
||||
self.path.chmod(0o600)
|
||||
mode = self.path.stat().st_mode
|
||||
module.configure(self.path)
|
||||
backup = self.path.with_name(self.path.name + ".before-dbx-mirror")
|
||||
self.assertEqual(backup.read_bytes(), before)
|
||||
self.assertEqual(backup.stat().st_mode, mode)
|
||||
module.configure(self.path)
|
||||
self.assertEqual(backup.read_bytes(), before)
|
||||
|
||||
def test_validator_failure_preserves_original_and_removes_candidate(self):
|
||||
before = b'{"debug": false}\n'
|
||||
self.path.write_bytes(before)
|
||||
|
||||
def reject(candidate):
|
||||
self.assertEqual(json.loads(candidate.read_text())["registry-mirrors"], [module.MIRROR])
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
raise ValueError("daemon validation failed")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "daemon validation failed"):
|
||||
module.configure(self.path, reject)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
|
||||
|
||||
def test_validation_precedes_atomic_replace(self):
|
||||
before = b'{"debug": true}\n'
|
||||
self.path.write_bytes(before)
|
||||
candidates = []
|
||||
|
||||
def validate(candidate):
|
||||
self.assertEqual(candidate.parent, self.path.parent)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertTrue(json.loads(candidate.read_text())["debug"])
|
||||
candidates.append(candidate)
|
||||
|
||||
module.configure(self.path, validate)
|
||||
self.assertEqual(len(candidates), 1)
|
||||
self.assertFalse(candidates[0].exists())
|
||||
self.assertIn(module.MIRROR, json.loads(self.path.read_text())["registry-mirrors"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -59,10 +59,25 @@ if (!routes[url]) process.exit(22);
|
||||
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
|
||||
`, { mode: 0o755 });
|
||||
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
|
||||
writeFileSync(join(mocks, "codesign"), `#!${process.execPath}
|
||||
const args = process.argv.slice(2);
|
||||
const mode = process.env.SIGNATURE_MODE || 'valid';
|
||||
const requirement = 'identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"';
|
||||
if (process.env.FIXTURE_PLATFORM.startsWith('linux')) process.exit(99);
|
||||
if (args[0] === '--verify') {
|
||||
if (!args.includes('--strict') || !args.includes('--all-architectures') || args[args.indexOf('-R') + 1] !== '=' + requirement) process.exit(98);
|
||||
process.exit(['unsigned', 'adhoc', 'wrong-team', 'wrong-id', 'tampered'].includes(mode) ? 1 : 0);
|
||||
}
|
||||
if (args[0] !== '--display') process.exit(97);
|
||||
console.error('Identifier=com.dbx.app.mcp');
|
||||
console.error('TeamIdentifier=TVDM965TDL');
|
||||
console.error('Timestamp=' + (mode === 'no-timestamp' ? 'none' : 'Oct 3, 2026'));
|
||||
console.error('designated => ' + (mode === 'hash-bound' ? 'cdhash H"' + 'ab'.repeat(20) + '"' : requirement.replaceAll('exists', '/* exists */')));
|
||||
`, { mode: 0o755 });
|
||||
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
|
||||
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
|
||||
const run = (extra = {}) => spawnSync("sh", [installer], {
|
||||
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, ...extra },
|
||||
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
|
||||
encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
return { root, home, mocks, binary: installedBinary, marker, run };
|
||||
@@ -162,6 +177,25 @@ for (const source of ["npm", "github"]) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const platform of ["darwin-arm64", "darwin-x64"]) {
|
||||
for (const mode of ["unsigned", "adhoc", "wrong-team", "wrong-id", "tampered", "hash-bound", "no-timestamp"]) {
|
||||
test(`rejects ${mode} macOS signature before replacing an installation: ${platform}`, (context) => {
|
||||
const setup = fixture(context, platform);
|
||||
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
|
||||
const original = "#!/bin/sh\nprintf 'dbx-mcp 0.4.95\\n'\n";
|
||||
writeFileSync(setup.binary, original, { mode: 0o755 });
|
||||
writeFileSync(setup.marker, "0.4.95\n");
|
||||
const result = setup.run({ SIGNATURE_MODE: mode });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /signature verification failed/i);
|
||||
assert.equal(readFileSync(setup.binary, "utf8"), original);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
|
||||
assert.ok(!existsSync(join(setup.home, ".zshrc")));
|
||||
assert.ok(!readdirSync(join(setup.home, ".dbx/bin")).some((name) => name.startsWith(".dbx-mcp-install.")));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("stale marker without binary reinstalls, and bash users get migration guidance", (context) => {
|
||||
const setup = fixture(context);
|
||||
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
for %%A in (%*) do (
|
||||
if /I "%%~A"=="-h" goto :help
|
||||
if /I "%%~A"=="--help" goto :help
|
||||
if /I "%%~A"=="/help" goto :help
|
||||
)
|
||||
goto :main
|
||||
|
||||
:help
|
||||
echo Usage: start.bat [OPTION]
|
||||
echo.
|
||||
echo Start the DBX Web browser service.
|
||||
echo.
|
||||
echo Options:
|
||||
echo -h, --help, /help Show this help message and exit.
|
||||
echo.
|
||||
echo Environment variables:
|
||||
echo DBX_PORT Listen port (default: 4224)
|
||||
echo DBX_DATA_DIR Data directory (default: package-dir\data)
|
||||
echo DBX_PUBLIC_BASE_PATH URL path prefix (default: /)
|
||||
echo DBX_PASSWORD Set the Web login password
|
||||
echo DBX_DISABLE_PASSWORD Set to 1 to disable login protection
|
||||
echo RUST_LOG Configure backend log filtering
|
||||
echo RUST_BACKTRACE Set to 1 to include Rust backtraces
|
||||
echo.
|
||||
echo Examples:
|
||||
echo set DBX_PORT=8080 ^&^& start.bat
|
||||
echo set RUST_LOG=dbx_web=debug,tower_http=info ^&^& start.bat
|
||||
exit /b 0
|
||||
|
||||
:main
|
||||
|
||||
:: 脚本所在目录(等价于 ROOT)
|
||||
set ROOT=%~dp0
|
||||
:: 去掉末尾反斜杠
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# UI preview
|
||||
|
||||
`.github/workflows/ui-preview.yml` shows reviewers what a pull request does to the UI, the way
|
||||
[yetone/magpie](https://github.com/yetone/magpie) does for its own — this is a port of that
|
||||
pipeline, adapted to dbx's Tauri/frontend split. When a PR touches `apps/desktop/src/` (or
|
||||
carries the `ui-preview` label), the workflow does this:
|
||||
|
||||
1. **publish.mjs detect** decides whether the PR is a UI change, and which lane it is:
|
||||
a diff that reaches `crates/`, `plugins/` or `src-tauri/` is a *rust* lane, anything else
|
||||
is a *web* lane. It puts a "recording…" note in a fixed block at the foot of the PR
|
||||
description, between `<!-- dbx-ui-preview:begin -->` and `<!-- dbx-ui-preview:end -->`.
|
||||
2. **The record job** builds the PR's frontend (`pnpm build`) and runs it against a backend:
|
||||
the released dbx-web static binary (web lane — no Rust is built; the PR's `dist/` replaces
|
||||
the embedded frontend via `DBX_STATIC_DIR`), or dbx-web built from the PR (rust lane).
|
||||
**run.sh** starts it headless (`DBX_DISABLE_PASSWORD=1`, an isolated home), then
|
||||
**seed.mjs** fills it with made-up data, so a change that only shows with data can be seen:
|
||||
- two SQLite databases with fixed, deterministic content (orders/products/customers with
|
||||
a view, a trigger and foreign keys; an events table that pages), saved as two
|
||||
connections and really connected and queried through the app;
|
||||
- a saved-SQL folder with three files, and three days of query history (real executions
|
||||
plus seeded entries, a few failures among them).
|
||||
3. **record.mjs**, which:
|
||||
- gives DeepSeek the diff, an outline of the running app, and the changed frontend files;
|
||||
- has it plan scenes: what to click, double-click, hover, type and capture;
|
||||
- walks the plan in Chromium with `cursor.js` drawing the pointer, its trail and each
|
||||
click. It always takes screenshots. When the plan interacts, it also records an mp4,
|
||||
never a GIF — each scene on a page of its own, its load trimmed off and the takes
|
||||
joined, so scene changes read as cuts rather than the app reloading. A failed step
|
||||
is re-planned from the page as it is (up to three times),
|
||||
after an Escape clears anything the failure left open (a menu, a dialog); what still
|
||||
fails is listed honestly in the PR block, and shots taken after a failure in their
|
||||
scene are marked ⚠️ both there and on the Pages player.
|
||||
4. **publish.mjs publish** puts the files under `pr-<n>/<sha>/` on the `ui-previews` branch,
|
||||
which GitHub Pages serves, and rewrites the block in the description: the video's poster
|
||||
links to the player page (GitHub won't embed a video it didn't host), the screenshots are
|
||||
shown inline, and the plan's summary / mismatch (what the description claims but the diff
|
||||
doesn't do) / unseen (what this sandbox can't show) notes go on top.
|
||||
|
||||
Security: the recording job runs the PR's code without asking anyone — its frontend build
|
||||
scripts, and in the rust lane the PR's backend itself. Any PR's code can therefore read
|
||||
the planner's API key (`PLAN_API_KEY`, environment `ui-preview`), so the key there should
|
||||
have a low spending limit. That job has no write access, and the job that writes never
|
||||
runs the PR's code. The planner talks to any OpenAI-compatible endpoint
|
||||
(`PLAN_API_URL`, default AtlasCloud; `PLAN_MODEL`, overridable via the
|
||||
`UI_PREVIEW_PLAN_MODEL` repo variable).
|
||||
|
||||
Known limits: the recording is of the web build, so desktop-shell behaviour (detached tabs,
|
||||
OS dialogs) is not covered; in the web lane the backend is the released dbx-web, so a PR
|
||||
whose frontend calls an endpoint newer than the release records that call failing — the
|
||||
honest-notes fields are the place that shows up.
|
||||
|
||||
To run it locally against a dbx-web that already runs somewhere (all of record.mjs, none of
|
||||
the starting):
|
||||
|
||||
```sh
|
||||
cd .github/ui-preview && npm install && npx playwright install chromium
|
||||
DBX_URL=http://127.0.0.1:4280 PLAN_API_KEY=… DIFF_FILE=pr.diff PR_TITLE=… \
|
||||
OUT_DIR=/tmp/out SRC_DIR=../.. node record.mjs # a plan from DeepSeek
|
||||
PLAN_FILE=plan.json DBX_URL=… … node record.mjs # or a hand-written plan
|
||||
```
|
||||
|
||||
`seed.mjs dbs <dir>` writes the databases; `seed.mjs api <url> <dir> [--as <server path>]`
|
||||
seeds a running instance (pass `--as` when the databases live where dbx-web runs, not where
|
||||
you run the script — testing over an SSH tunnel).
|
||||
|
||||
To re-run it for a PR, use `gh workflow run ui-preview.yml -f pr=<n>`.
|
||||
@@ -0,0 +1,107 @@
|
||||
// Drawn into every page the recorder opens, so the video shows where the
|
||||
// mouse is, the path it took and each click: an arrow on top of everything,
|
||||
// its trail fading behind it, a ring where a button went down. The recorder
|
||||
// sets captions with window.__uiPreviewCaption(text). None of it takes
|
||||
// pointer events, so the app under it behaves as it would.
|
||||
(() => {
|
||||
if (window.__uiPreviewCursor) return;
|
||||
window.__uiPreviewCursor = true;
|
||||
const TRAIL_MS = 1400;
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
let root, canvas, ctx, arrow, caption;
|
||||
const points = []; // {x, y, t}
|
||||
const rings = []; // {x, y, t}
|
||||
let x = -100, y = -100, down = false;
|
||||
|
||||
function mount() {
|
||||
if (root?.isConnected) return;
|
||||
root = document.createElement("div");
|
||||
root.setAttribute("data-ui-preview", "");
|
||||
root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;contain:strict;";
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
|
||||
ctx = canvas.getContext("2d");
|
||||
arrow = document.createElementNS(ns, "svg");
|
||||
arrow.setAttribute("width", "26");
|
||||
arrow.setAttribute("height", "26");
|
||||
arrow.setAttribute("viewBox", "0 0 26 26");
|
||||
arrow.style.cssText = "position:absolute;left:0;top:0;transform-origin:3px 3px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.35));transition:scale .08s;";
|
||||
arrow.innerHTML = '<path d="M3 2.5v18.2l4.6-4.4 3.1 7.2 3.3-1.4-3.1-7.1h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/>';
|
||||
caption = document.createElement("div");
|
||||
caption.style.cssText = "position:absolute;left:50%;bottom:18px;translate:-50% 0;max-width:80%;padding:7px 14px;border-radius:9px;background:rgba(17,17,17,.82);color:#fff;font:500 14px/1.4 -apple-system,'Segoe UI','Noto Sans CJK SC','PingFang SC',sans-serif;opacity:0;transition:opacity .2s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
|
||||
root.append(canvas, arrow, caption);
|
||||
document.documentElement.append(root);
|
||||
size();
|
||||
}
|
||||
function size() {
|
||||
const r = devicePixelRatio || 1;
|
||||
canvas.width = innerWidth * r;
|
||||
canvas.height = innerHeight * r;
|
||||
ctx.setTransform(r, 0, 0, r, 0, 0);
|
||||
}
|
||||
|
||||
function frame() {
|
||||
mount();
|
||||
const now = performance.now();
|
||||
while (points.length && now - points[0].t > TRAIL_MS) points.shift();
|
||||
while (rings.length && now - rings[0].t > 900) rings.shift();
|
||||
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
||||
// the path: each segment as faint as it is old
|
||||
ctx.lineCap = ctx.lineJoin = "round";
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1], b = points[i];
|
||||
const age = (now - b.t) / TRAIL_MS;
|
||||
ctx.strokeStyle = `rgba(255, 59, 48, ${0.75 * (1 - age)})`;
|
||||
ctx.lineWidth = 3.5 * (1 - age) + 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// a click: a ring that opens and fades, a dot where it landed
|
||||
for (const r of rings) {
|
||||
const k = (now - r.t) / 900;
|
||||
ctx.strokeStyle = `rgba(255, 59, 48, ${1 - k})`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, 8 + 26 * Math.sqrt(k), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = `rgba(255, 59, 48, ${0.9 * (1 - k)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (down) {
|
||||
ctx.fillStyle = "rgba(255, 59, 48, .35)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
arrow.style.translate = `${x - 3}px ${y - 3}px`;
|
||||
arrow.style.scale = down ? "0.85" : "1";
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
const opts = { capture: true, passive: true };
|
||||
addEventListener("mousemove", (e) => {
|
||||
x = e.clientX; y = e.clientY;
|
||||
points.push({ x, y, t: performance.now() });
|
||||
}, opts);
|
||||
addEventListener("mousedown", (e) => {
|
||||
x = e.clientX; y = e.clientY; down = true;
|
||||
rings.push({ x, y, t: performance.now() });
|
||||
}, opts);
|
||||
addEventListener("mouseup", () => { down = false; }, opts);
|
||||
addEventListener("resize", () => canvas && size());
|
||||
|
||||
window.__uiPreviewCaption = (text) => {
|
||||
mount();
|
||||
caption.textContent = text || "";
|
||||
caption.style.opacity = text ? "1" : "0";
|
||||
};
|
||||
// the recorder's last known spot, carried across a page load
|
||||
window.__uiPreviewAt = (px, py) => { x = px; y = py; };
|
||||
|
||||
if (document.readyState === "loading") addEventListener("DOMContentLoaded", () => requestAnimationFrame(frame));
|
||||
else requestAnimationFrame(frame);
|
||||
})();
|
||||
Generated
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "dbx-ui-preview",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dbx-ui-preview",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "dbx-ui-preview",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"seed": "node seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
}
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
// The trusted half of the UI preview: never runs the PR's code.
|
||||
//
|
||||
// node publish.mjs detect — does the PR touch the UI? (GITHUB_OUTPUT: ui,
|
||||
// lane, sha, number) and, when it does, puts
|
||||
// the "recording" note in the PR's description
|
||||
// node publish.mjs publish — the recording (OUT_DIR, from record.mjs) onto
|
||||
// the ui-previews branch, served by Pages, and
|
||||
// the preview into the PR's description
|
||||
//
|
||||
// The preview lives in one fixed block at the foot of the description,
|
||||
// between the markers below; each run replaces that block and leaves the
|
||||
// rest of the description as the author wrote it.
|
||||
//
|
||||
// lane: "rust" when the diff reaches the backend (crates/, plugins/,
|
||||
// src-tauri/) — then CI builds dbx-web from the PR; anything else builds
|
||||
// only the frontend and runs it against the released dbx-web.
|
||||
//
|
||||
// env: GITHUB_TOKEN, GITHUB_REPOSITORY, PR, OUT_DIR, RECORD_RESULT, RUN_URL
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const BEGIN = "<!-- dbx-ui-preview:begin -->";
|
||||
const END = "<!-- dbx-ui-preview:end -->";
|
||||
const BRANCH = "ui-previews";
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const pr = Number(process.env.PR);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const runURL = process.env.RUN_URL || "";
|
||||
// what counts as the UI: the app's own sources, not their tests
|
||||
const NOT_TEST = (f) => !/(__tests__|\.spec\.|\.test\.|\.bench\.)/.test(f);
|
||||
const UI = (f) => /^apps\/desktop\/src\//.test(f) && NOT_TEST(f);
|
||||
const RUST = (f) => /^(crates|plugins|src-tauri)\//.test(f);
|
||||
|
||||
async function gh(p, init = {}) {
|
||||
const res = await fetch(`https://api.github.com${p}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", ...init.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${(await res.text()).slice(0, 300)}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function files() {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 30; page++) {
|
||||
const batch = await gh(`/repos/${repo}/pulls/${pr}/files?per_page=100&page=${page}`);
|
||||
out.push(...batch.map((f) => f.filename));
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// the description with the block in place of the old one, or at its foot
|
||||
function withBlock(body, block) {
|
||||
body = body || "";
|
||||
const a = body.indexOf(BEGIN), b = body.indexOf(END);
|
||||
if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length);
|
||||
return body.replace(/\s*$/, "") + "\n\n" + block;
|
||||
}
|
||||
async function setBlock(inner) {
|
||||
// read just before writing, so an edit the author just made is kept
|
||||
const cur = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const next = withBlock(cur.body, `${BEGIN}\n${inner.trim()}\n${END}`);
|
||||
if (next !== cur.body) await gh(`/repos/${repo}/pulls/${pr}`, { method: "PATCH", body: JSON.stringify({ body: next }) });
|
||||
}
|
||||
|
||||
const short = (sha) => sha.slice(0, 7);
|
||||
const head = (sha) => `---\n### 🎬 界面预览\n<sub>CI 用这个 PR 的前端构建并真实运行 dbx(沙盒环境,dbx-web 后端 + 本地 SQLite 种子数据),按改动自动操作、截图和录屏 · <code>${short(sha)}</code>${runURL ? ` · [运行记录](${runURL})` : ""}</sub>\n`;
|
||||
|
||||
async function detect() {
|
||||
const p = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const changed = await files();
|
||||
const forced = p.labels.some((l) => l.name === "ui-preview");
|
||||
const ui = forced || changed.some(UI);
|
||||
const lane = changed.some(RUST) ? "rust" : "web";
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
const lines = [`ui=${ui}`, `lane=${lane}`, `sha=${p.head.sha}`, `number=${pr}`];
|
||||
if (out) await fs.appendFile(out, lines.join("\n") + "\n");
|
||||
console.log(lines.join(" "), "·", changed.filter(UI).join(", ") || "no UI files");
|
||||
if (ui && p.state === "open") await setBlock(`${head(p.head.sha)}\n⏳ 正在录制这次提交的界面改动…`);
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
// the recording onto the ui-previews branch: one commit, replaced each time
|
||||
// so the branch never piles up old videos; another PR's run pushing in
|
||||
// between makes this one start over from theirs
|
||||
async function pushMedia(sha, dir) {
|
||||
const work = await fs.mkdtemp(path.join(os.tmpdir(), "ui-previews-"));
|
||||
const remote = `https://x-access-token:${token}@github.com/${repo}.git`;
|
||||
git(["init", "-q"], work);
|
||||
git(["config", "user.name", "github-actions[bot]"], work);
|
||||
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], work);
|
||||
const open = new Set((await gh(`/repos/${repo}/pulls?state=open&per_page=100`)).map((p) => p.number));
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
let base = "";
|
||||
try {
|
||||
git(["fetch", "-q", "--depth=1", remote, BRANCH], work);
|
||||
base = git(["rev-parse", "FETCH_HEAD"], work);
|
||||
git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work);
|
||||
} catch { /* the first one */ }
|
||||
// this PR's latest run only, and no PR that's been closed
|
||||
for (const d of await fs.readdir(work).catch(() => [])) {
|
||||
const m = /^pr-(\d+)$/.exec(d);
|
||||
if (m && (Number(m[1]) === pr || !open.has(Number(m[1])))) await fs.rm(path.join(work, d), { recursive: true, force: true });
|
||||
}
|
||||
const to = path.join(work, `pr-${pr}`, short(sha));
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
for (const f of await fs.readdir(dir)) await fs.copyFile(path.join(dir, f), path.join(to, f));
|
||||
await fs.writeFile(path.join(to, "index.html"), player(sha, JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"))));
|
||||
await fs.writeFile(path.join(work, ".nojekyll"), "");
|
||||
await fs.writeFile(path.join(work, "index.html"), `<!doctype html><meta charset="utf-8"><title>dbx UI previews</title><p>Screenshots and recordings of <a href="https://github.com/${repo}/pulls">${repo}</a> pull requests, made by .github/workflows/ui-preview.yml.</p>`);
|
||||
git(["checkout", "-q", "--orphan", `tmp-${attempt}`], work);
|
||||
git(["add", "-A"], work);
|
||||
git(["commit", "-q", "-m", `ui preview for #${pr} at ${short(sha)}`], work);
|
||||
try {
|
||||
git(["push", "-q", `--force-with-lease=${BRANCH}:${base}`, remote, `HEAD:refs/heads/${BRANCH}`], work);
|
||||
await fs.rm(work, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log(`push lost a race (${e.message.split("\n")[0]}), again`);
|
||||
await new Promise((r) => setTimeout(r, 2000 + Math.random() * 4000));
|
||||
}
|
||||
}
|
||||
throw new Error("couldn't push the preview");
|
||||
}
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
// the page the video plays on: GitHub shows no video it didn't host itself
|
||||
function player(sha, m) {
|
||||
const shots = m.scenes.flatMap((s) => s.shots.map((x) => ({ ...x, scene: s.title })));
|
||||
const warned = m.errors?.length || shots.some((x) => x.warn);
|
||||
return `<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>#${pr} 界面预览 · ${short(sha)}</title>
|
||||
<style>:root{color-scheme:light dark;--bg:#f6f6f7;--fg:#1d1d1f;--muted:#6e6e73;--card:#fff;--line:#e5e5ea}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#111113;--fg:#f2f2f4;--muted:#9a9aa0;--card:#1c1c1f;--line:#2c2c30}}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif}
|
||||
main{max-width:1200px;margin:0 auto;padding:28px 16px 60px}h1{font-size:20px;margin:0 0 4px}.m{color:var(--muted);font-size:13px;margin:0 0 20px}
|
||||
.warn{margin:0 0 20px;padding:10px 14px;border:1px solid #c0392b;border-radius:10px;background:rgba(192,57,43,.07);color:#c0392b;font-size:14px}
|
||||
figure.shaky img{border-color:#c0392b}
|
||||
video,img{display:block;width:100%;border-radius:10px;border:1px solid var(--line);background:var(--card)}figure{margin:0 0 28px}figcaption{color:var(--muted);font-size:13px;margin-top:8px}
|
||||
a{color:inherit}</style>
|
||||
<main><h1>#${pr} 界面预览</h1><p class="m">commit ${short(sha)} · <a href="https://github.com/${repo}/pull/${pr}">回到 PR</a>${m.summary ? " · " + esc(m.summary) : ""}</p>
|
||||
${warned ? `<p class="warn">⚠️ 这次录制有 ${m.errors?.length || 0} 步没能按计划执行${shots.some((x) => x.warn) ? ";带 ⚠️ 的截图拍摄于步骤失败之后,画面未必是说明文字所描述的状态" : ""}。</p>` : ""}
|
||||
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。多个场景各自从应用初始状态开始。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
|
||||
${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${x.warn ? "⚠️ " : ""}${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
|
||||
</main></html>`;
|
||||
}
|
||||
|
||||
async function pagesURL() {
|
||||
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
|
||||
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const p = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const sha = process.env.SHA || p.head.sha;
|
||||
const dir = process.env.OUT_DIR;
|
||||
const result = process.env.RECORD_RESULT || "success";
|
||||
let m = null;
|
||||
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
|
||||
if (result === "cancelled" && !m) return; // a newer commit took over
|
||||
if (!m || m.leak || (!m.scenes.some((s) => s.shots.length) && !m.video)) {
|
||||
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图"
|
||||
: m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制"
|
||||
: `这次没录成(${result})`;
|
||||
const errs = m?.errors?.length ? `\n\n<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
|
||||
await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`);
|
||||
return;
|
||||
}
|
||||
await pushMedia(sha, dir);
|
||||
const raw = `https://raw.githubusercontent.com/${repo}/${BRANCH}/pr-${pr}/${short(sha)}/`;
|
||||
const site = `${await pagesURL()}pr-${pr}/${short(sha)}/`;
|
||||
let md = `${head(sha)}\n`;
|
||||
if (m.summary) md += `**改动**(按代码):${m.summary}\n\n`;
|
||||
if (m.mismatch) md += `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n`;
|
||||
if (m.unseen) md += `> [!NOTE]\n> **沙盒里看不到**:${m.unseen}\n\n`;
|
||||
if (m.errors?.length) md += `<details><summary>⚠️ 有 ${m.errors.length} 步没能照计划执行</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>\n\n`;
|
||||
if (m.video) {
|
||||
md += `[](${site})\n\n`;
|
||||
md += `<sub>▶️ 点图打开录屏(可暂停、拖动)· [直接下载 mp4](${raw}${m.video}) · 红线是鼠标轨迹,红色圆环是点击</sub>\n\n`;
|
||||
}
|
||||
for (const s of m.scenes) {
|
||||
if (!s.shots.length) continue;
|
||||
md += `#### ${s.title}\n\n`;
|
||||
// shown at its own size (they're taken at 2x), a full page no wider than 760
|
||||
for (const x of s.shots) md += `<img src="${raw}${x.file}" width="${Math.min(760, Math.round((x.width || 1520) / 2))}" alt="${esc(x.caption)}">\n\n${x.warn ? "⚠️ " : ""}${x.caption}\n\n`;
|
||||
}
|
||||
await setBlock(md);
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish")))
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
Executable
+704
@@ -0,0 +1,704 @@
|
||||
// Records a PR's UI changes in the real app: dbx-web is already running
|
||||
// (run.sh), this reads the PR's diff, has DeepSeek plan what to show from it
|
||||
// and the page as it really is, then walks the plan in Chromium with the
|
||||
// mouse drawn in (cursor.js) — screenshots always, a video when the change
|
||||
// needs clicking to see. Everything lands in OUT_DIR with a manifest.json
|
||||
// that publish.mjs turns into the PR's preview.
|
||||
//
|
||||
// env: DBX_URL (the app's link), DIFF_FILE, PR_TITLE, PR_BODY_FILE, OUT_DIR,
|
||||
// SRC_DIR (the PR's source, for code context), DEEPSEEK_API_KEY, SECRETS
|
||||
// (words that must never be on screen, one per line), UI_LOCALE (zh-CN),
|
||||
// PLAN_MODEL, PLAN_FILE (a plan to walk instead of asking for one).
|
||||
import { chromium } from "playwright";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const env = (k, d) => process.env[k] ?? d;
|
||||
const OUT = path.resolve(process.env.OUT_DIR || "ui-preview-out");
|
||||
// a leak empties OUT, so it must be a folder of its own: not the working
|
||||
// folder, the home, the root or one holding them
|
||||
for (const keep of [process.cwd(), os.homedir(), path.parse(OUT).root]) {
|
||||
const rel = path.relative(OUT, keep);
|
||||
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
||||
console.error(`record: OUT_DIR ${OUT} holds ${keep}; give it a folder of its own`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const BASE = env("DBX_URL");
|
||||
const LOCALE = env("UI_LOCALE", "zh-CN");
|
||||
const MODEL = env("PLAN_MODEL", "deepseek-flash");
|
||||
const VIEW = { width: 1440, height: 900 };
|
||||
// never pressed: they delete the sandbox's own data or its setup
|
||||
const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i;
|
||||
const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8);
|
||||
|
||||
const log = (...a) => console.log("[ui-preview]", ...a);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [], leak: false, skipped: null };
|
||||
|
||||
// The page as a compact outline the planner can pick real selectors from:
|
||||
// visible elements, their tag, id, classes, a few data- attributes and
|
||||
// their own text, indented; svg insides and invisible parts left out.
|
||||
function outline() {
|
||||
const lines = [];
|
||||
const MAX = 420;
|
||||
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70);
|
||||
const shown = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return false;
|
||||
const s = getComputedStyle(e);
|
||||
return s.visibility !== "hidden" && s.display !== "none" && +s.opacity !== 0;
|
||||
};
|
||||
// fold: where the view it is in ends on screen
|
||||
const walk = (e, depth, fold) => {
|
||||
if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return;
|
||||
const tag = e.tagName.toLowerCase();
|
||||
if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return;
|
||||
const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join("");
|
||||
const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join("");
|
||||
const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" ");
|
||||
const text = own(e);
|
||||
const r = e.getBoundingClientRect();
|
||||
const away = r.top >= fold ? " (below the fold)" : "";
|
||||
const interesting = cls || data || e.id || text || aria || /^(button|a|input|select|textarea|label|h\d)$/.test(tag);
|
||||
if (interesting) lines.push(`${" ".repeat(Math.min(depth, 12))}${tag}${e.id ? "#" + e.id : ""}${cls}${data}${aria ? " " + aria : ""}${text ? ` "${text}"` : ""}${away}`);
|
||||
const scrolls = /auto|scroll|overlay/.test(getComputedStyle(e).overflowY) && e.scrollHeight > e.clientHeight + 1;
|
||||
for (const c of e.children) walk(c, interesting ? depth + 1 : depth, scrolls ? Math.min(fold, r.bottom) : fold);
|
||||
};
|
||||
walk(document.body, 0, innerHeight);
|
||||
if (lines.length >= MAX) lines.push("… (cut)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function screenText(page) {
|
||||
return page.evaluate(() => document.documentElement.innerText + "\n" +
|
||||
[...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
|
||||
}
|
||||
async function checkLeak(page, where) {
|
||||
if (!secrets.length) return;
|
||||
const text = await screenText(page);
|
||||
for (const s of secrets) {
|
||||
// the whole key, or a long enough run of its middle
|
||||
const mid = s.slice(4, -4);
|
||||
if (text.includes(s) || (mid.length >= 12 && text.includes(mid))) {
|
||||
manifest.leak = true;
|
||||
throw new Error(`a secret is on screen (${where}); nothing will be published`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// any OpenAI-compatible endpoint; PLAN_API_URL/PLAN_API_KEY/PLAN_MODEL move
|
||||
// it off DeepSeek (AtlasCloud and friends) without touching this file
|
||||
const API_URL = env("PLAN_API_URL", "https://api.deepseek.com/v1/chat/completions");
|
||||
const API_KEY = env("PLAN_API_KEY") || env("DEEPSEEK_API_KEY");
|
||||
|
||||
async function askPlanner(messages) {
|
||||
// a reply cut short or not JSON is asked for again
|
||||
let last;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||
body: JSON.stringify({ model: MODEL, messages, response_format: { type: "json_object" }, max_tokens: 16000 }),
|
||||
signal: AbortSignal.timeout(240e3),
|
||||
});
|
||||
if (!res.ok) throw new Error(`planner API ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
const j = await res.json();
|
||||
const text = j.choices?.[0]?.message?.content ?? "";
|
||||
try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); }
|
||||
catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); }
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
// Two things for the planner, from the PR's source:
|
||||
//
|
||||
// 1. the changed frontend files themselves — a Vue component's template is
|
||||
// the surest guide to the selectors it renders;
|
||||
// 2. the entry chains to them: a dialog or panel the diff touches is often
|
||||
// rendered by some other component that isn't in the diff, opened by a
|
||||
// condition the diff never shows. For each changed .vue component we
|
||||
// grep the frontend for who imports it and under what v-if/v-show it is
|
||||
// rendered, then for who flips those conditions — the magpie-style
|
||||
// two-hop caller walk, adapted to Vue.
|
||||
const SKIP = /(__tests__|\.spec\.|\.test\.|\.bench\.|^apps\/desktop\/src\/i18n\/locales\/)/;
|
||||
async function readTree(root) {
|
||||
const out = [];
|
||||
const walk = async (dir) => {
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) await walk(p);
|
||||
else if (/\.(vue|ts)$/.test(e.name)) out.push(p);
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function codeContext(diff, src) {
|
||||
if (!src) return "";
|
||||
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))]
|
||||
.filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
|
||||
|
||||
// 1. the changed files
|
||||
let out = "";
|
||||
for (const f of changed.slice(0, 12)) {
|
||||
let body;
|
||||
try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; }
|
||||
const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`;
|
||||
if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; }
|
||||
out += chunk;
|
||||
}
|
||||
|
||||
// 2. entry chains: who renders the changed components, and who opens that
|
||||
const components = changed.filter((f) => f.endsWith(".vue")).map((f) => path.basename(f, ".vue"));
|
||||
if (!components.length) return out;
|
||||
const rel = (p) => path.relative(src, p);
|
||||
const files = (await readTree(path.join(src, "apps/desktop/src"))).filter((p) => !SKIP.test(rel(p)));
|
||||
const texts = new Map();
|
||||
const read = async (p) => {
|
||||
if (!texts.has(p)) {
|
||||
const b = await fs.readFile(p, "utf8").catch(() => "");
|
||||
texts.set(p, b.length > 400e3 ? "" : b);
|
||||
}
|
||||
return texts.get(p);
|
||||
};
|
||||
let entries = "";
|
||||
for (const name of components.slice(0, 6)) {
|
||||
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
const re = new RegExp(
|
||||
"import\\s+" + name + "\\b" +
|
||||
"|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" +
|
||||
"|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" +
|
||||
"|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
|
||||
const flags = new Map(); // caller file -> Set of line numbers
|
||||
for (const p of files) {
|
||||
const t = await read(p);
|
||||
if (!t || rel(p).endsWith(`/${name}.vue`)) continue;
|
||||
t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); });
|
||||
}
|
||||
for (const [p, lines] of flags) {
|
||||
// a dynamic import renames the component (const X = defineAsyncComponent):
|
||||
// resolve the local aliases, then mark where they are rendered — that
|
||||
// line, not the import, carries the v-if that shows it
|
||||
const ls = await read(p);
|
||||
const linesArr = ls.split("\n");
|
||||
const aliases = new Set([name]);
|
||||
for (const i of lines) {
|
||||
const m = /(?:import\s+(\w+)\s+from|const\s+(\w+)\s*=\s*(?:defineAsyncComponent|shallowRef|markRaw))/.exec(linesArr[i]);
|
||||
if (m && re.test(linesArr[i])) aliases.add(m[1] || m[2]);
|
||||
}
|
||||
if (aliases.size > 1) {
|
||||
const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]");
|
||||
linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); });
|
||||
}
|
||||
// the usage with its v-if/v-show/:open condition, and identifiers in it
|
||||
const span = [];
|
||||
for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n"));
|
||||
const cond = span.join("\n");
|
||||
const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`;
|
||||
if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; }
|
||||
entries += chunk;
|
||||
// who flips those conditions: identifiers in v-if / :open / v-model near the usage
|
||||
const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4);
|
||||
for (const id of ids) {
|
||||
const base = id.split(".").pop();
|
||||
const flip = new RegExp("\\b" + base + "\\s*=\\s*(true|false|!)|(?:function|const)\\s+" + base + "\\b|\\b" + base + "\\s*\\(");
|
||||
let shown = 0;
|
||||
for (const q of files) {
|
||||
if (shown >= 2) break;
|
||||
const qt = await read(q);
|
||||
if (!qt) continue;
|
||||
const qArr = qt.split("\n");
|
||||
for (let i = 0; i < qArr.length && shown < 2; i++) {
|
||||
if (!flip.test(qArr[i]) || re.test(qArr[i])) continue;
|
||||
const c = `--- ${rel(q)} flips ${id}:\n${qArr.slice(Math.max(0, i - 8), i + 8).join("\n")}\n`;
|
||||
if (entries.length + c.length > 12e3) break;
|
||||
entries += c;
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + (entries ? `\n=== how the changes are reached (other components rendering/opening them — the entry points to plan from) ===\n${entries}` : "");
|
||||
}
|
||||
|
||||
const GUIDE = `You plan a short screen recording of dbx, a database GUI (shown here in a browser at ${VIEW.width}x${VIEW.height}). It runs for real on a sandbox machine: the web build of the app against a dbx-web backend with password protection off, and made-up data beside it (seed.mjs) so the pages that need data have some. That data: two saved SQLite connections, "商店 Shop (SQLite)" (tables customers, products, orders, audit_log, a view v_daily_revenue, foreign keys and a trigger) and "分析 Analytics (SQLite)" (events with 5000 rows — the grid pages — and daily_stats with 90 days); a small sample.csv (name,category,price,stock) for import dialogs; a saved-SQL folder "常用查询" with three files; and query history over the last three days across both connections, real executions mixed with seeded entries, a few failures among them. The UI language is ${LOCALE}. What it doesn't have: any server database (MySQL, PostgreSQL, Redis, MongoDB… none are connected — connection dialogs can be opened but not completed), no AI provider configured, no plugins or JDBC drivers installed, no SSH tunnels.
|
||||
|
||||
dbx is a single-page app with no URL routing: every recording starts from the page as it opens. The left sidebar is a tree that opens one level at a time: a click on a connection lists its databases (SQLite shows one, "main 默认库"), a click on that database node lists its tables, and a click on a table opens its data grid — the query editor, table data, structure, ER diagram are that connection's tabs. Tree rows are plain divs inside div.connection-tree-content — click them as div.connection-tree-content div with the node's text, never as buttons. A double-click on a connection instead opens a database-browser tab, so working down the tree is how a table is reached; the outline you are given shows the app at rest, connections collapsed, so a node the plan needs that isn't in it has to be expanded into view first, not looked for on other pages. The search box above the tree (placeholder 搜索...) can reveal a node without that clicking, but while it holds text it filters the tree — a blank tree with text in the box means no match, and its text survives everything else the plan does: clear it with the × at its right end before walking the tree by hand, and never plan other steps while it holds text. The toolbar above the editor runs the SQL; other panels (SQL 库, 查询历史, 全局搜索, 设置) open from the interface itself. Action buttons in toolbars and dialogs are elements carrying data-slot="button", not the button tag — a step targeting "button" matches both, and grid toolbar actions also carry data-toolbar-action (camelCase, e.g. addRow). An editable table's grid toolbar has a 新增行 action (data-toolbar-action="addRow") that adds one row on a click; the icon button beside it, carrying aria-label="新增行" and no label text, opens a menu whose 插入多行… entry opens the insert-rows dialog (title 新增多行, row-count input #insert-rows-count). Two ways of working that reach further than clicks:
|
||||
- The query editor runs any SQL against the sandbox's SQLite databases (they are throwaway — writes are fine): when a state needs data (a result with no columns, an empty table, a table of a shape), open a connection, type the SQL that produces it and run it, and show the state it makes.
|
||||
- A file picker can't be clicked through, but a step can set it: { "do": "upload", "target": "input[type=file]", "value": "sample.csv" } — seed.mjs puts sample.csv (a small product list: name,category,price,stock) beside the databases, for import and mapping dialogs.
|
||||
|
||||
The diff is what the PR does. The title and description are the author's words and may be out of date or wrong: plan and summarise from the diff alone, and when the description claims something in the UI the diff doesn't do (a setting, a page, a button), don't look for it — say so in "mismatch".
|
||||
|
||||
You get the PR's title, description, diff, an outline of the app as it renders now, the changed frontend files themselves, and the entry chains: the other components that render the changed ones, under what v-if/v-show condition they are shown, and the code that flips those conditions. Use only selectors you can build from what the outline shows, or, for what only appears after an interaction (a dialog, a context menu, a dropdown, a tab), from the changed files and entry chains: the outline shows the app at rest, so an element the diff styles or builds being missing from it means you must open it first, not that it isn't there. The entry chains are the map — when a selector from the diff is nowhere in the outline, plan the steps that flip its condition (the toolbar button, the context menu, the tab the entry chain names), and don't search random other pages for it.
|
||||
|
||||
Write a plan that shows a reviewer exactly what this PR changes in the UI and whether it works as intended: go where the change is, do what a user would do to see it (open the connection, switch the tab, type the SQL, open the dialog…), and take a screenshot at each state that matters, before and after an interaction when that is the point. Put a wish for text in the step's "text" field, never inside the target selector ([aria-label="设置"] hides it from the runner's text fallback — target "button" with text "设置" instead). Captions say what is being done or what to look at ("打开订单表后的数据网格"), never what the result is or should be — the reviewer judges that from the picture, and the sandbox may differ from what you expect; and they never name a thing the diff doesn't add. When the change only shows with data this sandbox doesn't have (a server database, a plugin), say so in "unseen" and still show the place it would be. Keep it short: usually 1–3 scenes, under 15 steps each. Don't show unrelated pages. Never press anything that deletes, removes, clears or resets (删除/清空/移除/重置), and never run SQL that drops or truncates. Say ui_change false (and no scenes) only when the diff plainly changes nothing a user can see — only tests, comments, docs, or code that never reaches the screen; any change to the page's CSS, markup, text or behaviour is a UI change.
|
||||
|
||||
Reply with JSON only:
|
||||
{
|
||||
"ui_change": true,
|
||||
"summary": "one or two sentences, in Chinese, on what the diff changes in the UI",
|
||||
"mismatch": "Chinese, or empty: what the description says the UI gets that the diff doesn't do",
|
||||
"unseen": "Chinese, or empty: what of the change this sandbox can't show, and why",
|
||||
"scenes": [
|
||||
{ "title": "short Chinese title", "start": "app",
|
||||
"steps": [
|
||||
{ "do": "click", "target": "css selector", "text": "optional: only elements containing this text", "dblclick": false, "caption": "Chinese caption shown in the video" },
|
||||
{ "do": "hover", "target": "…", "caption": "…" },
|
||||
{ "do": "type", "target": "…", "value": "text to type", "caption": "…" },
|
||||
{ "do": "upload", "target": "input[type=file]", "value": "sample.csv", "caption": "…" },
|
||||
{ "do": "press", "key": "Escape" },
|
||||
{ "do": "scroll", "to": "css selector of what to bring into view", "text": "optional" },
|
||||
{ "do": "scroll", "target": "optional scroll container", "dy": 400 },
|
||||
{ "do": "wait", "ms": 800 },
|
||||
{ "do": "shot", "name": "kebab-case-name", "caption": "Chinese: what this screenshot shows", "target": "optional: capture just this element (with some room around it)" }
|
||||
] }
|
||||
]
|
||||
}`;
|
||||
|
||||
// the mouse glides like a hand would, so its trail reads in the video
|
||||
let mouse = { x: VIEW.width / 2, y: VIEW.height / 2 };
|
||||
async function glide(page, x, y) {
|
||||
const d = Math.hypot(x - mouse.x, y - mouse.y);
|
||||
const ms = Math.min(900, Math.max(250, d * 1.1));
|
||||
const n = Math.max(8, Math.round(ms / 16));
|
||||
const from = { ...mouse };
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
// a slight arc, not a ruler line
|
||||
const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08);
|
||||
const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend;
|
||||
const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend;
|
||||
await page.mouse.move(nx, ny);
|
||||
await sleep(16);
|
||||
}
|
||||
mouse = { x, y };
|
||||
}
|
||||
|
||||
async function locate(page, step) {
|
||||
if (!step.target) throw new Error(`${step.do} needs a target`);
|
||||
// dbx's UI kit renders its action buttons as elements carrying
|
||||
// data-slot="button" rather than the button tag, so a plan that says
|
||||
// "button" has to mean both
|
||||
const target = /^button$/i.test(step.target.trim()) ? 'button, [data-slot="button"]' : step.target;
|
||||
const all = page.locator(target);
|
||||
// a wish for text tucked into the selector ([aria-label="设置"], [title="Run"]):
|
||||
// take it out and treat it as the text filter, where the fallback can use it
|
||||
const attrText = /\[(?:aria-label|title|placeholder|data-slot|data-view)[*^$]?=~?"([^"]+)"/.exec(step.target)?.[1];
|
||||
const text = step.text || attrText || "";
|
||||
const visible = async (l) => {
|
||||
// the deepest match, not the first: a text filter matches a container and
|
||||
// everything inside it, and the click belongs on the innermost one — the
|
||||
// row, the label — not on the wrapper whose middle is often blank space
|
||||
const n = await l.count();
|
||||
let hit = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const one = l.nth(i);
|
||||
if (await one.isVisible()) hit = one;
|
||||
}
|
||||
return hit;
|
||||
};
|
||||
const countVisible = async (l) => {
|
||||
const n = await l.count();
|
||||
let c = 0;
|
||||
for (let i = 0; i < n; i++) if (await l.nth(i).isVisible()) c++;
|
||||
return c;
|
||||
};
|
||||
// a text match is usually a container whose subtree carries the text: walk
|
||||
// down while exactly one visible child still contains it, so the click
|
||||
// lands on the row or the label, not on a wrapper whose middle is blank
|
||||
const deepest = async (hit, text) => {
|
||||
if (!hit || !text) return hit;
|
||||
try {
|
||||
const el = await hit.evaluateHandle((n, text) => {
|
||||
for (;;) {
|
||||
const kids = [...n.children].filter((c) => (c.textContent || "").includes(text) && c.getBoundingClientRect().width > 0);
|
||||
if (kids.length === 1) n = kids[0]; else return n;
|
||||
}
|
||||
}, text);
|
||||
return el.asElement() || hit;
|
||||
} catch { return hit; }
|
||||
};
|
||||
let loc = text ? all.filter({ hasText: text }) : all;
|
||||
let hit = await visible(loc);
|
||||
// hasText matches an element's content, and a form field's content is its
|
||||
// value — empty until typed into — so input[placeholder="搜索..."] filters
|
||||
// itself out. The bare selector was right all along: trust it when exactly
|
||||
// one visible element answers to it.
|
||||
if (!hit && text && (await countVisible(all)) === 1) {
|
||||
loc = all;
|
||||
step = { ...step, text: "" };
|
||||
hit = await visible(loc);
|
||||
}
|
||||
if (hit) return deepest(hit, text);
|
||||
// the selector guessed a role or structure this app doesn't use (a tab, a
|
||||
// menuitem, a tree node…): fall back to the one visible thing carrying the
|
||||
// text — only when exactly one is on screen, so a wrong guess fails loud
|
||||
if (step.text) {
|
||||
const loose = page.locator('button, a, li, span, div[class]:not([class=""]), [role="menuitem"], [role="tab"], [role="button"], [role="treeitem"], [class*="categor"] *, [class*="nav"] *, [class*="tree"] *, [class*="sidebar"] *').filter({ hasText: step.text });
|
||||
const seen = [];
|
||||
const n = await loose.count();
|
||||
for (let i = 0; i < n && seen.length <= 2; i++) {
|
||||
const one = loose.nth(i);
|
||||
if (await one.isVisible()) seen.push(one);
|
||||
}
|
||||
// at most two on screen (the same text in a parent and its child): take
|
||||
// the last, which in DOM order is the innermost; more means the wish for
|
||||
// the text is too vague — fail loud rather than click something wrong
|
||||
if (seen.length >= 1 && seen.length <= 2) return deepest(await seen.at(-1), step.text);
|
||||
}
|
||||
throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`);
|
||||
}
|
||||
|
||||
|
||||
// Out of sight is scrolled to as a reader would, with the wheel over what
|
||||
// scrolls. Each view that hides it, the innermost first, is wheeled in small
|
||||
// steps until the element is in the middle of it, or at its top when it's
|
||||
// taller.
|
||||
async function bringIntoView(page, loc) {
|
||||
const PAD = 24;
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const views = await loc.evaluate((e, PAD) => {
|
||||
const out = [];
|
||||
const clip = { top: 0, bottom: innerHeight, left: 0, right: innerWidth };
|
||||
const scrollers = [];
|
||||
for (let s = e.parentElement; s; s = s.parentElement) {
|
||||
const root = s === document.scrollingElement;
|
||||
const o = getComputedStyle(s).overflowY;
|
||||
if ((root || /auto|scroll|overlay/.test(o)) && s.scrollHeight > s.clientHeight + 1) scrollers.push(s);
|
||||
}
|
||||
const r = e.getBoundingClientRect();
|
||||
for (const s of scrollers) {
|
||||
const root = s === document.scrollingElement;
|
||||
const b = root ? clip : s.getBoundingClientRect();
|
||||
// what of it shows: inside the window and every view around it
|
||||
let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight);
|
||||
let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth);
|
||||
for (let p = s.parentElement; p && !root; p = p.parentElement) {
|
||||
if (!scrollers.includes(p) || p === document.scrollingElement) continue;
|
||||
const pb = p.getBoundingClientRect();
|
||||
top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom);
|
||||
left = Math.max(left, pb.left); right = Math.min(right, pb.right);
|
||||
}
|
||||
const room = bottom - top - 2 * PAD;
|
||||
const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD);
|
||||
const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2);
|
||||
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)),
|
||||
x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
|
||||
}
|
||||
return out;
|
||||
}, PAD);
|
||||
const v = views.find((v) => !v.shown && Math.abs(v.dy) >= 4 && v.seen);
|
||||
if (!v) return;
|
||||
await glide(page, v.x, v.y);
|
||||
await wheel(page, v.dy);
|
||||
await sleep(350);
|
||||
}
|
||||
}
|
||||
|
||||
// a hand's flick: a few notches at a time, not one jump
|
||||
async function wheel(page, dy) {
|
||||
for (let left = dy; Math.abs(left) >= 1;) {
|
||||
const n = Math.sign(left) * Math.min(Math.abs(left), 90);
|
||||
await page.mouse.wheel(0, n);
|
||||
left -= n;
|
||||
await sleep(28);
|
||||
}
|
||||
}
|
||||
|
||||
async function pointAt(page, loc) {
|
||||
await bringIntoView(page, loc);
|
||||
const b = await loc.boundingBox();
|
||||
if (!b) throw new Error("the element has no box");
|
||||
const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2;
|
||||
await glide(page, x, y);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The app polls, so the network never goes quiet: loaded, its toolbar and
|
||||
// the seeded connections painted in, is ready
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState("load").catch(() => {});
|
||||
await page.waitForFunction(() => {
|
||||
const txt = document.body.innerText || "";
|
||||
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
|
||||
}, null, { timeout: 20000 }).catch(() => {});
|
||||
await sleep(1500);
|
||||
}
|
||||
|
||||
const caption = (page, text) => page.evaluate((t) => window.__uiPreviewCaption?.(t), text || "").catch(() => {});
|
||||
let shotN = 0;
|
||||
|
||||
async function runStep(page, step, scene) {
|
||||
if (step.caption) await caption(page, step.caption);
|
||||
switch (step.do) {
|
||||
case "click": {
|
||||
const loc = await locate(page, step);
|
||||
const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || "");
|
||||
if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`);
|
||||
await pointAt(page, loc);
|
||||
await sleep(180);
|
||||
if (step.dblclick) {
|
||||
await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
} else {
|
||||
await page.mouse.down();
|
||||
await sleep(90);
|
||||
await page.mouse.up();
|
||||
}
|
||||
// a click that opens something (a menu, the settings, a dialog) needs
|
||||
// its new DOM painted before the next step looks for it
|
||||
await sleep(1200);
|
||||
break;
|
||||
}
|
||||
case "hover": {
|
||||
await pointAt(page, await locate(page, step));
|
||||
await sleep(900);
|
||||
break;
|
||||
}
|
||||
case "type": {
|
||||
const loc = await locate(page, step);
|
||||
await pointAt(page, loc);
|
||||
await page.mouse.down(); await sleep(80); await page.mouse.up();
|
||||
await loc.fill("");
|
||||
await page.keyboard.type(String(step.value ?? ""), { delay: 70 });
|
||||
await sleep(600);
|
||||
break;
|
||||
}
|
||||
case "upload": {
|
||||
// a file for an <input type=file>: the name is resolved inside SEED_DIR
|
||||
// (seed.mjs puts a sample CSV there) — the OS picker can't be driven
|
||||
const loc = step.target ? await locate(page, step) : page.locator('input[type="file"]').first();
|
||||
const dir = env("SEED_DIR", ".");
|
||||
const file = path.resolve(dir, String(step.value ?? step.file ?? "sample.csv"));
|
||||
await loc.setInputFiles(file);
|
||||
await sleep(800);
|
||||
break;
|
||||
}
|
||||
case "press":
|
||||
await page.keyboard.press(step.key || "Escape");
|
||||
await sleep(500);
|
||||
break;
|
||||
case "scroll": {
|
||||
if (step.to) {
|
||||
await bringIntoView(page, await locate(page, { ...step, target: step.to }));
|
||||
await sleep(400);
|
||||
break;
|
||||
}
|
||||
// over the view to scroll, or the page's middle: the wheel turns what's under the mouse
|
||||
if (step.target) await pointAt(page, await locate(page, step));
|
||||
else if (mouse.y < 60) await glide(page, VIEW.width / 2, VIEW.height / 2);
|
||||
await wheel(page, step.dy ?? 400);
|
||||
await sleep(700);
|
||||
break;
|
||||
}
|
||||
case "wait":
|
||||
await sleep(Math.min(step.ms ?? 800, 4000));
|
||||
break;
|
||||
case "shot": {
|
||||
// in sight first, scrolled to on camera: the clip is of the screen
|
||||
const loc = step.target ? await locate(page, step) : null;
|
||||
if (loc) await bringIntoView(page, loc);
|
||||
await sleep(250);
|
||||
await checkLeak(page, `before ${step.name}`);
|
||||
const file = `${String(++shotN).padStart(2, "0")}-${(step.name || "shot").replace(/[^a-z0-9-]/gi, "-").slice(0, 40)}.png`;
|
||||
// the screenshot without the drawn mouse and caption
|
||||
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.setProperty("visibility", "hidden"));
|
||||
let clip;
|
||||
try {
|
||||
if (loc) {
|
||||
const b = await loc.boundingBox();
|
||||
if (b) {
|
||||
// the target with room around it, never so small that a button
|
||||
// is shown without where it is
|
||||
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48));
|
||||
const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2));
|
||||
const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2));
|
||||
clip = { x, y, width: w, height: h };
|
||||
}
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, file), clip });
|
||||
} finally {
|
||||
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.removeProperty("visibility"));
|
||||
}
|
||||
// a shot taken after a failed step in its scene shows the state the
|
||||
// plan actually reached, not the one the caption claims
|
||||
scene.shots.push({ file, caption: step.caption || step.name || "", width: 2 * (clip?.width ?? VIEW.width), warn: scene.derailed || undefined });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown step ${step.do}`);
|
||||
}
|
||||
if (step.do !== "shot") await checkLeak(page, `after ${step.do}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT, { recursive: true });
|
||||
const diff = await fs.readFile(env("DIFF_FILE"), "utf8");
|
||||
const body = await fs.readFile(env("PR_BODY_FILE", "/dev/null"), "utf8").catch(() => "");
|
||||
const browser = await chromium.launch();
|
||||
const ctxOpts = { viewport: VIEW, deviceScaleFactor: 2, locale: LOCALE, colorScheme: "light" };
|
||||
|
||||
// what the app looks like now, for the plan
|
||||
const look = await browser.newContext(ctxOpts);
|
||||
const lp = await look.newPage();
|
||||
const outlines = {};
|
||||
try {
|
||||
await lp.goto(BASE);
|
||||
await settle(lp);
|
||||
outlines.app = await lp.evaluate(outline);
|
||||
} catch (e) { outlines.app = `(failed to load: ${e.message})`; }
|
||||
await look.close();
|
||||
|
||||
const MAXDIFF = 150e3;
|
||||
const code = await codeContext(diff, env("SRC_DIR")).catch((e) => (log("code context:", e.message), ""));
|
||||
log(`code context: ${code.length} characters`);
|
||||
const ask = [
|
||||
{ role: "system", content: GUIDE },
|
||||
{ role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` +
|
||||
`=== outline: app ===\n${outlines.app}` +
|
||||
(code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") },
|
||||
];
|
||||
let plan;
|
||||
try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask); }
|
||||
catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); }
|
||||
if (!plan || plan.ui_change === false || !plan.scenes?.length) {
|
||||
manifest.summary = plan?.summary || "";
|
||||
manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan";
|
||||
// still something to look at: the app as it opens
|
||||
plan = { summary: manifest.summary, scenes: plan ? [] : [{ title: "应用主界面", start: "app", steps: [{ do: "shot", name: "app", caption: "应用主界面" }] }] };
|
||||
}
|
||||
manifest.summary = plan.summary || "";
|
||||
manifest.mismatch = plan.mismatch || "";
|
||||
manifest.unseen = plan.unseen || "";
|
||||
log("plan:", JSON.stringify(plan, null, 1));
|
||||
|
||||
if (plan.scenes.length) {
|
||||
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
|
||||
await rec.addInitScript({ path: path.join(here, "cursor.js") });
|
||||
let repairs = 0, interactive = false;
|
||||
// one take per scene: every scene starts from the app as it opens, and
|
||||
// its video keeps only what happens once the app has settled — the
|
||||
// load never reaches the final cut, so a scene change reads as a cut,
|
||||
// not as the app reloading out of nowhere
|
||||
const takes = [];
|
||||
for (const s of plan.scenes.slice(0, 5)) {
|
||||
const scene = { title: s.title || "", shots: [], derailed: false };
|
||||
manifest.scenes.push(scene);
|
||||
const page = await rec.newPage();
|
||||
const began = Date.now();
|
||||
let ready = 0;
|
||||
try {
|
||||
await page.goto(BASE);
|
||||
await settle(page);
|
||||
await page.evaluate(([x, y]) => window.__uiPreviewAt?.(x, y), [mouse.x, mouse.y]);
|
||||
ready = Date.now();
|
||||
} catch (e) {
|
||||
manifest.errors.push(`${scene.title}: open: ${e.message}`);
|
||||
await page.close().catch(() => {});
|
||||
if (manifest.leak) break;
|
||||
continue;
|
||||
}
|
||||
// the scene's title readable on the settled page for a beat, before
|
||||
// the first step's caption replaces it
|
||||
await caption(page, scene.title);
|
||||
await sleep(1100);
|
||||
let steps = (s.steps || []).slice(0, 25);
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (["click", "hover", "type", "press", "scroll", "upload"].includes(step.do)) interactive = true;
|
||||
try { await runStep(page, step, scene); }
|
||||
catch (e) {
|
||||
if (manifest.leak) break;
|
||||
manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`);
|
||||
log("step failed:", e.message);
|
||||
scene.derailed = true;
|
||||
// whatever the failed step (or an earlier stray one) left open — a
|
||||
// menu, a dropdown, a confirm dialog — swallows every click and
|
||||
// shows up in the shots that follow: clear it before going on
|
||||
await page.keyboard.press("Escape").catch(() => {});
|
||||
await sleep(400);
|
||||
// an active sidebar search keeps the tree filtered down to
|
||||
// nothing, starving every later lookup of its targets: empty it
|
||||
// the way a user would
|
||||
try {
|
||||
const box = page.locator(".connection-tree-search input").first();
|
||||
if ((await box.isVisible().catch(() => false)) && (await box.inputValue().catch(() => ""))) {
|
||||
await box.click({ timeout: 1500 });
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("Delete");
|
||||
await sleep(300);
|
||||
}
|
||||
} catch {}
|
||||
if (repairs >= 3) continue;
|
||||
repairs++;
|
||||
// the page as it is now, and what went wrong: the rest of the scene again
|
||||
try {
|
||||
const fix = await askPlanner([...ask, { role: "assistant", content: JSON.stringify(plan) },
|
||||
{ role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]);
|
||||
if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); }
|
||||
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
|
||||
}
|
||||
}
|
||||
if (manifest.leak) { await page.close().catch(() => {}); break; }
|
||||
await caption(page, "");
|
||||
await sleep(600);
|
||||
takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) });
|
||||
await page.close();
|
||||
}
|
||||
await rec.close();
|
||||
manifest.interactive = interactive;
|
||||
// a video when there's something to watch: an interaction
|
||||
if (!manifest.leak && interactive && takes.length) {
|
||||
// the takes joined, each already trimmed of its load
|
||||
const args = ["-y", "-loglevel", "error"];
|
||||
const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`));
|
||||
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30",
|
||||
path.join(OUT, "preview.mp4"));
|
||||
execFileSync("ffmpeg", args);
|
||||
manifest.video = "preview.mp4";
|
||||
manifest.poster = await poster(browser);
|
||||
}
|
||||
await fs.rm(path.join(OUT, "raw"), { recursive: true, force: true });
|
||||
}
|
||||
await browser.close();
|
||||
if (manifest.leak) {
|
||||
for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true });
|
||||
manifest.scenes = []; manifest.video = manifest.poster = null;
|
||||
}
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak }));
|
||||
}
|
||||
|
||||
// the video's still in the PR: a frame from the middle with a play button
|
||||
async function poster(browser) {
|
||||
const frame = path.join(OUT, "frame.png");
|
||||
const dur = parseFloat(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path.join(OUT, "preview.mp4")]).toString()) || 2;
|
||||
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-ss", String(Math.min(dur / 3, 6)), "-i", path.join(OUT, "preview.mp4"), "-frames:v", "1", frame]);
|
||||
const img = (await fs.readFile(frame)).toString("base64");
|
||||
const p = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 });
|
||||
await p.setContent(`<body style="margin:0;background:url(data:image/png;base64,${img}) center/cover;height:100vh;display:grid;place-items:center">
|
||||
<div style="position:absolute;inset:0;background:rgba(0,0,0,.28)"></div>
|
||||
<div style="position:relative;display:flex;align-items:center;gap:18px;padding:22px 34px 22px 26px;border-radius:999px;background:rgba(17,17,19,.78);color:#fff;font:600 30px -apple-system,'Segoe UI','Noto Sans CJK SC',sans-serif">
|
||||
<svg width="46" height="46" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#fff"/><path d="M9.5 7.5v9l7-4.5z" fill="#111"/></svg>
|
||||
播放录屏 · ${Math.round(dur)} 秒</div></body>`);
|
||||
await p.screenshot({ path: path.join(OUT, "poster.png") });
|
||||
await p.close();
|
||||
await fs.rm(frame);
|
||||
return "poster.png";
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
manifest.errors.push(`recorder: ${e.message}`);
|
||||
await fs.mkdir(OUT, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)).catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the PR's frontend against a dbx-web backend in a home of its own, with
|
||||
# made-up data beside it (seed.mjs: two SQLite databases, connections, a
|
||||
# saved-SQL library, query history), and records it (record.mjs).
|
||||
#
|
||||
# run.sh <out dir>
|
||||
#
|
||||
# env: DBX_WEB_BIN the dbx-web binary (or the static package's launcher)
|
||||
# — required
|
||||
# DIST_DIR the PR's frontend build; when set it replaces the
|
||||
# binary's embedded frontend (DBX_STATIC_DIR)
|
||||
# DBX_PORT listen port (default 4280)
|
||||
# DEEPSEEK_API_KEY, DIFF_FILE, PR_TITLE, PR_BODY_FILE, SRC_DIR (the
|
||||
# PR's source, for code context) — read by record.mjs;
|
||||
# PLAYWRIGHT_BROWSERS_PATH must already point at the installed
|
||||
# Chromium, since HOME moves.
|
||||
#
|
||||
# For local testing against a dbx-web that already runs somewhere: set
|
||||
# DBX_URL and nothing here is started or seeded.
|
||||
set -euo pipefail
|
||||
[ "$(uname -s)" = Linux ] || { echo "run.sh: Linux only" >&2; exit 1; }
|
||||
out_arg=$1
|
||||
mkdir -p "$out_arg"
|
||||
out=$(cd "$out_arg" && pwd)
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
work=$(mktemp -d)
|
||||
web=""
|
||||
trap 'kill "${web:-}" 2>/dev/null || true' EXIT
|
||||
|
||||
url=${DBX_URL:-}
|
||||
if [ -z "$url" ]; then
|
||||
: "${DBX_WEB_BIN:?DBX_WEB_BIN or DBX_URL is required}"
|
||||
port=${DBX_PORT:-4280}
|
||||
# nothing of the runner's own: the app sees only this home
|
||||
export HOME="$work/home" XDG_CONFIG_HOME="$work/home/.config" XDG_CACHE_HOME="$work/home/.cache" \
|
||||
XDG_DATA_HOME="$work/home/.local/share" XDG_STATE_HOME="$work/home/.local/state"
|
||||
mkdir -p "$HOME" "$work/data" "$work/dbs"
|
||||
|
||||
static=()
|
||||
[ -n "${DIST_DIR:-}" ] && static=(DBX_STATIC_DIR="$(cd "$DIST_DIR" && pwd)")
|
||||
env DBX_PORT="$port" DBX_BIND_ADDR=127.0.0.1 DBX_DISABLE_PASSWORD=1 \
|
||||
DBX_DATA_DIR="$work/data" "${static[@]}" \
|
||||
"$DBX_WEB_BIN" >"$work/web.log" 2>&1 &
|
||||
web=$!
|
||||
url="http://127.0.0.1:$port"
|
||||
for _ in $(seq 60); do
|
||||
curl -fsS -o /dev/null "$url/api/auth/check" 2>/dev/null && break
|
||||
kill -0 "$web" 2>/dev/null || { cat "$work/web.log"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS -o /dev/null "$url/api/auth/check" || { echo "run.sh: dbx-web never became ready" >&2; cat "$work/web.log"; exit 1; }
|
||||
|
||||
node "$here/seed.mjs" dbs "$work/dbs"
|
||||
node "$here/seed.mjs" api "$url" "$work/dbs"
|
||||
export SEED_DIR="$work/dbs"
|
||||
fi
|
||||
|
||||
status=0
|
||||
DBX_URL="$url" OUT_DIR="$out" node "$here/record.mjs" || status=$?
|
||||
exit $status
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
// Fills the UI preview's sandbox (run.sh) with data to show: a fresh dbx-web
|
||||
// has no connections, so a change to the sidebar, the grid, query history or
|
||||
// the ER diagram has nothing to draw. Made up, every bit of it, and never
|
||||
// real.
|
||||
//
|
||||
// node seed.mjs dbs <dir> [--as <path on the server>] — two SQLite
|
||||
// databases with fixed, deterministic content (python3 writes them, so
|
||||
// the files are identical byte-for-byte on every run);
|
||||
// node seed.mjs api <base-url> <dir> [--as <path on the server>]
|
||||
// — saves the connections (SQLite: the file path is the host), connects,
|
||||
// runs real queries through the gateway so history and result caches
|
||||
// are genuine, then adds a saved-SQL library and a few more history
|
||||
// entries the UI can list.
|
||||
//
|
||||
// The connections point at files as the dbx-web process sees them: pass
|
||||
// --as when the databases are generated on another machine than the one
|
||||
// dbx-web runs on (local testing over an SSH tunnel).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const arg = (n) => process.argv[n];
|
||||
const opt = (name, d) => {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d;
|
||||
};
|
||||
|
||||
const DBS = [
|
||||
{
|
||||
file: "dbx-shop.sqlite",
|
||||
name: "商店 Shop (SQLite)",
|
||||
note: "订单、商品与客户示例库",
|
||||
tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)",
|
||||
warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"],
|
||||
},
|
||||
{
|
||||
file: "dbx-analytics.sqlite",
|
||||
name: "分析 Analytics (SQLite)",
|
||||
note: "事件流与每日指标",
|
||||
tables: "events(5000 行,看分页)· daily_stats(90 天)",
|
||||
warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"],
|
||||
},
|
||||
];
|
||||
|
||||
// Deterministic content: the same LCG magpie's seed uses, so two previews of
|
||||
// one PR differ only by the PR.
|
||||
function python(dir) {
|
||||
const py = `
|
||||
import os, sqlite3, random, datetime
|
||||
rng = random.Random(20261008)
|
||||
def d(days, hours=0):
|
||||
return (datetime.datetime(2026, 10, 8, 12, 0, 0) - datetime.timedelta(days=days, hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
shop = sqlite3.connect(os.path.join(${JSON.stringify(dir)}, "dbx-shop.sqlite"))
|
||||
shop.executescript("""
|
||||
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT, city TEXT, level TEXT, created_at TEXT);
|
||||
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT, price REAL, stock INTEGER, listed INTEGER DEFAULT 1);
|
||||
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(id), product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER, amount REAL, status TEXT, created_at TEXT);
|
||||
CREATE TABLE audit_log (id INTEGER PRIMARY KEY, ts TEXT, what TEXT);
|
||||
CREATE INDEX idx_orders_created ON orders(created_at);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE VIEW v_daily_revenue AS SELECT date(created_at) AS day, COUNT(*) AS orders, ROUND(SUM(amount), 2) AS revenue FROM orders GROUP BY date(created_at);
|
||||
CREATE TRIGGER trg_orders_audit AFTER INSERT ON orders BEGIN INSERT INTO audit_log(ts, what) VALUES (datetime('now'), 'order ' || NEW.id); END;
|
||||
""")
|
||||
cities = ["上海", "北京", "深圳", "杭州", "成都", "广州", "武汉", "南京"]
|
||||
surnames = ["王", "李", "张", "刘", "陈", "杨", "赵", "周", "吴", "徐"]
|
||||
givens = ["晓东", "思远", "雨桐", "子墨", "一鸣", "嘉怡", "浩然", "若彤", "俊杰", "梦琪"]
|
||||
levels = ["bronze", "silver", "gold"]
|
||||
for i in range(1, 201):
|
||||
shop.execute("INSERT INTO customers VALUES (?,?,?,?,?,?)",
|
||||
(i, rng.choice(surnames) + rng.choice(givens), f"user{i}@example.com", rng.choice(cities), rng.choice(levels), d(rng.randint(30, 400), rng.randint(0, 23))))
|
||||
cats = ["键盘", "显示器", "鼠标", "硬盘", "内存", "耳机", "摄像头", "扩展坞"]
|
||||
for i in range(1, 121):
|
||||
shop.execute("INSERT INTO products VALUES (?,?,?,?,?,?)",
|
||||
(i, f"{rng.choice(cats)} {chr(65 + i % 26)}{i:03d}", rng.choice(cats), round(rng.uniform(49, 4999), 2), rng.randint(0, 500), 1 if rng.random() > 0.1 else 0))
|
||||
statuses = ["paid", "shipped", "done", "refund"]
|
||||
for i in range(1, 501):
|
||||
q = rng.randint(1, 3)
|
||||
price = rng.uniform(49, 4999)
|
||||
shop.execute("INSERT INTO orders VALUES (?,?,?,?,?,?,?)",
|
||||
(i, rng.randint(1, 200), rng.randint(1, 120), q, round(q * price, 2), rng.choice(statuses) if rng.random() > 0.15 else "pending", d(rng.randint(0, 90), rng.randint(0, 23))))
|
||||
shop.commit()
|
||||
|
||||
an = sqlite3.connect(os.path.join(${JSON.stringify(dir)}, "dbx-analytics.sqlite"))
|
||||
an.executescript("""
|
||||
CREATE TABLE events (id INTEGER PRIMARY KEY, ts TEXT, user_id INTEGER, event_type TEXT, page TEXT, duration_ms INTEGER);
|
||||
CREATE TABLE daily_stats (stat_date TEXT PRIMARY KEY, dau INTEGER, revenue REAL, new_users INTEGER);
|
||||
CREATE INDEX idx_events_ts ON events(ts);
|
||||
CREATE INDEX idx_events_type ON events(event_type);
|
||||
""")
|
||||
types = ["page_view", "click", "scroll", "search", "submit", "error"]
|
||||
pages = ["/", "/grid", "/editor", "/history", "/settings", "/diagram"]
|
||||
day = datetime.date(2026, 10, 8)
|
||||
for i in range(1, 5001):
|
||||
back = rng.randint(0, 29)
|
||||
ts = datetime.datetime(2026, 10, 8, rng.randint(0, 23), rng.randint(0, 59), rng.randint(0, 59)) - datetime.timedelta(days=back)
|
||||
an.execute("INSERT INTO events VALUES (?,?,?,?,?,?)", (i, ts.strftime("%Y-%m-%d %H:%M:%S"), rng.randint(1, 400), rng.choice(types) if rng.random() > 0.02 else "error", rng.choice(pages), rng.randint(50, 180000) if rng.random() > 0.1 else None))
|
||||
for k in range(90):
|
||||
date = (day - datetime.timedelta(days=k)).strftime("%Y-%m-%d")
|
||||
an.execute("INSERT INTO daily_stats VALUES (?,?,?,?)", (date, rng.randint(180, 420), round(rng.uniform(800, 5200), 2), rng.randint(3, 26)))
|
||||
an.commit()
|
||||
print("ok")
|
||||
`;
|
||||
execFileSync("python3", ["-c", py], { stdio: ["ignore", "inherit", "inherit"] });
|
||||
}
|
||||
|
||||
async function api(base, p, init = {}) {
|
||||
const res = await fetch(base.replace(/\/$/, "") + p, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`);
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
|
||||
async function seedApi() {
|
||||
const base = arg(3);
|
||||
const dir = path.resolve(arg(4));
|
||||
const as = path.resolve(opt("--as", dir));
|
||||
const now = Date.now();
|
||||
const iso = (msAgo) => new Date(now - msAgo).toISOString();
|
||||
|
||||
const conns = DBS.map((db, i) => ({
|
||||
id: `seed-${db.file.replace(".sqlite", "")}`,
|
||||
name: db.name,
|
||||
note: db.note,
|
||||
db_type: "sqlite",
|
||||
driver_label: "SQLite",
|
||||
host: path.join(as, db.file),
|
||||
port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "main",
|
||||
color: i === 0 ? "#2f6fed" : "#8f4bd6",
|
||||
}));
|
||||
await api(base, "/api/connection/save", { method: "POST", body: JSON.stringify({ configs: conns, removed_ids: [] }) });
|
||||
console.log("[ui-preview] saved", conns.length, "connections");
|
||||
|
||||
for (const c of conns) {
|
||||
await api(base, "/api/connection/connect", { method: "POST", body: JSON.stringify({ config: c, client_attempt: 1 }) });
|
||||
const info = await api(base, `/api/connection/info?connection_id=${encodeURIComponent(c.id)}`).catch((e) => (console.log("[ui-preview] connection info:", e.message), null));
|
||||
const database = info?.current_database || info?.databases?.[0] || "main";
|
||||
const db = DBS.find((x) => c.host.endsWith(x.file));
|
||||
for (const sql of db.warm) {
|
||||
const r = await api(base, "/api/query/execute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ connectionId: c.id, database, sql, maxRows: 200 }),
|
||||
}).catch((e) => (console.log("[ui-preview] warm query failed:", e.message), null));
|
||||
if (r) console.log("[ui-preview] ran", sql.split("\n")[0].slice(0, 60));
|
||||
}
|
||||
}
|
||||
|
||||
// A saved-SQL library, so the SQL library panel has folders and files.
|
||||
const folder = await api(base, "/api/saved-sql/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: "seed-folder-daily", name: "常用查询", createdAt: iso(36e5 * 72), updatedAt: iso(36e5 * 5) }),
|
||||
}).catch(() => null);
|
||||
const saved = [
|
||||
{ id: "seed-sql-1", name: "各城市营收 Top", sql: "SELECT c.city, COUNT(*) AS orders, ROUND(SUM(o.amount), 2) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC" },
|
||||
{ id: "seed-sql-2", name: "近 14 天 DAU", sql: "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14" },
|
||||
{ id: "seed-sql-3", name: "低库存商品", sql: "SELECT name, category, stock FROM products WHERE stock < 20 AND listed = 1 ORDER BY stock" },
|
||||
];
|
||||
for (const [i, s] of saved.entries()) {
|
||||
await api(base, "/api/saved-sql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily",
|
||||
database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)),
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] saved-sql:", e.message));
|
||||
}
|
||||
console.log("[ui-preview] saved-sql library seeded");
|
||||
|
||||
// A few more history entries besides the real ones above: spread over the
|
||||
// last three days, one failure included, so the history list pages.
|
||||
const extra = [
|
||||
[conns[0], "SELECT COUNT(*) FROM orders WHERE status = 'pending'", 12, true],
|
||||
[conns[0], "SELECT * FROM v_daily_revenue ORDER BY day DESC LIMIT 7", 18, true],
|
||||
[conns[0], "UPDATE products SET stock = stock - 1 WHERE id = 42", 6, false],
|
||||
[conns[1], "SELECT stat_date, dau, revenue FROM daily_stats ORDER BY stat_date DESC LIMIT 30", 15, true],
|
||||
[conns[1], "SELECT page, COUNT(*) FROM events WHERE event_type = 'error' GROUP BY page", 22, true],
|
||||
[conns[1], "DELETE FROM events WHERE id < 0", 4, false],
|
||||
];
|
||||
for (const [c, sql, ms, ok] of extra) {
|
||||
await api(base, "/api/history/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
entry: {
|
||||
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name,
|
||||
database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok,
|
||||
error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor",
|
||||
},
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] history:", e.message));
|
||||
}
|
||||
console.log("[ui-preview] history seeded");
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
if (cmd === "dbs") {
|
||||
const dir = path.resolve(arg(3));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
python(dir);
|
||||
// a small importable file, for import/mapping dialogs (record.mjs's
|
||||
// "upload" step resolves names inside this directory)
|
||||
fs.writeFileSync(path.join(dir, "sample.csv"),
|
||||
"name,category,price,stock\n" +
|
||||
Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
|
||||
console.log("[ui-preview] databases written to", dir);
|
||||
} else if (cmd === "api") {
|
||||
seedApi().catch((e) => { console.error(e); process.exit(1); });
|
||||
} else {
|
||||
console.error("usage: seed.mjs dbs <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1631,9 +1631,26 @@ jobs:
|
||||
- name: Verify managed JDBC assets in all offline ZIPs
|
||||
run: node agents/scripts/verify_offline_jdbc_release.mjs release
|
||||
|
||||
- name: Fetch previous agent registry for delta patches
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.release-token.outputs.token }}
|
||||
run: |
|
||||
mkdir -p prev-release
|
||||
if gh release download agents-latest --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern agent-registry.json --dir prev-release --clobber; then
|
||||
echo "previous agent-registry.json fetched for delta generation"
|
||||
else
|
||||
echo "no previous agents-latest registry found; delta patches are skipped"
|
||||
rm -rf prev-release
|
||||
fi
|
||||
|
||||
- name: Build single-driver packages
|
||||
run: |
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
|
||||
PREV_ARGS=()
|
||||
if [ -f prev-release/agent-registry.json ]; then
|
||||
PREV_ARGS+=(--previous-registry prev-release/agent-registry.json)
|
||||
fi
|
||||
python3 agents/scripts/build_driver_zips.py release --cleanup-sources "${PREV_ARGS[@]}"
|
||||
python3 -m json.tool release/agent-registry.json > /dev/null
|
||||
echo "=== final agent-registry.json ==="
|
||||
cat release/agent-registry.json
|
||||
@@ -1776,12 +1793,48 @@ jobs:
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ steps.release-token.outputs.token }}
|
||||
files: release/*
|
||||
body_path: RELEASE_NOTES.md
|
||||
make_latest: false
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.release-token.outputs.token }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Idempotent release publish: create the release only when missing and
|
||||
# upload only assets it does not have yet, in small spaced batches.
|
||||
# Re-uploading the full set on every retry trips GitHub secondary
|
||||
# rate limits and dropped connections strand the whole job.
|
||||
if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
# --draft=false: a tag deleted mid-recovery leaves the release as a
|
||||
# draft, which /releases/tags then 404s for reuse jobs and users.
|
||||
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file RELEASE_NOTES.md --draft=false --latest=false >/dev/null
|
||||
else
|
||||
gh release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag --title "$TAG_NAME" \
|
||||
--notes-file RELEASE_NOTES.md --latest=false
|
||||
fi
|
||||
|
||||
mapfile -t existing < <(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name')
|
||||
declare -A have=()
|
||||
for name in "${existing[@]:-}"; do
|
||||
[ -n "$name" ] && have["$name"]=1
|
||||
done
|
||||
|
||||
missing=()
|
||||
for f in release/*; do
|
||||
[ -f "$f" ] || continue
|
||||
[ -n "${have["$(basename "$f")"]:-}" ] || missing+=("$f")
|
||||
done
|
||||
echo "release already has ${#existing[@]} assets; uploading ${#missing[@]} missing"
|
||||
|
||||
batch_size=5
|
||||
i=0
|
||||
while [ "$i" -lt "${#missing[@]}" ]; do
|
||||
gh release upload "$TAG_NAME" "${missing[@]:$i:$batch_size}" --repo "$GITHUB_REPOSITORY"
|
||||
i=$((i + batch_size))
|
||||
if [ "$i" -lt "${#missing[@]}" ]; then sleep 20; fi
|
||||
done
|
||||
echo "uploaded ${#missing[@]} assets in batches of $batch_size"
|
||||
|
||||
- name: Update agents-latest GitHub release
|
||||
env:
|
||||
@@ -1830,6 +1883,7 @@ jobs:
|
||||
--recursive \
|
||||
--exclude "*" \
|
||||
--include "dbx-agent-*.tar.zst" \
|
||||
--include "dbx-agent-*.tar.zst.delta" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
echo "Uploaded single-driver packages"
|
||||
|
||||
|
||||
+192
-108
@@ -7,8 +7,9 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}-{1}', github.event.pull_request.user.id, (endsWith(github.event.pull_request.number, '0') || endsWith(github.event.pull_request.number, '2') || endsWith(github.event.pull_request.number, '4') || endsWith(github.event.pull_request.number, '6') || endsWith(github.event.pull_request.number, '8')) && 'even' || 'odd') || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login == github.repository_owner }}
|
||||
queue: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && 'max' || 'single' }}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only
|
||||
@@ -156,7 +157,20 @@ jobs:
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: GitHub script tests
|
||||
run: node --test .github/scripts/*.test.mjs
|
||||
env:
|
||||
FAST_CHECKS: ${{ needs.changes.outputs.fast }}
|
||||
run: |
|
||||
tests=()
|
||||
for file in .github/scripts/*.test.mjs; do
|
||||
if [[ "$FAST_CHECKS" == true && "${file##*/}" == ci-* ]]; then
|
||||
continue
|
||||
fi
|
||||
tests+=("$file")
|
||||
done
|
||||
node --test "${tests[@]}"
|
||||
|
||||
- name: Docker mirror config tests
|
||||
run: python3 .github/scripts/configure-docker-mirror.test.py
|
||||
|
||||
- name: PowerShell MCP installer tests
|
||||
shell: pwsh
|
||||
@@ -171,9 +185,7 @@ jobs:
|
||||
NPM_CONFIG_LOGLEVEL: error
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fork PRs cannot read repository secrets, so they build without sccache
|
||||
# (see rust-fmt-clippy) and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -190,8 +202,16 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
|
||||
# Pin mold because runner image updates can change bundled DuckDB C++ links.
|
||||
# The pinned build prevents duckdb_create_config runtime failures.
|
||||
@@ -211,7 +231,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -220,8 +240,7 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -232,13 +251,15 @@ jobs:
|
||||
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}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
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}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -249,15 +270,18 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-packages-x86_64-unknown-linux-gnu
|
||||
# Release-linking (LTO) dominates this job and sccache cannot cache
|
||||
# those crate types.
|
||||
cache-workspace-crates: true
|
||||
# Preserve completed dependency builds when a later test step fails.
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -277,7 +301,7 @@ jobs:
|
||||
|
||||
windows-standard-check:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows_win7_bundle == 'true'
|
||||
if: needs.changes.outputs.windows_standard == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -752,10 +776,7 @@ jobs:
|
||||
env:
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fork PRs cannot read repository secrets, and the GHA sccache backend can
|
||||
# never hold entries for this repo because main only populates S3, so fork
|
||||
# builds run without sccache and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The fast lane skips only system font discovery while retaining the other default capabilities.
|
||||
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
|
||||
steps:
|
||||
@@ -763,8 +784,16 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
@@ -776,8 +805,7 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -788,13 +816,15 @@ jobs:
|
||||
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}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
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}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -805,12 +835,15 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-rust-fmt-clippy-x86_64-unknown-linux-gnu
|
||||
# Preserve completed dependency builds when a later lint step fails.
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -818,6 +851,15 @@ jobs:
|
||||
run: |-
|
||||
node .github/scripts/ci-rust.mjs clippy workspace "$RUST_FEATURE_MODE"
|
||||
|
||||
- name: Upload Rust Cargo timings
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-clippy-cargo-timings
|
||||
path: target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
@@ -834,20 +876,35 @@ jobs:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Deep async export tests exceed the Rust test harness's default thread stack on Linux.
|
||||
RUST_MIN_STACK: 8388608
|
||||
# Fork PRs cannot read repository secrets, and the GHA sccache backend can
|
||||
# never hold entries for this repo because main only populates S3, so fork
|
||||
# builds run without sccache and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The fast lane skips only system font discovery while retaining the other default capabilities.
|
||||
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
|
||||
RUST_TEST_GROUP: ${{ matrix.group }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Configure public Docker Hub cache
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
source /etc/os-release
|
||||
[[ "$ID" == ubuntu ]] || exit 0
|
||||
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
|
||||
timeout 30s sudo systemctl restart docker
|
||||
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
|
||||
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
if: matrix.group != 'foundation'
|
||||
|
||||
# Pin mold because runner image updates can change bundled C++ links.
|
||||
@@ -866,8 +923,7 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -878,13 +934,15 @@ jobs:
|
||||
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}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
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}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -895,19 +953,22 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-rust-test-v2-x86_64-unknown-linux-gnu
|
||||
# Test linking dominates this job and sccache cannot cache those crate types.
|
||||
cache-workspace-crates: true
|
||||
# Preserve completed dependency builds when a later test step fails.
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -921,6 +982,15 @@ jobs:
|
||||
run: |-
|
||||
mold -run node .github/scripts/ci-rust.mjs doctest "$RUST_TEST_GROUP" "$RUST_FEATURE_MODE"
|
||||
|
||||
- name: Upload Rust Cargo timings
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-rust-${{ matrix.group }}-cargo-timings
|
||||
path: target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
@@ -1031,7 +1101,7 @@ jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}
|
||||
packages: ${{ steps.filter.outputs.packages }}
|
||||
rust: ${{ steps.plan.outputs.rust }}
|
||||
rust_full: ${{ steps.plan.outputs.rust_full }}
|
||||
@@ -1040,7 +1110,8 @@ jobs:
|
||||
agents: ${{ steps.plan.outputs.agents }}
|
||||
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows == 'true' || steps.plan.outputs.duckdb_windows == 'true' }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
windows_standard: ${{ steps.plan.outputs.windows_standard }}
|
||||
windows_win7_bundle: ${{ steps.plan.outputs.windows_win7_bundle }}
|
||||
windows_win7_candidate: ${{ steps.plan.outputs.windows_win7_candidate }}
|
||||
windows_win7_affected_packages: ${{ steps.plan.outputs.windows_win7_affected_packages }}
|
||||
windows_win7_reasons: ${{ steps.plan.outputs.windows_win7_reasons }}
|
||||
@@ -1059,14 +1130,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Fetch change base
|
||||
id: change-base
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then
|
||||
BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"
|
||||
if [[ -z "$BASE_SHA" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||
@@ -1076,7 +1148,9 @@ jobs:
|
||||
uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
base: ${{ steps.change-base.outputs.sha }}
|
||||
ref: ${{ github.sha }}
|
||||
filters: |
|
||||
frontend:
|
||||
- 'apps/desktop/**'
|
||||
@@ -1159,35 +1233,16 @@ jobs:
|
||||
- 'flake.lock'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/update-nix-pnpm-hash.yml'
|
||||
windows_win7_bundle:
|
||||
- '.github/scripts/assert-win7-pe-compat.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/assert-win7-installer-content.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-runtime.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-runtime.ps1'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/release.yml'
|
||||
- 'src-tauri/tauri.webview2-win7-fixed.conf.json'
|
||||
- 'src-tauri/build.rs'
|
||||
- 'src-tauri/Cargo.toml'
|
||||
- 'src-tauri/windows/nsis/**'
|
||||
- 'src-tauri/src/commands/update.rs'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
- 'crates/dbx-drivers/**'
|
||||
- 'crates/dbx-driver-*/**'
|
||||
- 'crates/dbx-platform/**'
|
||||
- 'crates/dbx-core/src/host/update.rs'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'vendor/ctor/**'
|
||||
- 'vendor/dirs-sys/**'
|
||||
- 'vendor/pageant/**'
|
||||
- 'vendor/webview2-com-sys/**'
|
||||
- 'vendor/wry/**'
|
||||
github_scripts:
|
||||
- '.github/scripts/**'
|
||||
- 'docs/scripts/contributor-snapshot.mjs'
|
||||
- 'docs/scripts/update-contributors.mjs'
|
||||
- 'docs/public/install-mcp*'
|
||||
- '.github/workflows/contributors.yml'
|
||||
- '.github/workflows/issue-claim.yml'
|
||||
- '.github/workflows/notify.yml'
|
||||
- '.github/workflows/plugin-dev-host.yml'
|
||||
- '.github/workflows/spam-comment-guard.yml'
|
||||
- '.github/workflows/mcp-release.yml'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
- '.github/workflows/plugin-release-reusable.yml'
|
||||
@@ -1200,7 +1255,6 @@ jobs:
|
||||
BASE_SHA: ${{ steps.change-base.outputs.sha }}
|
||||
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
|
||||
AGENTS_CHANGED: ${{ steps.filter.outputs.agents }}
|
||||
WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
run: node .github/scripts/ci-plan.mjs
|
||||
|
||||
agents:
|
||||
@@ -1319,9 +1373,7 @@ jobs:
|
||||
env:
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fork PRs cannot read repository secrets, so they build without sccache
|
||||
# (see rust-fmt-clippy) and lean on the rust-cache restore from main.
|
||||
RUSTC_WRAPPER: ${{ secrets.SCCACHE_S3_BUCKET != '' && 'sccache' || '' }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
defaults:
|
||||
run:
|
||||
working-directory: agents
|
||||
@@ -1330,8 +1382,16 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
if: matrix.driver == 'duckdb'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libdbus-1-dev pkg-config
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
# Pin mold because runner image updates can change bundled DuckDB C++ links.
|
||||
@@ -1345,8 +1405,7 @@ jobs:
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
- name: Configure S3 sccache
|
||||
if: env.RUSTC_WRAPPER == 'sccache'
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
@@ -1357,13 +1416,15 @@ jobs:
|
||||
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}"
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
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}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
@@ -1374,15 +1435,19 @@ jobs:
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
} >> "$GITHUB_ENV"
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: agents/drivers/${{ matrix.driver }} -> target
|
||||
# A failed bundled native build can leave link-compatible but invalid artifacts.
|
||||
shared-key: ci-agent-rust-v2-${{ matrix.driver }}
|
||||
cache-on-failure: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
# mold -run wraps the whole process tree (see the packages job note);
|
||||
@@ -1391,11 +1456,19 @@ jobs:
|
||||
env:
|
||||
DRIVER: ${{ matrix.driver }}
|
||||
run: |
|
||||
mold -run cargo nextest run --manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast
|
||||
mold -run cargo nextest run --manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast --timings
|
||||
mold -run cargo test --doc --manifest-path "drivers/$DRIVER/Cargo.toml" --locked
|
||||
- name: TDengine native agent build
|
||||
if: matrix.driver == 'tdengine'
|
||||
run: mold -run cargo build --manifest-path drivers/tdengine/Cargo.toml --locked --release --bin dbx-tdengine-driver
|
||||
- name: Upload Rust Cargo timings
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: DBX-agent-${{ matrix.driver }}-cargo-timings
|
||||
path: agents/drivers/${{ matrix.driver }}/target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
# Diagnostics only; agent jobs must not swallow build/test failures
|
||||
# (ci-workflow.test.mjs), so keep this step best-effort at the command level.
|
||||
- name: Show sccache stats
|
||||
@@ -1427,7 +1500,7 @@ jobs:
|
||||
run: bash ../.github/scripts/ci-agent-go.sh "$DRIVER" "$BINARY" "$RACE"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.agent_go) }}
|
||||
agent-integration:
|
||||
needs: [changes, fast-checks, agent-checks]
|
||||
@@ -1438,6 +1511,16 @@ jobs:
|
||||
working-directory: agents
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Configure public Docker Hub cache
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
source /etc/os-release
|
||||
[[ "$ID" == ubuntu ]] || exit 0
|
||||
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
|
||||
timeout 30s sudo systemctl restart docker
|
||||
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
|
||||
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
|
||||
- name: Setup RocketMQ server Java
|
||||
if: matrix.scenario == 'rocketmq'
|
||||
uses: actions/setup-java@v6
|
||||
@@ -1455,15 +1538,16 @@ jobs:
|
||||
if: matrix.driver == 'tdengine'
|
||||
- name: Install nextest
|
||||
if: matrix.driver == 'tdengine'
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
- name: TDengine Rust cache
|
||||
if: matrix.driver == 'tdengine'
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: agents/drivers/tdengine -> target
|
||||
cache-on-failure: true
|
||||
cache-on-failure: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.version == '3.4.2.2' }}
|
||||
- name: Run one live engine and version
|
||||
env:
|
||||
@@ -1474,7 +1558,7 @@ jobs:
|
||||
name: agent-integration (${{ matrix.scenario }}, ${{ matrix.version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.agent_integration) }}
|
||||
ci:
|
||||
needs:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Update Contributors
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
- cron: "0 18 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -16,7 +16,11 @@ permissions:
|
||||
jobs:
|
||||
claim:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
if: >-
|
||||
${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(contains(github.event.comment.body, '/claim') ||
|
||||
contains(github.event.comment.body, '/unclaim') ||
|
||||
contains(github.event.comment.body, '/close')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
run: cargo check -p dbx-mcp -p dbx-cli --no-default-features
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -453,7 +453,22 @@ jobs:
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
# npm can accept a publish but leave the version stuck in a staged
|
||||
# (invisible, blocks republication) state; the only recovery is
|
||||
# to drop the staged version and publish again.
|
||||
publish_log="${RUNNER_TEMP}/npm-publish.log"
|
||||
if ! npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance 2>&1 | tee "$publish_log"; then
|
||||
if ! grep -q "previously staged version" "$publish_log"; then
|
||||
exit 1
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} became visible despite the error; continuing."
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::npm holds ${{ matrix.package-name }}@${VERSION} in a staged (invisible) state; dropping it and retrying."
|
||||
npm unpublish "${{ matrix.package-name }}@${VERSION}" --force
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
fi
|
||||
|
||||
publish-cli:
|
||||
name: Publish @dbx-app/cli
|
||||
@@ -709,6 +724,25 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm can accept a publish but leave the version stuck in a staged
|
||||
# (invisible, blocks republication) state; the only recovery is
|
||||
# to drop the staged version and publish again.
|
||||
npm_publish_with_staged_recovery() {
|
||||
local publish_log="${RUNNER_TEMP}/npm-publish.log"
|
||||
if ! npm publish "$@" --access public --provenance 2>&1 | tee "$publish_log"; then
|
||||
if ! grep -q "previously staged version" "$publish_log"; then
|
||||
return 1
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} became visible despite the error; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "::warning::npm holds ${{ matrix.package-name }}@${VERSION} in a staged (invisible) state; dropping it and retrying."
|
||||
npm unpublish "${{ matrix.package-name }}@${VERSION}" --force
|
||||
npm publish "$@" --access public --provenance
|
||||
return
|
||||
fi
|
||||
}
|
||||
if [[ "${{ runner.os }}" == "macOS" ]]; then
|
||||
work_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
@@ -727,14 +761,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
cmp "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" "$work_dir/package/bin/${{ matrix.binary }}"
|
||||
npm publish "$tarball" --access public --provenance --ignore-scripts
|
||||
npm_publish_with_staged_recovery "$tarball" --ignore-scripts
|
||||
exit 0
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
npm_publish_with_staged_recovery "./packages/${{ matrix.package-dir }}"
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
|
||||
@@ -3,15 +3,16 @@ name: Notifications
|
||||
on:
|
||||
# issues:
|
||||
# types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened, closed]
|
||||
release:
|
||||
types: [published]
|
||||
# pull_request_target:
|
||||
# types: [closed]
|
||||
workflow_dispatch:
|
||||
# Release 不在此通知:桌面正式版由 publish-packages.yml 在转正后发飞书卡片;
|
||||
# 组件包(agents-v*/packages-v*)发布不进群。
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'release' || (!github.event.release.draft && !github.event.release.prerelease) }}
|
||||
steps:
|
||||
- name: Build message
|
||||
id: msg
|
||||
@@ -27,10 +28,6 @@ jobs:
|
||||
PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
PR_MERGED: ${{ github.event.pull_request.merged }}
|
||||
PR_MERGED_BY: ${{ github.event.pull_request.merged_by.login }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import os
|
||||
@@ -49,32 +46,8 @@ jobs:
|
||||
# f"提交者: {user}",
|
||||
# f"链接: {url}",
|
||||
# ])
|
||||
#
|
||||
# elif event == "release":
|
||||
if event == "release":
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
name = os.environ["RELEASE_NAME"]
|
||||
url = os.environ["RELEASE_URL"]
|
||||
body = os.environ.get("RELEASE_BODY", "")
|
||||
|
||||
import re
|
||||
body = re.sub(r'#{1,6}\s*', '', body)
|
||||
body = re.sub(r'\*\*(.+?)\*\*', r'\1', body)
|
||||
body = re.sub(r'\*(.+?)\*', r'\1', body)
|
||||
body = re.sub(r'`(.+?)`', r'\1', body)
|
||||
body = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', body)
|
||||
body = re.sub(r'\n{3,}', '\n\n', body)
|
||||
body = body.strip()
|
||||
|
||||
msg = "\n".join([
|
||||
f"🚀 新版本发布 {tag}",
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
f"下载: {url}",
|
||||
])
|
||||
|
||||
elif event in ("pull_request", "pull_request_target"):
|
||||
if event in ("pull_request", "pull_request_target"):
|
||||
title = os.environ["PR_TITLE"]
|
||||
url = os.environ["PR_URL"]
|
||||
# if action == "opened":
|
||||
@@ -165,7 +138,7 @@ jobs:
|
||||
# PYEOF
|
||||
|
||||
- name: Send to Feishu
|
||||
if: ${{ steps.msg.outputs.skip != 'true' && github.event_name != 'release' }}
|
||||
if: ${{ steps.msg.outputs.skip != 'true' }}
|
||||
env:
|
||||
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
|
||||
MSG_CONTENT: ${{ steps.msg.outputs.msg }}
|
||||
@@ -173,35 +146,3 @@ jobs:
|
||||
curl -s -X POST "$FEISHU_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg text "$MSG_CONTENT" '{msg_type: "text", content: {text: $text}}')"
|
||||
|
||||
- name: Send to QQ group
|
||||
# PR 合并通知不再发 QQ 群,QQ 群仅保留 release 正式发布通知
|
||||
# if: steps.msg.outputs.skip != 'true'
|
||||
if: steps.msg.outputs.skip != 'true' && github.event_name != 'pull_request_target'
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
ASTRBOT_API_KEY: ${{ secrets.ASTRBOT_API_KEY }}
|
||||
MSG_CONTENT: ${{ steps.msg.outputs.msg }}
|
||||
# AI_ANALYSIS: ${{ steps.ai.outputs.analysis }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# if [ -n "$AI_ANALYSIS" ]; then
|
||||
# MSG_CONTENT="${MSG_CONTENT}
|
||||
#
|
||||
# 🤖 AI 分析:
|
||||
# ${AI_ANALYSIS}"
|
||||
# fi
|
||||
|
||||
PAYLOAD=$(jq -nc --arg msg "$MSG_CONTENT" \
|
||||
'{"umo": "default:GroupMessage:1087880322", "message": $msg}')
|
||||
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 @-"; then
|
||||
echo "::warning::QQ group notification failed; the event notification was sent to Feishu."
|
||||
fi
|
||||
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
|
||||
@@ -11,8 +11,22 @@ on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- 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
|
||||
test:
|
||||
needs: checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -29,11 +43,9 @@ jobs:
|
||||
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
|
||||
timeout-minutes: 5
|
||||
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
- uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
- run: cargo nextest run --locked --manifest-path plugins/sdk/cli/Cargo.toml --no-fail-fast
|
||||
|
||||
@@ -502,3 +502,22 @@ jobs:
|
||||
-H 'Authorization: Bearer ${ASTRBOT_API_KEY}' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @-"
|
||||
|
||||
notify-feishu:
|
||||
name: Notify Feishu group
|
||||
needs: [promote-release]
|
||||
if: ${{ inputs.notify }}
|
||||
# Card sends after promotion so the title reflects the formal release.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Send release card to Feishu
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
FEISHU_CHAT_ID: ${{ secrets.FEISHU_CHAT_ID }}
|
||||
run: python3 scripts/notify_feishu.py "${{ github.event.inputs.tag }}"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: Pull Request Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
# Temporarily disabled (2026-10-10): informational labels only, no
|
||||
# mechanical consumers, fires on every PR event. Uncomment to re-enable.
|
||||
# pull_request_target:
|
||||
# types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: pull-request-labels-${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -1089,25 +1089,6 @@ jobs:
|
||||
--retain 5 \
|
||||
--apply
|
||||
|
||||
notify-feishu:
|
||||
name: Notify Feishu group
|
||||
needs: [publish, sync-release-to-cnb]
|
||||
# Card sends once the release is public even if the CNB mirror sync failed.
|
||||
if: always() && needs.publish.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Send release card to Feishu
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
FEISHU_CHAT_ID: ${{ secrets.FEISHU_CHAT_ID }}
|
||||
run: python3 scripts/notify_feishu.py "${{ github.ref_name }}"
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
@@ -12,7 +12,12 @@ jobs:
|
||||
delete-malicious-link:
|
||||
runs-on: ubuntu-latest
|
||||
# 跳过机器人与仓库 owner,避免误删维护者的正当评论
|
||||
if: ${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner }}
|
||||
if: >-
|
||||
${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner &&
|
||||
(contains(github.event.comment.body, '.zip') ||
|
||||
contains(github.event.comment.body, '.7z') ||
|
||||
contains(github.event.comment.body, '.exe') ||
|
||||
contains(github.event.comment.body, '.scr')) }}
|
||||
env:
|
||||
# owner PAT:GITHUB_TOKEN 非 admin 删不掉别人的评论,必须用 owner 令牌
|
||||
GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }}
|
||||
|
||||
@@ -20,6 +20,9 @@ concurrency:
|
||||
jobs:
|
||||
sync-cnb:
|
||||
name: Sync to CNB
|
||||
# Mirror destinations are fixed to the upstream remotes; forks must never
|
||||
# publish their refs there, even with mirrored secrets (#10944).
|
||||
if: github.repository == 't8y2/dbx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -41,6 +44,7 @@ jobs:
|
||||
|
||||
sync-atomgit:
|
||||
name: Sync to AtomGit
|
||||
if: github.repository == 't8y2/dbx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -62,6 +66,7 @@ jobs:
|
||||
|
||||
sync-gitee:
|
||||
name: Sync to Gitee
|
||||
if: github.repository == 't8y2/dbx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
name: UI preview
|
||||
|
||||
# A pull request that changes the UI gets screenshots — and a screen
|
||||
# recording, the mouse drawn in, when the change needs clicking to see — of
|
||||
# the real app built from it, in a fixed block at the foot of its
|
||||
# description. See .github/ui-preview/.
|
||||
#
|
||||
# Pull requests come from forks, so this runs on pull_request_target with
|
||||
# the scripts from main. The PR's code is only built and run in the record
|
||||
# job, which holds the planner's API key (environment ui-preview, no approval
|
||||
# asked: any PR's code can read it, so the key there is one with a low
|
||||
# limit) and nothing that can write to the repo. The publish job, which can
|
||||
# write, never runs the PR's code.
|
||||
#
|
||||
# The backend the recording runs against comes in two lanes: a diff that
|
||||
# only touches the frontend (the usual case) runs against the released
|
||||
# dbx-web static binary — no Rust is built; a diff that reaches crates/,
|
||||
# plugins/ or src-tauri/ builds dbx-web from the PR itself. Either way the
|
||||
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
|
||||
|
||||
on:
|
||||
# Temporarily disabled (2026-10-10) while jobs queue without a runner;
|
||||
# manual dispatch below still works. Uncomment to re-enable.
|
||||
# pull_request_target:
|
||||
# types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: Pull request number
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: ui-preview-${{ github.event.pull_request.number || inputs.pr }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'ui-preview'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
ui: ${{ steps.detect.outputs.ui }}
|
||||
lane: ${{ steps.detect.outputs.lane }}
|
||||
sha: ${{ steps.detect.outputs.sha }}
|
||||
number: ${{ steps.detect.outputs.number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- id: detect
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/ui-preview/publish.mjs detect
|
||||
# what the recorder reads of the PR, handed over so its job needs no token
|
||||
- if: steps.detect.outputs.ui == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ steps.detect.outputs.number }}
|
||||
run: |
|
||||
mkdir pr-info
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" -H "Accept: application/vnd.github.diff" > pr-info/pr.diff
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .title > pr-info/title.txt
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '.body // ""' > pr-info/body.txt
|
||||
- if: steps.detect.outputs.ui == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-info
|
||||
path: pr-info
|
||||
retention-days: 3
|
||||
|
||||
record:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.ui == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: ${{ needs.detect.outputs.lane == 'rust' && 60 || 30 }}
|
||||
environment: ui-preview
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.pw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
path: ci
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
# the fork's code, on purpose: this job is the one meant to run it,
|
||||
# with a read-only token and no credentials left in the checkout
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.detect.outputs.sha }}
|
||||
path: pr
|
||||
persist-credentials: false
|
||||
allow-unsafe-pr-checkout: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-info
|
||||
path: pr-info
|
||||
|
||||
# the frontend, from the PR, for both lanes
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: pr/package.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pr/pnpm-lock.yaml
|
||||
- name: Build the PR's frontend
|
||||
working-directory: pr
|
||||
run: |
|
||||
pnpm install --frozen-lockfile || pnpm install
|
||||
pnpm build
|
||||
|
||||
# the backend: released static binary, or built from the PR
|
||||
- name: The released dbx-web (frontend-only diff)
|
||||
if: needs.detect.outputs.lane != 'rust'
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p backend
|
||||
gh release download --repo "$GITHUB_REPOSITORY" --pattern '*x64-browser-static.tar.gz' --dir backend --clobber
|
||||
tar xzf backend/*x64-browser-static.tar.gz -C backend
|
||||
echo "DBX_WEB_BIN=$(echo backend/*/dbx-web)" >> "$GITHUB_ENV"
|
||||
- name: dbx-web built from the PR (backend diff)
|
||||
if: needs.detect.outputs.lane == 'rust'
|
||||
working-directory: pr
|
||||
run: |
|
||||
cargo build --release -p dbx-web --no-default-features --features dbx-core/sqlite-bundled
|
||||
echo "DBX_WEB_BIN=pr/target/release/dbx-web" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Chromium, ffmpeg and fonts
|
||||
working-directory: ci/.github/ui-preview
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg fonts-noto-cjk fonts-noto-color-emoji
|
||||
npm install --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Record
|
||||
env:
|
||||
PLAN_API_URL: https://api.atlascloud.ai/v1/chat/completions
|
||||
PLAN_API_KEY: ${{ secrets.PLAN_API_KEY }}
|
||||
PLAN_MODEL: ${{ vars.UI_PREVIEW_PLAN_MODEL || 'openai/gpt-5.4-mini' }}
|
||||
DIFF_FILE: ${{ github.workspace }}/pr-info/pr.diff
|
||||
PR_BODY_FILE: ${{ github.workspace }}/pr-info/body.txt
|
||||
DIST_DIR: ${{ github.workspace }}/pr/dist
|
||||
SRC_DIR: ${{ github.workspace }}/pr
|
||||
run: PR_TITLE="$(cat pr-info/title.txt)" ci/.github/ui-preview/run.sh out
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-preview
|
||||
path: out
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
publish:
|
||||
needs: [detect, record]
|
||||
if: always() && needs.detect.outputs.ui == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ui-preview
|
||||
path: out
|
||||
- env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR: ${{ needs.detect.outputs.number }}
|
||||
SHA: ${{ needs.detect.outputs.sha }}
|
||||
OUT_DIR: out
|
||||
RECORD_RESULT: ${{ needs.record.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/ui-preview/publish.mjs publish
|
||||
@@ -60,8 +60,10 @@ Thumbs.db
|
||||
# GitHub (keep workflows)
|
||||
.github/*
|
||||
!.github/CODEOWNERS
|
||||
!.github/FUNDING.yml
|
||||
!.github/workflows/
|
||||
!.github/scripts/
|
||||
!.github/ui-preview/
|
||||
!.github/ISSUE_TEMPLATE/
|
||||
|
||||
# Env
|
||||
|
||||
Generated
+96
-29
@@ -262,6 +262,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -1527,9 +1533,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2038,8 +2044,18 @@ version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
dependencies = [
|
||||
"darling_core 0.24.1",
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2055,17 +2071,41 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core 0.24.1",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -2103,7 +2143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.31"
|
||||
version = "0.6.41"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2119,6 +2159,7 @@ dependencies = [
|
||||
"font-kit",
|
||||
"futures",
|
||||
"grep",
|
||||
"gtk",
|
||||
"ignore",
|
||||
"libloading 0.8.9",
|
||||
"log",
|
||||
@@ -2186,7 +2227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.103"
|
||||
version = "0.4.116"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
@@ -2366,6 +2407,7 @@ dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
@@ -2393,7 +2435,9 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"futures-util",
|
||||
"redis",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2522,7 +2566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.103"
|
||||
version = "0.4.116"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2538,6 +2582,7 @@ dependencies = [
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"schemars 1.2.2",
|
||||
"security-framework 3.7.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
@@ -2560,6 +2605,7 @@ dependencies = [
|
||||
"serde",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2687,6 +2733,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-core",
|
||||
"tauri",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2703,7 +2750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.31"
|
||||
version = "0.6.41"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -5851,10 +5898,10 @@ name = "mysql-common-derive"
|
||||
version = "0.32.2"
|
||||
source = "git+https://github.com/t8y2/rust_mysql_common.git?rev=47740d85a8277167a5717afac785d37b46a36296#47740d85a8277167a5717afac785d37b46a36296"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"heck 0.5.0",
|
||||
"manyhow",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5909,7 +5956,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"mysql-common-derive",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
@@ -6060,7 +6107,7 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
@@ -6078,6 +6125,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -6134,7 +6191,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
@@ -7589,22 +7646,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.7"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"crc16",
|
||||
"futures-sink",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.2",
|
||||
"rustls 0.23.43",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"ryu",
|
||||
@@ -7614,6 +7673,7 @@ dependencies = [
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7874,18 +7934,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "2.2.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
|
||||
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"indexmap 2.14.0",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
@@ -7906,15 +7967,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "2.2.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
|
||||
checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.24.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8016,7 +8077,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -8773,7 +8834,7 @@ version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -9016,7 +9077,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
@@ -12073,6 +12134,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
|
||||
@@ -82,7 +82,7 @@ dev: node_modules/.modules.yaml check-tauri-dev-port
|
||||
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
|
||||
# os-keyring must stay in sync with src-tauri defaults: without it the keychain
|
||||
# key is unreadable and the secret-store migration wizard reappears on every launch.
|
||||
RUST_MIN_STACK=16777216 $(PNPM) dev:tauri -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring
|
||||
RUST_MIN_STACK=16777216 $(PNPM) dev:tauri -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring,sqlserver-native-attention
|
||||
|
||||
dev-web: node_modules/.modules.yaml
|
||||
$(PNPM) dev:web
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Apache%20CouchDB-E42528?logo=apachecouchdb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
@@ -68,6 +69,17 @@
|
||||
## ❤️ Sponsors
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jumpserver.org/" target="_blank">
|
||||
<img src="docs/public/sponsors/jumpserver-card.png" alt="JumpServer" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
JumpServer is an Open-source Privileged Access Management (PAM) platform with AI-powered capabilities, providing DevOps and IT teams a unified workspace to securely access SSH, RDP, Kubernetes, databases, websites, RemoteApp, VirtualApp, and more.
|
||||
<a href="https://github.com/jumpserver/jumpserver" target="_blank">Visit JumpServer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
@@ -79,17 +91,6 @@
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jalapeno-cloud.ai/DBX" target="_blank">
|
||||
@@ -123,6 +124,17 @@
|
||||
<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.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">
|
||||
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HiAPI is a developer-focused API platform for image, video, audio, and text models — GPT Image 2.5 (Flare & Sunburst), OpenAI-compatible text endpoints, and Remote MCP/Agent Skills for Claude Code, Cursor, and more. Pay as you go with no minimum spend; new users get 200 Credits on signup, plus 10% bonus Credits on top-ups.
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">Visit HiAPI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
@@ -134,6 +146,40 @@
|
||||
<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://www.packyapi.ai/register?aff=wyDA" target="_blank">
|
||||
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
PackyCode is a stable, efficient API relay for mainstream AI models. Access them with one API endpoint and one API key — automatic failover keeps access fast and reliable, with dedicated high-speed routes for Codex and Claude Code. Get started with $1 in free credits, a first top-up discount, and up to 80% off eligible routes; pay in RMB with no currency conversion markups or extra top-up fees.
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">Visit PackyCode</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://mate.vip/ee/" target="_blank">
|
||||
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
|
||||
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -224,7 +270,7 @@
|
||||
|
||||
### 100+ 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. All in a single ~25 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, CouchDB, 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. All in a single ~25 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
|
||||
+59
-13
@@ -29,6 +29,7 @@
|
||||
<img src="https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Elasticsearch-005571?logo=elasticsearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Meilisearch-FF5CAA?logo=meilisearch&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Apache%20CouchDB-E42528?logo=apachecouchdb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TiDB-DC150B?logo=tidb&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Doris-0052CC?logoColor=white" />
|
||||
@@ -68,6 +69,17 @@
|
||||
## ❤️ 赞助商
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jumpserver.org/" target="_blank">
|
||||
<img src="docs/public/sponsors/jumpserver-card.png" alt="JumpServer" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
JumpServer 是具备 AI 能力的开源特权访问管理(PAM)平台,为 DevOps 和 IT 团队提供统一的工作空间,安全访问 SSH、RDP、Kubernetes、数据库、网站、RemoteApp、VirtualApp 等资源。
|
||||
<a href="https://www.jumpserver.org/" target="_blank">访问 JumpServer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
@@ -79,17 +91,6 @@
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jalapeno-cloud.ai/DBX" target="_blank">
|
||||
@@ -123,6 +124,17 @@
|
||||
<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.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">
|
||||
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HiAPI 是面向开发者的图片、视频、音频及文本模型 API 平台——GPT Image 2.5(Flare 与 Sunburst)、OpenAI 兼容文本接口,并可通过 Remote MCP/Agent Skills 接入 Claude Code、Cursor 等编程工具。按量付费、无最低消费;新用户注册赠送 200 Credits,充值送 10% 积分。
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">访问 HiAPI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
@@ -134,6 +146,40 @@
|
||||
<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://www.packyapi.ai/register?aff=wyDA" target="_blank">
|
||||
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
PackyCode 是一家稳定、高效的 API 中转服务商,一句话接入主流大模型。统一域名、统一密钥、智能容灾切换,97% 可用性;人民币 1:1 充值,无汇率无手续费坑,新用户首充立享折扣 + $1 免费体验额度,多分组折扣低至 2 折起,并提供专属 Codex/Claude Code 高速通道。
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">访问 PackyCode</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://mate.vip/ee/" target="_blank">
|
||||
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
|
||||
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -224,7 +270,7 @@
|
||||
|
||||
### 100+ 种数据库,一个工具搞定
|
||||
|
||||
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。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、CouchDB、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。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -598,7 +644,7 @@ DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
|
||||
|
||||
<details>
|
||||
<summary><strong>支持哪些数据库?</strong></summary>
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT)。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、CouchDB、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -421,7 +421,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
releasePooledConnection(true);
|
||||
return;
|
||||
}
|
||||
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements()) {
|
||||
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
|
||||
return;
|
||||
}
|
||||
releasePooledConnection(!preparePooledConnectionForReturn());
|
||||
@@ -438,7 +438,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
if (sessionAffinity) {
|
||||
return;
|
||||
}
|
||||
if (executor.hasOpenSessions() || executor.hasActiveStatements()) {
|
||||
if (executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
|
||||
return;
|
||||
}
|
||||
releasePooledConnection(!preparePooledConnectionForReturn());
|
||||
@@ -713,7 +713,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
);
|
||||
}
|
||||
|
||||
protected boolean hasRetainedResultResources() { return false; }
|
||||
|
||||
protected void releaseRetainedResultResources() { }
|
||||
|
||||
private void closeCurrentConnection() throws Exception {
|
||||
releaseRetainedResultResources();
|
||||
if (pooledLease != null) {
|
||||
boolean evict = pooledConnectionPoisoned || sessionAffinity || !preparePooledConnectionForReturn();
|
||||
releasePooledConnection(evict);
|
||||
@@ -732,7 +737,17 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
return JdbcSchemaSwitcher.resetBeforeReturn(connection);
|
||||
}
|
||||
|
||||
protected final synchronized void discardConnection(Connection failedConnection) throws java.sql.SQLException {
|
||||
if (pooledLease != null && connection == failedConnection) {
|
||||
// Evict the checked-out lease before closing its proxy; close alone returns it to the pool.
|
||||
releasePooledConnection(true);
|
||||
} else {
|
||||
failedConnection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void releasePooledConnection(boolean evict) {
|
||||
releaseRetainedResultResources();
|
||||
JdbcConnectionPoolRegistry.Lease lease = pooledLease;
|
||||
pooledLease = null;
|
||||
connection = null;
|
||||
|
||||
@@ -38,6 +38,8 @@ public final class AgentProtocol {
|
||||
public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page";
|
||||
public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page";
|
||||
public static final String METHOD_CLOSE_QUERY_SESSION = "close_query_session";
|
||||
public static final String METHOD_READ_LARGE_VALUE_CHUNK = "read_large_value_chunk";
|
||||
public static final String METHOD_RELEASE_LARGE_VALUE = "release_large_value";
|
||||
public static final String METHOD_START_TABLE_READ = "start_table_read";
|
||||
public static final String METHOD_FETCH_TABLE_READ_PAGE = "fetch_table_read_page";
|
||||
public static final String METHOD_CLOSE_TABLE_READ_SESSION = "close_table_read_session";
|
||||
@@ -141,6 +143,7 @@ public final class AgentProtocol {
|
||||
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";
|
||||
public static final String CAPABILITY_BLOB_BIND_STATEMENTS_V1 = "blob_bind_statements_v1";
|
||||
|
||||
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
|
||||
CAPABILITY_CONNECT,
|
||||
@@ -215,6 +218,8 @@ public final class AgentProtocol {
|
||||
METHOD_EXECUTE_QUERY_PAGE,
|
||||
METHOD_FETCH_QUERY_PAGE,
|
||||
METHOD_CLOSE_QUERY_SESSION,
|
||||
METHOD_READ_LARGE_VALUE_CHUNK,
|
||||
METHOD_RELEASE_LARGE_VALUE,
|
||||
METHOD_START_TABLE_READ,
|
||||
METHOD_FETCH_TABLE_READ_PAGE,
|
||||
METHOD_CLOSE_TABLE_READ_SESSION,
|
||||
@@ -267,6 +272,7 @@ public final class AgentProtocol {
|
||||
|
||||
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
|
||||
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
|
||||
jdbcAllCapabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
|
||||
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
|
||||
|
||||
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
|
||||
@@ -383,6 +389,13 @@ public final class AgentProtocol {
|
||||
);
|
||||
}
|
||||
|
||||
public static HandshakeResult multiSessionJdbcHandshakeResult(boolean blobBindStatements) {
|
||||
if (!blobBindStatements) return multiSessionJdbcHandshakeResult();
|
||||
java.util.ArrayList<String> capabilities = new java.util.ArrayList<>(MULTI_SESSION_JDBC_CAPABILITIES);
|
||||
capabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
|
||||
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
|
||||
}
|
||||
|
||||
public static final class HandshakeResult {
|
||||
private final int protocolVersion;
|
||||
private final int agentProtocolVersion;
|
||||
|
||||
@@ -57,6 +57,11 @@ final class AgentRpcError extends RuntimeException {
|
||||
);
|
||||
}
|
||||
|
||||
static AgentRpcError rollbackConfirmedSql(SQLException error) {
|
||||
return new AgentRpcError(message(error), "sql", false, "keep", "execute", "unknown",
|
||||
safeSqlState(error.getSQLState()), error.getErrorCode(), safeClassName(error), error);
|
||||
}
|
||||
|
||||
static AgentRpcError backpressure(String stage, Throwable cause) {
|
||||
return new AgentRpcError(
|
||||
"Agent request capacity is temporarily exhausted",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Savepoint;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class BlobBoundExecutor {
|
||||
private BlobBoundExecutor() { }
|
||||
|
||||
@FunctionalInterface
|
||||
public interface PreparedStatementConfigurer {
|
||||
void configure(PreparedStatement statement) throws SQLException;
|
||||
default void discardConnection(Connection connection) throws SQLException { connection.close(); }
|
||||
default boolean isRollbackConfirmedBusinessError(SQLException error) { return false; }
|
||||
default boolean isUnsupportedSavepointRelease(SQLException error) { return false; }
|
||||
}
|
||||
|
||||
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
|
||||
int timeoutSecs, boolean transaction) {
|
||||
return execute(conn, previews, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs,
|
||||
transaction, statement -> { });
|
||||
}
|
||||
|
||||
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
|
||||
int timeoutSecs, boolean transaction, PreparedStatementConfigurer configurer) {
|
||||
BlobBoundStatement.validate(previews, statements);
|
||||
if (timeoutSecs < 0) throw new IllegalArgumentException("BLOB binding timeout must be non-negative");
|
||||
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
|
||||
operation.checkCancelled();
|
||||
boolean autoCommit = conn.getAutoCommit();
|
||||
if (transaction && !autoCommit) {
|
||||
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
|
||||
}
|
||||
// Typed grid saves are atomic even when execute_batch omits useTransaction.
|
||||
// Existing manual transactions keep their batch-only savepoint boundary.
|
||||
boolean oneShot = transaction || autoCommit;
|
||||
if (oneShot && !conn.getMetaData().supportsTransactions()) {
|
||||
throw new UnsupportedOperationException("Transactions are not supported by this JDBC driver");
|
||||
}
|
||||
if (oneShot) conn.setAutoCommit(false);
|
||||
Savepoint savepoint = !oneShot ? conn.setSavepoint() : null;
|
||||
boolean finished = !oneShot;
|
||||
boolean discarded = false;
|
||||
boolean commitAttempted = false;
|
||||
try {
|
||||
long start = System.currentTimeMillis();
|
||||
long affected = executeAll(conn, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs, operation, configurer);
|
||||
operation.checkCancelled();
|
||||
if (oneShot) { commitAttempted = true; conn.commit(); }
|
||||
if (savepoint != null) releaseSavepoint(conn, savepoint, configurer);
|
||||
finished = true;
|
||||
return new QueryResult(Collections.emptyList(), Collections.emptyList(), affected,
|
||||
System.currentTimeMillis() - start, false);
|
||||
} catch (Exception failure) {
|
||||
if (oneShot) {
|
||||
try { conn.rollback(); finished = true; }
|
||||
catch (Exception rollbackFailure) {
|
||||
discarded = true;
|
||||
throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer);
|
||||
}
|
||||
if (!commitAttempted && failure instanceof SQLException sqlFailure
|
||||
&& configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
|
||||
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
|
||||
}
|
||||
}
|
||||
if (savepoint != null) {
|
||||
try { conn.rollback(savepoint); }
|
||||
catch (Exception rollbackFailure) { throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer); }
|
||||
try { releaseSavepoint(conn, savepoint, configurer); }
|
||||
catch (Exception releaseFailure) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB savepoint cleanup failed after batch rollback; discard this connection and treat transaction state as unknown", "08007", releaseFailure);
|
||||
unsafe.addSuppressed(failure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
throw unsafe;
|
||||
}
|
||||
if (failure instanceof SQLException sqlFailure && configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
|
||||
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
} finally {
|
||||
if (oneShot) {
|
||||
if (finished) {
|
||||
try { conn.setAutoCommit(autoCommit); }
|
||||
catch (Exception resetFailure) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB transaction connection reset failed; committed writes may exist and operation outcome is unknown", "08007", resetFailure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
throw unsafe;
|
||||
}
|
||||
} else if (!discarded) configurer.discardConnection(conn); // Never enable auto-commit after an unconfirmed rollback.
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new RuntimeException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static SQLTransientConnectionException rollbackUnconfirmed(Connection conn, Exception failure,
|
||||
Exception rollbackFailure, PreparedStatementConfigurer configurer) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB binding rollback was not confirmed; discard this connection and treat the operation outcome as unknown", "08007", failure);
|
||||
unsafe.addSuppressed(rollbackFailure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
return unsafe;
|
||||
}
|
||||
|
||||
private static void releaseSavepoint(Connection conn, Savepoint savepoint,
|
||||
PreparedStatementConfigurer configurer) throws SQLException {
|
||||
try { conn.releaseSavepoint(savepoint); }
|
||||
catch (SQLException error) {
|
||||
if (!configurer.isUnsupportedSavepointRelease(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static long executeAll(Connection conn, List<BlobBoundStatement> statements, String schema,
|
||||
Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql, int timeoutSecs,
|
||||
JdbcExecutor.NativeOperation operation, PreparedStatementConfigurer configurer) throws Exception {
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
try (BoundResources resources = new BoundResources()) {
|
||||
operation.checkCancelled();
|
||||
JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
for (BlobBoundStatement bound : statements) {
|
||||
operation.checkCancelled();
|
||||
String sql = JdbcExecutor.trimSql(bound.sql());
|
||||
Statement statement = bound.blobParameters().isEmpty() ? conn.createStatement()
|
||||
: isAnonymousBlock(sql) ? conn.prepareCall(sql) : conn.prepareStatement(sql);
|
||||
resources.statements.add(executor.trackStatement(statement));
|
||||
statement.setQueryTimeout(timeoutSecs);
|
||||
if (statement instanceof PreparedStatement prepared) {
|
||||
configurer.configure(prepared);
|
||||
int index = 1;
|
||||
for (String hex : bound.blobParameters()) {
|
||||
InputStream stream = new HexStream(hex);
|
||||
resources.streams.add(stream);
|
||||
prepared.setBlob(index++, stream, (long) hex.length() / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prepare and bind the complete batch before the first write.
|
||||
long affected = 0;
|
||||
for (int i = 0; i < statements.size(); i++) {
|
||||
operation.checkCancelled();
|
||||
Statement statement = resources.statements.get(i).statement();
|
||||
boolean result = statement instanceof PreparedStatement prepared ? prepared.execute()
|
||||
: statement.execute(JdbcExecutor.trimSql(statements.get(i).sql()));
|
||||
if (!result) affected += Math.max(0, statement.getUpdateCount());
|
||||
operation.checkCancelled();
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BoundResources implements AutoCloseable {
|
||||
final List<JdbcExecutor.TrackedStatement<Statement>> statements = new ArrayList<>();
|
||||
final List<InputStream> streams = new ArrayList<>();
|
||||
@Override public void close() throws Exception {
|
||||
Exception failure = null;
|
||||
List<AutoCloseable> resources = new ArrayList<>(statements);
|
||||
resources.addAll(streams);
|
||||
for (AutoCloseable resource : resources) {
|
||||
try { resource.close(); }
|
||||
catch (Exception error) {
|
||||
if (failure == null) failure = error;
|
||||
else failure.addSuppressed(error);
|
||||
}
|
||||
}
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAnonymousBlock(String sql) {
|
||||
String normalized = sql.toUpperCase(Locale.ROOT);
|
||||
return normalized.startsWith("DECLARE") || normalized.startsWith("BEGIN");
|
||||
}
|
||||
|
||||
/** Decode already-validated hex without retaining a second full byte array. */
|
||||
private static final class HexStream extends InputStream {
|
||||
private final String hex;
|
||||
private int offset;
|
||||
private boolean closed;
|
||||
HexStream(String hex) { this.hex = hex; }
|
||||
@Override public int available() { return closed ? 0 : (hex.length() - offset) / 2; }
|
||||
@Override public int read() throws java.io.IOException {
|
||||
if (closed) throw new java.io.IOException("BLOB binding stream is closed");
|
||||
if (offset == hex.length()) return -1;
|
||||
int value = Character.digit(hex.charAt(offset), 16) * 16 + Character.digit(hex.charAt(offset + 1), 16);
|
||||
offset += 2;
|
||||
return value;
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Optional BLOB-only binding payload; preview text stays authoritative. */
|
||||
public final class BlobBoundStatement {
|
||||
private String previewSql;
|
||||
private String sql;
|
||||
private List<String> blobParameters;
|
||||
|
||||
public BlobBoundStatement(String previewSql, String sql, List<String> blobParameters) {
|
||||
this.previewSql = previewSql;
|
||||
this.sql = sql;
|
||||
this.blobParameters = blobParameters;
|
||||
}
|
||||
|
||||
public String sql() { return sql; }
|
||||
public List<String> blobParameters() { return blobParameters; }
|
||||
|
||||
public static void validate(List<String> statements, List<BlobBoundStatement> boundStatements) {
|
||||
if (statements == null || boundStatements == null || statements.size() != boundStatements.size()) {
|
||||
throw new IllegalArgumentException("BLOB bound statements must match the complete statement batch");
|
||||
}
|
||||
for (int i = 0; i < statements.size(); i++) {
|
||||
BlobBoundStatement bound = boundStatements.get(i);
|
||||
if (bound == null || statements.get(i) == null || !statements.get(i).equals(bound.previewSql)
|
||||
|| bound.sql == null || bound.sql.trim().isEmpty() || bound.blobParameters == null) {
|
||||
throw new IllegalArgumentException("BLOB binding preview mismatch at statement " + (i + 1));
|
||||
}
|
||||
if (bound.blobParameters.isEmpty() && !bound.sql.equals(bound.previewSql)) {
|
||||
throw new IllegalArgumentException("Unbound statement SQL must match its preview");
|
||||
}
|
||||
SqlScan scan = scanSql(bound.sql);
|
||||
String visible = scan.visible().stripLeading().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!visible.matches("(?s)^(?:UPDATE|INSERT|DELETE)\\b.*")
|
||||
&& !visible.matches("(?s)^(?:DECLARE\\b.*?)?BEGIN\\s+(?:UPDATE|INSERT|DELETE)\\b.*")
|
||||
|| java.util.regex.Pattern.compile("\\b(?:CREATE|ALTER|DROP|TRUNCATE|COMMIT|ROLLBACK|SAVEPOINT|GRANT|REVOKE|EXECUTE)\\b").matcher(visible).find()) {
|
||||
throw new IllegalArgumentException("BLOB binding only supports data-grid DML, without DDL or transaction control");
|
||||
}
|
||||
if (!bound.blobParameters.isEmpty() && scan.parameters() != bound.blobParameters.size()) {
|
||||
throw new IllegalArgumentException("BLOB parameter count does not match statement " + (i + 1));
|
||||
}
|
||||
for (String hex : bound.blobParameters) {
|
||||
if (hex == null || (hex.length() & 1) != 0) {
|
||||
throw new IllegalArgumentException("BLOB parameters must contain even-length hexadecimal bytes");
|
||||
}
|
||||
for (int j = 0; j < hex.length(); j++) {
|
||||
if (Character.digit(hex.charAt(j), 16) < 0 || hex.charAt(j) > 127) {
|
||||
throw new IllegalArgumentException("BLOB parameters must contain hexadecimal bytes only");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record SqlScan(int parameters, String visible) { }
|
||||
|
||||
private static SqlScan scanSql(String sql) {
|
||||
int count = 0;
|
||||
StringBuilder visible = new StringBuilder();
|
||||
for (int i = 0; i < sql.length(); i++) {
|
||||
char ch = sql.charAt(i);
|
||||
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
|
||||
if (ch == '-' && next == '-') {
|
||||
visible.append(' ');
|
||||
while (i + 1 < sql.length() && sql.charAt(i + 1) != '\n') i++;
|
||||
} else if (ch == '/' && next == '*') {
|
||||
visible.append(' ');
|
||||
int end = sql.indexOf("*/", i + 2);
|
||||
i = end < 0 ? sql.length() : end + 1;
|
||||
} else if ((ch == 'q' || ch == 'Q') && next == '\'' && i + 2 < sql.length()) {
|
||||
visible.append(' ');
|
||||
char open = sql.charAt(i + 2);
|
||||
char close = open == '[' ? ']' : open == '(' ? ')' : open == '{' ? '}' : open == '<' ? '>' : open;
|
||||
int end = sql.indexOf("" + close + '\'', i + 3);
|
||||
i = end < 0 ? sql.length() : end + 1;
|
||||
} else if (ch == '\'' || ch == '"') {
|
||||
visible.append(' ');
|
||||
char quote = ch;
|
||||
while (++i < sql.length()) {
|
||||
if (sql.charAt(i) == quote) {
|
||||
if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) i++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visible.append(ch);
|
||||
if (ch == '?') count++;
|
||||
}
|
||||
}
|
||||
return new SqlScan(count, visible.toString());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public final class ColumnInfo {
|
||||
private Integer character_maximum_length;
|
||||
private String character_set;
|
||||
private String collation;
|
||||
private String resolved_schema;
|
||||
private String resolved_table;
|
||||
private String resolved_object_type;
|
||||
|
||||
public ColumnInfo() {
|
||||
this("", "", true, null, false);
|
||||
@@ -128,6 +131,18 @@ public final class ColumnInfo {
|
||||
return collation;
|
||||
}
|
||||
|
||||
public String getResolved_schema() {
|
||||
return resolved_schema;
|
||||
}
|
||||
|
||||
public String getResolved_table() {
|
||||
return resolved_table;
|
||||
}
|
||||
|
||||
public String getResolved_object_type() {
|
||||
return resolved_object_type;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -176,6 +191,18 @@ public final class ColumnInfo {
|
||||
this.collation = collation;
|
||||
}
|
||||
|
||||
public void setResolved_schema(String resolved_schema) {
|
||||
this.resolved_schema = resolved_schema;
|
||||
}
|
||||
|
||||
public void setResolved_table(String resolved_table) {
|
||||
this.resolved_table = resolved_table;
|
||||
}
|
||||
|
||||
public void setResolved_object_type(String resolved_object_type) {
|
||||
this.resolved_object_type = resolved_object_type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -192,7 +219,10 @@ public final class ColumnInfo {
|
||||
&& Objects.equals(numeric_scale, that.numeric_scale)
|
||||
&& Objects.equals(character_maximum_length, that.character_maximum_length)
|
||||
&& Objects.equals(character_set, that.character_set)
|
||||
&& Objects.equals(collation, that.collation);
|
||||
&& Objects.equals(collation, that.collation)
|
||||
&& Objects.equals(resolved_schema, that.resolved_schema)
|
||||
&& Objects.equals(resolved_table, that.resolved_table)
|
||||
&& Objects.equals(resolved_object_type, that.resolved_object_type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +239,10 @@ public final class ColumnInfo {
|
||||
numeric_scale,
|
||||
character_maximum_length,
|
||||
character_set,
|
||||
collation
|
||||
collation,
|
||||
resolved_schema,
|
||||
resolved_table,
|
||||
resolved_object_type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +260,9 @@ public final class ColumnInfo {
|
||||
+ ", character_maximum_length=" + character_maximum_length
|
||||
+ ", character_set=" + character_set
|
||||
+ ", collation=" + collation
|
||||
+ ", resolved_schema=" + resolved_schema
|
||||
+ ", resolved_table=" + resolved_table
|
||||
+ ", resolved_object_type=" + resolved_object_type
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public final class CompletionAssistantCandidate {
|
||||
private final String parent_name;
|
||||
private final String comment;
|
||||
private final String data_type;
|
||||
private final String signature;
|
||||
private final String routine_id;
|
||||
|
||||
public CompletionAssistantCandidate(
|
||||
String name,
|
||||
@@ -19,6 +21,21 @@ public final class CompletionAssistantCandidate {
|
||||
String parentName,
|
||||
String comment,
|
||||
String dataType
|
||||
) {
|
||||
this(name, kind, database, schema, parentSchema, parentName, comment, dataType, null, null);
|
||||
}
|
||||
|
||||
public CompletionAssistantCandidate(
|
||||
String name,
|
||||
CompletionAssistantCandidateKind kind,
|
||||
String database,
|
||||
String schema,
|
||||
String parentSchema,
|
||||
String parentName,
|
||||
String comment,
|
||||
String dataType,
|
||||
String signature,
|
||||
String routineId
|
||||
) {
|
||||
this.name = name;
|
||||
this.kind = kind;
|
||||
@@ -28,6 +45,8 @@ public final class CompletionAssistantCandidate {
|
||||
this.parent_name = parentName;
|
||||
this.comment = comment;
|
||||
this.data_type = dataType;
|
||||
this.signature = signature;
|
||||
this.routine_id = routineId;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
@@ -38,4 +57,6 @@ public final class CompletionAssistantCandidate {
|
||||
public String getParent_name() { return parent_name; }
|
||||
public String getComment() { return comment; }
|
||||
public String getData_type() { return data_type; }
|
||||
public String getSignature() { return signature; }
|
||||
public String getRoutine_id() { return routine_id; }
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
|
||||
private final List<CompletionAssistantCandidate> candidates;
|
||||
private final boolean incomplete;
|
||||
private final boolean fallback_used;
|
||||
private final Boolean routine_search_supported;
|
||||
|
||||
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
|
||||
this(candidates, incomplete, fallbackUsed, null);
|
||||
}
|
||||
|
||||
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
|
||||
this.candidates = candidates;
|
||||
this.incomplete = incomplete;
|
||||
this.fallback_used = fallbackUsed;
|
||||
this.routine_search_supported = routineSearchSupported;
|
||||
}
|
||||
|
||||
public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Shared wire shape; null state properties mean the database did not report them. */
|
||||
public record ConstraintInfo(
|
||||
String name, String constraint_type, String definition, List<String> columns,
|
||||
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid
|
||||
) {
|
||||
}
|
||||
@@ -14,6 +14,21 @@ public interface DatabaseAgent {
|
||||
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
|
||||
default boolean supportsQueryTiming() { return false; }
|
||||
|
||||
default boolean supportsBlobBindStatements() { return false; }
|
||||
|
||||
default QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, int timeoutSecs, boolean transaction) {
|
||||
throw new UnsupportedOperationException("BLOB statement binding is not supported by this agent");
|
||||
}
|
||||
|
||||
/** A dedicated manual transaction must never migrate to a replacement connection. */
|
||||
default boolean permitsAutomaticReconnect() { return true; }
|
||||
|
||||
default List<QueryResult> executeQueryResults(String sql, String schema, ExecuteQueryOptions options) {
|
||||
throw new UnsupportedOperationException("Complete manual transaction batches are not supported by this driver");
|
||||
}
|
||||
|
||||
|
||||
void connect(ConnectParams params);
|
||||
|
||||
boolean testConnection(ConnectParams params);
|
||||
@@ -78,6 +93,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ColumnInfo> getColumns(String schema, String table);
|
||||
|
||||
default List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
|
||||
return getColumns(schema, table);
|
||||
}
|
||||
|
||||
default ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
throw new UnsupportedOperationException("Object source is not supported");
|
||||
}
|
||||
@@ -148,6 +167,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ForeignKeyInfo> listForeignKeys(String schema, String table);
|
||||
|
||||
default List<ConstraintInfo> listConstraints(String schema, String table) {
|
||||
throw new UnsupportedOperationException("Constraint metadata is not supported by this driver");
|
||||
}
|
||||
|
||||
List<TriggerInfo> listTriggers(String schema, String table);
|
||||
|
||||
default List<PartitionInfo> listPartitions(String schema, String table) {
|
||||
@@ -192,6 +215,14 @@ public interface DatabaseAgent {
|
||||
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
|
||||
}
|
||||
|
||||
default Object readLargeValueChunk(String valueRef, long offset, int limit) {
|
||||
throw new UnsupportedOperationException("LOB snapshot chunks are not supported by this agent");
|
||||
}
|
||||
|
||||
default boolean releaseLargeValue(String valueRef) { return false; }
|
||||
|
||||
default void invalidateLargeValues() { }
|
||||
|
||||
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) {
|
||||
|
||||
@@ -6,19 +6,25 @@ public final class ExecuteQueryOptions {
|
||||
private int maxRows;
|
||||
private Integer fetchSize;
|
||||
private int timeoutSecs;
|
||||
private boolean deferLobs;
|
||||
|
||||
public ExecuteQueryOptions() {
|
||||
this(JdbcExecutor.DEFAULT_MAX_ROWS, null, 0);
|
||||
this(JdbcExecutor.DEFAULT_MAX_ROWS, null, 0, false);
|
||||
}
|
||||
|
||||
public ExecuteQueryOptions(int maxRows, Integer fetchSize) {
|
||||
this(maxRows, fetchSize, 0);
|
||||
this(maxRows, fetchSize, 0, false);
|
||||
}
|
||||
|
||||
public ExecuteQueryOptions(int maxRows, Integer fetchSize, int timeoutSecs) {
|
||||
this(maxRows, fetchSize, timeoutSecs, false);
|
||||
}
|
||||
|
||||
public ExecuteQueryOptions(int maxRows, Integer fetchSize, int timeoutSecs, boolean deferLobs) {
|
||||
this.maxRows = maxRows;
|
||||
this.fetchSize = fetchSize;
|
||||
this.timeoutSecs = timeoutSecs;
|
||||
this.deferLobs = deferLobs;
|
||||
}
|
||||
|
||||
public int getMaxRows() {
|
||||
@@ -33,6 +39,10 @@ public final class ExecuteQueryOptions {
|
||||
return timeoutSecs;
|
||||
}
|
||||
|
||||
public boolean getDeferLobs() {
|
||||
return deferLobs;
|
||||
}
|
||||
|
||||
public void setMaxRows(int maxRows) {
|
||||
this.maxRows = maxRows;
|
||||
}
|
||||
@@ -45,6 +55,10 @@ public final class ExecuteQueryOptions {
|
||||
this.timeoutSecs = timeoutSecs;
|
||||
}
|
||||
|
||||
public void setDeferLobs(boolean deferLobs) {
|
||||
this.deferLobs = deferLobs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -52,16 +66,17 @@ public final class ExecuteQueryOptions {
|
||||
ExecuteQueryOptions that = (ExecuteQueryOptions) other;
|
||||
return maxRows == that.maxRows
|
||||
&& timeoutSecs == that.timeoutSecs
|
||||
&& deferLobs == that.deferLobs
|
||||
&& Objects.equals(fetchSize, that.fetchSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(maxRows, fetchSize, timeoutSecs);
|
||||
return Objects.hash(maxRows, fetchSize, timeoutSecs, deferLobs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ExecuteQueryOptions(maxRows=" + maxRows + ", fetchSize=" + fetchSize + ", timeoutSecs=" + timeoutSecs + ")";
|
||||
return "ExecuteQueryOptions(maxRows=" + maxRows + ", fetchSize=" + fetchSize + ", timeoutSecs=" + timeoutSecs + ", deferLobs=" + deferLobs + ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class JdbcExecutor {
|
||||
private final ConcurrentHashMap<String, QuerySession> sessions = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, QuerySession> tableReadSessions = new ConcurrentHashMap<>();
|
||||
private final java.util.Set<Statement> activeStatements = ConcurrentHashMap.newKeySet();
|
||||
private final java.util.Set<NativeOperation> nativeOperations = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public JdbcExecutor() {
|
||||
}
|
||||
@@ -37,11 +38,121 @@ public final class JdbcExecutor {
|
||||
return AgentExecutionContext.jdbcExecutor();
|
||||
}
|
||||
|
||||
/** Latch cancellation across the sequential statements of one native driver operation. */
|
||||
public NativeOperation beginNativeOperation() {
|
||||
NativeOperation operation = new NativeOperation();
|
||||
nativeOperations.add(operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
public final class NativeOperation implements AutoCloseable {
|
||||
private final java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
private NativeOperation() {
|
||||
}
|
||||
|
||||
public void checkCancelled() {
|
||||
if (cancelled.get()) {
|
||||
throw new java.util.concurrent.CancellationException("Native JDBC operation cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
nativeOperations.remove(this);
|
||||
}
|
||||
}
|
||||
/** Register native-driver statements with this session's cancellation boundary. */
|
||||
public <S extends Statement> TrackedStatement<S> trackStatement(S statement) {
|
||||
java.util.Objects.requireNonNull(statement, "statement");
|
||||
activeStatements.add(statement);
|
||||
return new TrackedStatement<>(statement);
|
||||
}
|
||||
|
||||
/** Owns the original JDBC Statement without changing its identity or unwrap behavior. */
|
||||
public final class TrackedStatement<S extends Statement> implements AutoCloseable {
|
||||
private final S statement;
|
||||
private boolean closed;
|
||||
|
||||
private TrackedStatement(S statement) {
|
||||
this.statement = statement;
|
||||
}
|
||||
|
||||
public S statement() {
|
||||
return statement;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws SQLException {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
statement.close();
|
||||
} finally {
|
||||
activeStatements.remove(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static int statementMaxRows(int maxRows) {
|
||||
int effectiveMaxRows = Math.max(maxRows, 1);
|
||||
return effectiveMaxRows == Integer.MAX_VALUE ? Integer.MAX_VALUE : effectiveMaxRows + 1;
|
||||
}
|
||||
|
||||
/** Execute one batch once, consuming every row and response even when retention is capped. */
|
||||
public List<QueryResult> executeAll(Connection conn, String sql, int maxRows, Integer fetchSize,
|
||||
int timeoutSecs, ResultValueReader valueReader) {
|
||||
return unchecked(() -> {
|
||||
long start = System.currentTimeMillis();
|
||||
int retainedRows = Math.max(maxRows, 1);
|
||||
List<QueryResult> results = new ArrayList<>();
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
activeStatements.add(stmt);
|
||||
try {
|
||||
// setMaxRows can truncate the server response; cap retained rows instead.
|
||||
applyQueryTimeout(stmt, timeoutSecs);
|
||||
if (fetchSize != null && fetchSize > 0) stmt.setFetchSize(fetchSize);
|
||||
boolean hasResult = stmt.execute(sql);
|
||||
while (true) {
|
||||
if (hasResult) {
|
||||
try (ResultSet rs = stmt.getResultSet()) {
|
||||
QueryResult result = readResultSet(rs, System.currentTimeMillis() - start, retainedRows, valueReader);
|
||||
while (rs.next()) { /* fully consume rows beyond the UI limit */ }
|
||||
results.add(result);
|
||||
}
|
||||
} else {
|
||||
int count = stmt.getUpdateCount();
|
||||
if (count < 0) break;
|
||||
results.add(new QueryResult(Collections.emptyList(), Collections.emptyList(), count,
|
||||
System.currentTimeMillis() - start, false));
|
||||
}
|
||||
appendStatementWarnings(results.get(results.size() - 1), stmt);
|
||||
hasResult = stmt.getMoreResults();
|
||||
}
|
||||
if (results.isEmpty()) results.add(new QueryResult(Collections.emptyList(), Collections.emptyList(),
|
||||
0, System.currentTimeMillis() - start, false));
|
||||
appendStatementWarnings(results.get(results.size() - 1), stmt);
|
||||
return results;
|
||||
} finally {
|
||||
activeStatements.remove(stmt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void appendStatementWarnings(QueryResult result, Statement stmt) {
|
||||
try {
|
||||
Set<SQLWarning> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (SQLWarning warning = stmt.getWarnings(); warning != null && seen.add(warning); warning = warning.getNextWarning()) {
|
||||
String message = warning.getMessage();
|
||||
if (message != null && !message.isBlank()) result.addInformationalMessage(message,
|
||||
warning.getErrorCode() == 0 ? null : Integer.toString(warning.getErrorCode()));
|
||||
}
|
||||
stmt.clearWarnings();
|
||||
} catch (SQLException ignored) {
|
||||
// Advisory warnings cannot change an already acknowledged execution.
|
||||
}
|
||||
}
|
||||
|
||||
public QueryResult execute(Connection conn, String sql, String schema, Function<String, String> setSchemaSql) {
|
||||
return execute(conn, sql, schema, setSchemaSql, DEFAULT_MAX_ROWS, null, this::defaultResultValue);
|
||||
}
|
||||
@@ -339,6 +450,19 @@ public final class JdbcExecutor {
|
||||
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, StatementMessageReader.NONE, tableReadSessions);
|
||||
}
|
||||
|
||||
public QueryPageResult executeBoundedPage(
|
||||
Connection conn,
|
||||
String sql,
|
||||
String schema,
|
||||
Function<String, String> setSchemaSql,
|
||||
Supplier<String> resetSchemaSql,
|
||||
QueryPageOptions options,
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader,
|
||||
StatementMessageReader.NONE, sessions, false, true);
|
||||
}
|
||||
|
||||
public QueryPageResult startTableRead(
|
||||
Connection conn,
|
||||
String sql,
|
||||
@@ -387,6 +511,23 @@ public final class JdbcExecutor {
|
||||
StatementMessageReader statementMessageReader,
|
||||
ConcurrentHashMap<String, QuerySession> targetSessions,
|
||||
boolean advancePastUpdateCounts
|
||||
) {
|
||||
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader,
|
||||
statementMessageReader, targetSessions, advancePastUpdateCounts, false);
|
||||
}
|
||||
|
||||
private QueryPageResult executePage(
|
||||
Connection conn,
|
||||
String sql,
|
||||
String schema,
|
||||
Function<String, String> setSchemaSql,
|
||||
Supplier<String> resetSchemaSql,
|
||||
QueryPageOptions options,
|
||||
ResultValueReader valueReader,
|
||||
StatementMessageReader statementMessageReader,
|
||||
ConcurrentHashMap<String, QuerySession> targetSessions,
|
||||
boolean advancePastUpdateCounts,
|
||||
boolean boundStatementRows
|
||||
) {
|
||||
return unchecked(() -> {
|
||||
expireIdleSessions(targetSessions, System.currentTimeMillis(), QUERY_SESSION_IDLE_TIMEOUT_MILLIS);
|
||||
@@ -402,9 +543,14 @@ public final class JdbcExecutor {
|
||||
QuerySession createdSession = null;
|
||||
activeStatements.add(stmt);
|
||||
try {
|
||||
if (boundStatementRows) {
|
||||
stmt.setMaxRows(statementMaxRows(options.getMaxRows()));
|
||||
}
|
||||
applyQueryTimeout(stmt, options.getTimeoutSecs());
|
||||
if (options.getFetchSize() != null && options.getFetchSize() > 0) {
|
||||
stmt.setFetchSize(options.getFetchSize());
|
||||
} else if (boundStatementRows) {
|
||||
stmt.setFetchSize(Math.max(1, Math.min(options.getPageSize(), Math.max(options.getMaxRows(), 1))));
|
||||
}
|
||||
// Keep script transaction-control statements in the SQL stream.
|
||||
// JDBC transaction APIs are reserved for executeTransaction.
|
||||
@@ -443,10 +589,12 @@ public final class JdbcExecutor {
|
||||
Math.max(options.getMaxRows(), 1),
|
||||
statementMessageReader
|
||||
);
|
||||
} else {
|
||||
appendStatementWarnings(result, stmt);
|
||||
}
|
||||
activeStatements.remove(stmt);
|
||||
stmt.close();
|
||||
return new QueryPageResult(
|
||||
QueryPageResult page = new QueryPageResult(
|
||||
result.getColumns(),
|
||||
result.getColumn_types(),
|
||||
result.getRows(),
|
||||
@@ -456,6 +604,8 @@ public final class JdbcExecutor {
|
||||
null,
|
||||
false
|
||||
);
|
||||
page.setMessages(result.getMessages());
|
||||
return page;
|
||||
}
|
||||
|
||||
phaseStarted = System.nanoTime();
|
||||
@@ -537,6 +687,9 @@ public final class JdbcExecutor {
|
||||
}
|
||||
|
||||
public void cancelActiveStatements() {
|
||||
for (NativeOperation operation : nativeOperations) {
|
||||
operation.cancelled.set(true);
|
||||
}
|
||||
for (Statement statement : activeStatements) {
|
||||
try {
|
||||
statement.cancel();
|
||||
@@ -777,8 +930,9 @@ public final class JdbcExecutor {
|
||||
|
||||
while (rows.size() < effectivePageSize && session.rowsRead < session.maxRows) {
|
||||
if (!session.resultSet.next()) {
|
||||
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
closeSession(targetSessions, session.id);
|
||||
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
return page;
|
||||
}
|
||||
rows.add(rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex));
|
||||
session.rowsRead += 1;
|
||||
@@ -786,14 +940,16 @@ public final class JdbcExecutor {
|
||||
|
||||
if (session.rowsRead >= session.maxRows) {
|
||||
boolean truncated = session.resultSet.next();
|
||||
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
|
||||
closeSession(targetSessions, session.id);
|
||||
return sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
|
||||
return page;
|
||||
}
|
||||
|
||||
boolean hasMore = session.resultSet.next();
|
||||
if (!hasMore) {
|
||||
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
closeSession(targetSessions, session.id);
|
||||
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
|
||||
return page;
|
||||
}
|
||||
|
||||
session.pendingRow = rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex);
|
||||
@@ -811,6 +967,9 @@ public final class JdbcExecutor {
|
||||
boolean hasMore
|
||||
) {
|
||||
QueryPageResult result = new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, truncated, sessionId, hasMore);
|
||||
QueryResult messages = new QueryResult();
|
||||
appendStatementWarnings(messages, session.statement);
|
||||
result.setMessages(messages.getMessages());
|
||||
result.setCursor_rows_read(session.rowsRead);
|
||||
return result;
|
||||
}
|
||||
@@ -917,6 +1076,7 @@ public final class JdbcExecutor {
|
||||
StatementMessageReader statementMessageReader
|
||||
) {
|
||||
if (!result.getColumns().isEmpty() || !result.getRows().isEmpty()) {
|
||||
appendStatementWarnings(result, stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -933,6 +1093,7 @@ public final class JdbcExecutor {
|
||||
break;
|
||||
}
|
||||
rows.add(Collections.singletonList(message));
|
||||
result.addInformationalMessage(message, warning.getSQLState());
|
||||
}
|
||||
}
|
||||
stmt.clearWarnings();
|
||||
@@ -953,6 +1114,7 @@ public final class JdbcExecutor {
|
||||
break;
|
||||
}
|
||||
rows.add(Collections.singletonList(message));
|
||||
result.addInformationalMessage(message, null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
@@ -962,7 +1124,7 @@ public final class JdbcExecutor {
|
||||
if (rows.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
return new QueryResult(
|
||||
QueryResult messageResult = new QueryResult(
|
||||
Collections.singletonList("Message"),
|
||||
Collections.singletonList("nvarchar"),
|
||||
rows,
|
||||
@@ -970,6 +1132,8 @@ public final class JdbcExecutor {
|
||||
result.getExecution_time_ms(),
|
||||
truncated
|
||||
);
|
||||
messageResult.setMessages(result.getMessages());
|
||||
return messageResult;
|
||||
}
|
||||
|
||||
private QueryResult emptyQueryResult(long start) {
|
||||
@@ -1053,6 +1217,20 @@ public final class JdbcExecutor {
|
||||
* agents to convert vendor-specific column types (e.g. PostGIS
|
||||
* {@code geometry}) without re-querying the metadata.
|
||||
*/
|
||||
public <T> T withActiveStatement(Statement statement, SqlOperation<T> operation) throws SQLException {
|
||||
activeStatements.add(statement);
|
||||
try {
|
||||
return operation.run();
|
||||
} finally {
|
||||
activeStatements.remove(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface SqlOperation<T> {
|
||||
T run() throws SQLException;
|
||||
}
|
||||
|
||||
public interface ColumnAwareResultValueReader extends ResultValueReader {
|
||||
Object read(ResultSet rs, int index, int sqlType, String columnTypeName) throws SQLException;
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ public final class JsonRpcServer {
|
||||
|
||||
void cancelActiveStatements() {
|
||||
jdbcExecutor.cancelActiveStatements();
|
||||
agent.invalidateLargeValues();
|
||||
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
|
||||
if (jdbcAgent != null) {
|
||||
jdbcAgent.releaseIdlePooledConnection(jdbcExecutor);
|
||||
@@ -265,7 +266,9 @@ public final class JsonRpcServer {
|
||||
}
|
||||
if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
String currentSchema = params.has("current_schema") && !params.get("current_schema").isJsonNull()
|
||||
? params.get("current_schema").getAsString() : null;
|
||||
return agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) {
|
||||
switchCatalog(params);
|
||||
@@ -279,6 +282,10 @@ public final class JsonRpcServer {
|
||||
switchCatalog(params);
|
||||
return agent.listTriggers(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_CONSTRAINTS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.listConstraints(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_LIST_PARTITIONS.equals(method)) {
|
||||
switchCatalog(params);
|
||||
return agent.listPartitions(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
@@ -288,15 +295,14 @@ public final class JsonRpcServer {
|
||||
return agent.listSubpartitions(params.get("schema").getAsString(), params.get("table").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)) {
|
||||
return agent.executeQuery(
|
||||
params.get("sql").getAsString(),
|
||||
stringOrNull(params, "schema"),
|
||||
new ExecuteQueryOptions(
|
||||
intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS),
|
||||
intOrNull(params, "fetchSize"),
|
||||
intOrDefault(params, "timeoutSecs", 0)
|
||||
)
|
||||
);
|
||||
ExecuteQueryOptions options = new ExecuteQueryOptions(
|
||||
intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS),
|
||||
intOrNull(params, "fetchSize"), intOrDefault(params, "timeoutSecs", 0),
|
||||
booleanOrDefault(params, "deferLobs", false));
|
||||
if (params.has("returnAllResults") && params.get("returnAllResults").getAsBoolean()) {
|
||||
return agent.executeQueryResults(params.get("sql").getAsString(), stringOrNull(params, "schema"), options);
|
||||
}
|
||||
return agent.executeQuery(params.get("sql").getAsString(), stringOrNull(params, "schema"), options);
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)) {
|
||||
return agent.executeQueryPage(
|
||||
@@ -306,7 +312,8 @@ public final class JsonRpcServer {
|
||||
intOrDefault(params, "pageSize", 100),
|
||||
intOrNull(params, "fetchSize"),
|
||||
intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS),
|
||||
intOrDefault(params, "timeoutSecs", 0)
|
||||
intOrDefault(params, "timeoutSecs", 0),
|
||||
booleanOrDefault(params, "deferLobs", false)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -319,6 +326,13 @@ public final class JsonRpcServer {
|
||||
if (AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)) {
|
||||
return agent.closeQuerySession(params.get("sessionId").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK.equals(method)) {
|
||||
return agent.readLargeValueChunk(params.get("valueRef").getAsString(),
|
||||
params.get("offset").getAsLong(), intOrDefault(params, "limit", 4096));
|
||||
}
|
||||
if (AgentProtocol.METHOD_RELEASE_LARGE_VALUE.equals(method)) {
|
||||
return agent.releaseLargeValue(params.get("valueRef").getAsString());
|
||||
}
|
||||
if (AgentProtocol.METHOD_START_TABLE_READ.equals(method)) {
|
||||
return agent.startTableRead(
|
||||
params.get("sql").getAsString(),
|
||||
@@ -327,7 +341,8 @@ public final class JsonRpcServer {
|
||||
intOrDefault(params, "pageSize", 100),
|
||||
intOrNull(params, "fetchSize"),
|
||||
intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS),
|
||||
intOrDefault(params, "timeoutSecs", 0)
|
||||
intOrDefault(params, "timeoutSecs", 0),
|
||||
booleanOrDefault(params, "deferLobs", false)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -354,22 +369,33 @@ public final class JsonRpcServer {
|
||||
return result;
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
Type statementsType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
|
||||
if (params.has("boundStatements")) {
|
||||
return executeBlobBoundStatements(params, statements, true);
|
||||
}
|
||||
return agent.executeTransaction(statements, stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
return agent.beginManualTransaction(stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
return agent.commitManualTransaction();
|
||||
}
|
||||
if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
return agent.rollbackManualTransaction();
|
||||
}
|
||||
if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) {
|
||||
agent.invalidateLargeValues();
|
||||
Type statementsType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
|
||||
if (params.has("boundStatements")) {
|
||||
return executeBlobBoundStatements(params, statements, false);
|
||||
}
|
||||
return agent.executeBatch(statements, stringOrNull(params, "schema"));
|
||||
}
|
||||
if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
|
||||
@@ -389,6 +415,38 @@ public final class JsonRpcServer {
|
||||
throw new IllegalArgumentException("Unknown method: " + method);
|
||||
}
|
||||
|
||||
private QueryResult executeBlobBoundStatements(JsonObject params, List<String> previews, boolean transaction) {
|
||||
if (!agent.supportsBlobBindStatements()) {
|
||||
throw new UnsupportedOperationException("Agent does not support blob_bind_statements_v1");
|
||||
}
|
||||
if (!params.get("boundStatements").isJsonArray()) {
|
||||
throw new IllegalArgumentException("BLOB bound statements must be an array");
|
||||
}
|
||||
for (com.google.gson.JsonElement entry : params.getAsJsonArray("boundStatements")) {
|
||||
if (!entry.isJsonObject()) throw new IllegalArgumentException("BLOB bound statement must be an object");
|
||||
JsonObject item = entry.getAsJsonObject();
|
||||
for (String field : java.util.Arrays.asList("previewSql", "sql")) {
|
||||
if (!item.has(field) || !item.get(field).isJsonPrimitive()
|
||||
|| !item.get(field).getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException("BLOB bound statement text fields must be strings");
|
||||
}
|
||||
}
|
||||
if (!item.has("blobParameters") || !item.get("blobParameters").isJsonArray()) {
|
||||
throw new IllegalArgumentException("BLOB parameters must be an array of hexadecimal strings");
|
||||
}
|
||||
for (com.google.gson.JsonElement value : item.getAsJsonArray("blobParameters")) {
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException("BLOB parameter must be a hexadecimal string");
|
||||
}
|
||||
}
|
||||
}
|
||||
Type boundType = new TypeToken<List<BlobBoundStatement>>() {}.getType();
|
||||
List<BlobBoundStatement> statements = gson.fromJson(params.get("boundStatements"), boundType);
|
||||
BlobBoundStatement.validate(previews, statements);
|
||||
return agent.executeBlobBoundStatements(previews, statements, stringOrNull(params, "schema"),
|
||||
intOrDefault(params, "queryTimeoutSecs", 0), transaction);
|
||||
}
|
||||
|
||||
private void switchCatalog(JsonObject params) throws Exception {
|
||||
String database = stringOrNull(params, "database");
|
||||
if (database != null && !database.trim().isEmpty() && agent.getConnection() != null) {
|
||||
@@ -407,6 +465,7 @@ public final class JsonRpcServer {
|
||||
}
|
||||
|
||||
private void ensureLiveConnection(String method) {
|
||||
if (!agent.permitsAutomaticReconnect()) return;
|
||||
if (lastConnectParams == null || !shouldValidateConnection(method)) {
|
||||
return;
|
||||
}
|
||||
@@ -556,6 +615,11 @@ public final class JsonRpcServer {
|
||||
return element.getAsInt();
|
||||
}
|
||||
|
||||
private static boolean booleanOrDefault(JsonObject object, String key, boolean defaultValue) {
|
||||
JsonElement element = object.get(key);
|
||||
return element == null || element instanceof JsonNull ? defaultValue : element.getAsBoolean();
|
||||
}
|
||||
|
||||
private MetadataListConstraints metadataListConstraints(JsonObject params) {
|
||||
return new MetadataListConstraints(
|
||||
stringOrNull(params, "filter"),
|
||||
|
||||
@@ -45,6 +45,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
private final Object maintenanceLock = new Object();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private ScheduledExecutorService maintenance;
|
||||
private boolean blobBindStatements;
|
||||
|
||||
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
|
||||
this(agentFactory, new JdbcConnectionPoolRegistry(), RuntimeLimits.defaults());
|
||||
@@ -91,6 +92,12 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
return new MultiSessionJsonRpcServer(sessionHandlerFactory, true);
|
||||
}
|
||||
|
||||
public static MultiSessionJsonRpcServer withBlobBindings(Supplier<? extends DatabaseAgent> agentFactory) {
|
||||
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(agentFactory);
|
||||
server.blobBindStatements = true;
|
||||
return server;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
synchronized (outputLock) {
|
||||
protocolOutput.println("{\"ready\":true}");
|
||||
@@ -138,7 +145,9 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
try {
|
||||
Object result;
|
||||
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
|
||||
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
|
||||
result = sessionHandlerFactory == null
|
||||
? AgentProtocol.multiSessionJdbcHandshakeResult(blobBindStatements)
|
||||
: customHandshake();
|
||||
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
|
||||
result = openSession(requiredSessionId(params), params);
|
||||
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
|
||||
@@ -425,6 +434,8 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
|
||||
|
||||
private boolean quarantineAndClose(ExecutorService cleanup) {
|
||||
state.compareAndSet(State.ACTIVE, State.QUARANTINED);
|
||||
// Release native requests before cleanup waits for their session lock.
|
||||
cancel();
|
||||
boolean replaceRuntime = server != null && server.quarantine();
|
||||
if (!cleanupScheduled.compareAndSet(false, true)) {
|
||||
return replaceRuntime;
|
||||
|
||||
@@ -8,6 +8,7 @@ public final class ObjectInfo {
|
||||
private String schema;
|
||||
private String comment;
|
||||
private Boolean valid;
|
||||
private String parent_name;
|
||||
|
||||
public ObjectInfo() {
|
||||
this("", "", null, null, null);
|
||||
@@ -69,6 +70,14 @@ public final class ObjectInfo {
|
||||
this.valid = valid;
|
||||
}
|
||||
|
||||
public String getParent_name() {
|
||||
return parent_name;
|
||||
}
|
||||
|
||||
public void setParent_name(String parentName) {
|
||||
this.parent_name = parentName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -78,12 +87,13 @@ public final class ObjectInfo {
|
||||
&& Objects.equals(object_type, that.object_type)
|
||||
&& Objects.equals(schema, that.schema)
|
||||
&& Objects.equals(comment, that.comment)
|
||||
&& Objects.equals(parent_name, that.parent_name)
|
||||
&& Objects.equals(valid, that.valid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, object_type, schema, comment, valid);
|
||||
return Objects.hash(name, object_type, schema, comment, valid, parent_name);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -7,20 +7,26 @@ public final class QueryPageOptions {
|
||||
private Integer fetchSize;
|
||||
private int maxRows;
|
||||
private int timeoutSecs;
|
||||
private boolean deferLobs;
|
||||
|
||||
public QueryPageOptions() {
|
||||
this(100, null, JdbcExecutor.DEFAULT_MAX_ROWS, 0);
|
||||
this(100, null, JdbcExecutor.DEFAULT_MAX_ROWS, 0, false);
|
||||
}
|
||||
|
||||
public QueryPageOptions(int pageSize, Integer fetchSize, int maxRows) {
|
||||
this(pageSize, fetchSize, maxRows, 0);
|
||||
this(pageSize, fetchSize, maxRows, 0, false);
|
||||
}
|
||||
|
||||
public QueryPageOptions(int pageSize, Integer fetchSize, int maxRows, int timeoutSecs) {
|
||||
this(pageSize, fetchSize, maxRows, timeoutSecs, false);
|
||||
}
|
||||
|
||||
public QueryPageOptions(int pageSize, Integer fetchSize, int maxRows, int timeoutSecs, boolean deferLobs) {
|
||||
this.pageSize = pageSize;
|
||||
this.fetchSize = fetchSize;
|
||||
this.maxRows = maxRows;
|
||||
this.timeoutSecs = timeoutSecs;
|
||||
this.deferLobs = deferLobs;
|
||||
}
|
||||
|
||||
public int getPageSize() {
|
||||
@@ -39,6 +45,10 @@ public final class QueryPageOptions {
|
||||
return timeoutSecs;
|
||||
}
|
||||
|
||||
public boolean getDeferLobs() {
|
||||
return deferLobs;
|
||||
}
|
||||
|
||||
public void setPageSize(int pageSize) {
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
@@ -55,6 +65,10 @@ public final class QueryPageOptions {
|
||||
this.timeoutSecs = timeoutSecs;
|
||||
}
|
||||
|
||||
public void setDeferLobs(boolean deferLobs) {
|
||||
this.deferLobs = deferLobs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -63,12 +77,13 @@ public final class QueryPageOptions {
|
||||
return pageSize == that.pageSize
|
||||
&& maxRows == that.maxRows
|
||||
&& timeoutSecs == that.timeoutSecs
|
||||
&& deferLobs == that.deferLobs
|
||||
&& Objects.equals(fetchSize, that.fetchSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(pageSize, fetchSize, maxRows, timeoutSecs);
|
||||
return Objects.hash(pageSize, fetchSize, maxRows, timeoutSecs, deferLobs);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -77,6 +92,7 @@ public final class QueryPageOptions {
|
||||
+ ", fetchSize=" + fetchSize
|
||||
+ ", maxRows=" + maxRows
|
||||
+ ", timeoutSecs=" + timeoutSecs
|
||||
+ ", deferLobs=" + deferLobs
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ public final class QueryPageResult {
|
||||
private boolean truncated;
|
||||
private String session_id;
|
||||
private boolean has_more;
|
||||
private List<java.util.Map<String, Object>> messages;
|
||||
|
||||
public List<java.util.Map<String, Object>> getMessages() {
|
||||
return messages == null ? Collections.emptyList() : messages;
|
||||
}
|
||||
|
||||
public void setMessages(List<java.util.Map<String, Object>> messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public QueryPageResult() {
|
||||
this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false, null, false);
|
||||
|
||||
@@ -20,6 +20,26 @@ public final class QueryResult {
|
||||
private Long server_execute_time_us;
|
||||
private java.util.Map<String, Double> query_timings_ms;
|
||||
private boolean truncated;
|
||||
// Optional protocol field: existing drivers omit it, manual SQL Server batches retain PRINT/warnings.
|
||||
private List<Map<String, Object>> messages;
|
||||
|
||||
public List<Map<String, Object>> getMessages() {
|
||||
return messages == null ? Collections.emptyList() : messages;
|
||||
}
|
||||
|
||||
public void setMessages(List<Map<String, Object>> messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public void addInformationalMessage(String message, String code) {
|
||||
if (messages == null) messages = new ArrayList<>();
|
||||
if (messages.size() >= 512) return;
|
||||
Map<String, Object> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("severity", "INFO");
|
||||
entry.put("message", message);
|
||||
if (code != null) entry.put("code", code);
|
||||
messages.add(entry);
|
||||
}
|
||||
|
||||
public QueryResult() {
|
||||
this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false);
|
||||
|
||||
@@ -65,6 +65,15 @@ public final class StandardJdbcMetadata {
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
}
|
||||
// HANA's databaseName (for example HXE) is not a JDBC catalog.
|
||||
// Its driver returns no rows, rather than throwing, for that catalog.
|
||||
// Retry without it before falling back to only the current schema.
|
||||
if (names.isEmpty() && catalog != null && profile.getCatalogFallbackEnabled()) {
|
||||
try {
|
||||
appendSchemas(names, meta.getSchemas(null, null));
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
addNonBlank(names, conn.getSchema());
|
||||
} catch (Exception | AbstractMethodError ignored) {
|
||||
@@ -641,6 +650,10 @@ public final class StandardJdbcMetadata {
|
||||
if ("BASE TABLE".equals(normalized) || "COLUMN TABLE".equals(normalized) || "ROW TABLE".equals(normalized)) {
|
||||
return "TABLE";
|
||||
}
|
||||
if ("CALC VIEW".equals(normalized) || "JOIN VIEW".equals(normalized)
|
||||
|| "OLAP VIEW".equals(normalized) || "HIERARCHY VIEW".equals(normalized)) {
|
||||
return "VIEW";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
"close_query_session",
|
||||
"read_large_value_chunk",
|
||||
"release_large_value",
|
||||
"start_table_read",
|
||||
"fetch_table_read_page",
|
||||
"close_table_read_session",
|
||||
|
||||
@@ -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", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "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_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1", "blob_bind_statements_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": [
|
||||
@@ -36,6 +36,8 @@
|
||||
"execute_query_page",
|
||||
"fetch_query_page",
|
||||
"close_query_session",
|
||||
"read_large_value_chunk",
|
||||
"release_large_value",
|
||||
"start_table_read",
|
||||
"fetch_table_read_page",
|
||||
"close_table_read_session",
|
||||
@@ -51,5 +53,15 @@
|
||||
"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", "bulk_write", "rename_collection", "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"
|
||||
"cursorSessionField": "sessionId",
|
||||
"blobBindStatementsV1": {
|
||||
"capability": "blob_bind_statements_v1",
|
||||
"methods": ["execute_batch", "execute_transaction"],
|
||||
"optionalFields": ["boundStatements", "queryTimeoutSecs"],
|
||||
"statementFields": ["previewSql", "sql", "blobParameters"],
|
||||
"blobEncoding": "even-length hexadecimal without 0x",
|
||||
"previewRule": "complete array length and each previewSql must exactly match statements; unbound sql equals previewSql",
|
||||
"manualTransaction": "same connection, per-batch savepoint, no implicit commit; rollback to savepoint on failure",
|
||||
"unsupported": "reject complete request before executing any statement; no SQL-text fallback"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Savepoint;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BlobBoundExecutorTest {
|
||||
private static final String PREVIEW = "UPDATE t SET b=HEXTORAW('00ff80')";
|
||||
private static final String SQL = "BEGIN UPDATE t SET b=? WHERE DBMS_LOB.COMPARE(b,?)=0; END;";
|
||||
private static BlobBoundStatement bound() { return new BlobBoundStatement(PREVIEW, SQL, Arrays.asList("00ff80", "cafe")); }
|
||||
|
||||
@Test void bindsStreamsOnFixedCallableSqlAndClosesThemBeforeCommit() throws Exception {
|
||||
Fake jdbc = new Fake();
|
||||
QueryResult result = run(jdbc, Arrays.asList(bound()), true);
|
||||
assertEquals(SQL, jdbc.preparedSql);
|
||||
assertEquals(Arrays.asList("00ff80", "cafe"), jdbc.boundHex);
|
||||
assertEquals(Arrays.asList(3L, 2L), jdbc.lengths);
|
||||
assertEquals(7, jdbc.timeout);
|
||||
assertEquals(1, jdbc.closedStatements);
|
||||
assertTrue(jdbc.events.indexOf("close") < jdbc.events.indexOf("commit"));
|
||||
assertEquals(1, jdbc.commits);
|
||||
assertEquals(0, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autoCommit);
|
||||
assertEquals(1, result.getAffected_rows());
|
||||
for (InputStream stream : jdbc.streams) assertThrows(java.io.IOException.class, stream::read);
|
||||
}
|
||||
|
||||
@Test void piecewiseDriverSeesRemainingBytesUntilEntireHexStreamIsSent() throws Exception {
|
||||
Fake jdbc=new Fake(); jdbc.piecewiseRead=true;
|
||||
String hex="00ff80".repeat(40000);
|
||||
BlobBoundStatement statement=new BlobBoundStatement(PREVIEW,"UPDATE t SET b=?",Arrays.asList(hex));
|
||||
run(jdbc,Arrays.asList(statement),false);
|
||||
assertEquals(Arrays.asList(hex),jdbc.boundHex);
|
||||
assertEquals(4,jdbc.pieces);
|
||||
assertEquals(0,jdbc.streams.get(0).available());
|
||||
}
|
||||
|
||||
@Test void timeoutRollsBackClosesStreamsAndNeverFallsBackToText() throws Exception {
|
||||
Fake jdbc = new Fake(); jdbc.fail = true;
|
||||
RuntimeException failure = assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), true));
|
||||
assertInstanceOf(SQLTimeoutException.class, failure.getCause());
|
||||
assertEquals(1, jdbc.executions); assertEquals(1, jdbc.rollbacks); assertEquals(0, jdbc.commits);
|
||||
assertEquals(1, jdbc.closedStatements); assertTrue(jdbc.autoCommit);
|
||||
for (InputStream stream : jdbc.streams) assertThrows(java.io.IOException.class, stream::read);
|
||||
}
|
||||
|
||||
@Test void manualFailureRollsBackOnlyBatchSavepoint() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit = false; jdbc.fail = true;
|
||||
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), false));
|
||||
assertEquals(1, jdbc.savepointRollbacks); assertEquals(0, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits); assertFalse(jdbc.autoCommit); assertEquals(1, jdbc.releases);
|
||||
}
|
||||
|
||||
@Test void manualSuccessDoesNotCommitOrChangeAutoCommit() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit = false;
|
||||
run(jdbc, Arrays.asList(bound()), false);
|
||||
assertEquals(1, jdbc.savepoints); assertEquals(1, jdbc.releases);
|
||||
assertEquals(0, jdbc.commits); assertEquals(0, jdbc.rollbacks); assertFalse(jdbc.autoCommit);
|
||||
}
|
||||
|
||||
@Test void laterManualConflictRollsBackFirstWriteButPreservesPriorTransactionWork() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit=false; jdbc.failAt=2;
|
||||
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound(),bound()), false));
|
||||
assertEquals(2,jdbc.executions); assertEquals(42,jdbc.value);
|
||||
assertEquals(1,jdbc.savepointRollbacks); assertEquals(0,jdbc.rollbacks);
|
||||
assertEquals(0,jdbc.commits); assertFalse(jdbc.autoCommit);
|
||||
}
|
||||
|
||||
@Test void failedManualRollbackQuarantinesConnectionWithUnknownOutcome() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit=false; jdbc.failAt=1; jdbc.failRollback=true;
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound()),false));
|
||||
assertTrue(jdbc.connectionClosed); assertEquals(0,jdbc.commits);
|
||||
com.google.gson.JsonObject data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
|
||||
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
assertEquals("connection",data.get("category").getAsString());
|
||||
assertEquals("08007",data.get("sqlState").getAsString());
|
||||
}
|
||||
|
||||
@Test void confirmedObBusinessErrorKeepsPriorWorkButRollbackFailureStillQuarantines() {
|
||||
for (int cleanupFailure : new int[]{0,1,2}) {
|
||||
boolean cleanupFails=cleanupFailure!=0;
|
||||
Fake jdbc=new Fake(); jdbc.autoCommit=false; jdbc.failAt=2; jdbc.failRollback=cleanupFailure==1; jdbc.failRelease=cleanupFailure==2;
|
||||
jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale target", "HY000", 20001);
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
};
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
|
||||
var data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
|
||||
assertEquals(cleanupFails?"quarantine":"keep",data.get("sessionDisposition").getAsString());
|
||||
assertEquals(cleanupFails?"connection":"sql",data.get("category").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
assertEquals(0,jdbc.commits); assertEquals(0,jdbc.rollbacks);
|
||||
if(!cleanupFails) { assertEquals(42,jdbc.value); assertEquals(1,jdbc.savepointRollbacks); assertFalse(jdbc.connectionClosed); }
|
||||
else { assertTrue(jdbc.connectionClosed); assertEquals("08007",data.get("sqlState").getAsString()); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactUnsupportedReleaseDoesNotCommitOrDiscardSuccessfulOrRolledBackManualBatch() {
|
||||
for(boolean conflict : new boolean[]{false,true}) {
|
||||
Fake jdbc=new Fake(); jdbc.autoCommit=false; jdbc.failRelease=true;
|
||||
jdbc.releaseFailure=new java.sql.SQLException("releaseSavepoint is not supported","99999",17023);
|
||||
if(conflict) { jdbc.failAt=2; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000",20001); }
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
public boolean isUnsupportedSavepointRelease(java.sql.SQLException error) { return error.getErrorCode()==17023 && "99999".equals(error.getSQLState()); }
|
||||
};
|
||||
if(conflict) {
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
|
||||
assertEquals("keep",AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data").get("sessionDisposition").getAsString());
|
||||
assertEquals(42,jdbc.value); assertEquals(1,jdbc.savepointRollbacks);
|
||||
} else {
|
||||
BlobBoundExecutor.execute(jdbc.connection(),Arrays.asList(PREVIEW),Arrays.asList(bound()),null,s->s,()->null,7,false,configurer);
|
||||
assertEquals(43,jdbc.value); assertEquals(0,jdbc.savepointRollbacks);
|
||||
}
|
||||
assertEquals(0,jdbc.commits); assertFalse(jdbc.autoCommit); assertFalse(jdbc.connectionClosed);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defaultTypedBatchCommitsOrRollsBackBeforeRestoringAutoCommit() {
|
||||
for(boolean conflict : new boolean[]{false,true}) {
|
||||
Fake jdbc=new Fake();
|
||||
if(conflict) { jdbc.failAt=2; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("ORA-20001: stale", "HY000",20001); }
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
};
|
||||
if(conflict) {
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer));
|
||||
assertEquals("keep",AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data").get("sessionDisposition").getAsString());
|
||||
assertEquals(42,jdbc.value); assertEquals(1,jdbc.rollbacks); assertEquals(0,jdbc.commits);
|
||||
} else {
|
||||
BlobBoundExecutor.execute(jdbc.connection(),Arrays.asList(PREVIEW,PREVIEW),Arrays.asList(bound(),bound()),null,s->s,()->null,7,false,configurer);
|
||||
assertEquals(44,jdbc.value); assertEquals(1,jdbc.commits); assertEquals(0,jdbc.rollbacks);
|
||||
}
|
||||
assertTrue(jdbc.autoCommit); assertEquals(0,jdbc.savepoints);
|
||||
}
|
||||
Fake manual=new Fake(); manual.autoCommit=false;
|
||||
assertThrows(IllegalStateException.class,()->run(manual,Arrays.asList(bound()),true));
|
||||
assertEquals(0,manual.executions); assertFalse(manual.autoCommit);
|
||||
}
|
||||
|
||||
@Test void typedDdlOrTransactionControlRejectsCompleteBatchBeforeOpeningConnection() {
|
||||
for(String sql : List.of("CREATE TABLE t (b BLOB)","BEGIN UPDATE t SET b=?; COMMIT; END;", "BEGIN EXECUTE IMMEDIATE 'DROP TABLE t'; END;")) {
|
||||
Fake jdbc=new Fake();
|
||||
BlobBoundStatement invalid=new BlobBoundStatement(sql,sql,sql.contains("?")?List.of("00"):List.of());
|
||||
assertThrows(IllegalArgumentException.class,()->run(jdbc,Arrays.asList(bound(),invalid),false));
|
||||
assertEquals(0,jdbc.executions); assertNull(jdbc.preparedSql); assertTrue(jdbc.autoCommit);
|
||||
}
|
||||
BlobBoundStatement.validate(List.of(PREVIEW),List.of(new BlobBoundStatement(PREVIEW,"UPDATE t SET b=? /* COMMIT */ WHERE note='CREATE TABLE x'",List.of("00"))));
|
||||
}
|
||||
|
||||
@Test void defaultTypedCommitRollbackOrResetFailuresNeverBecomeKnownBusinessKeep() {
|
||||
for(String stage : List.of("commit","rollback","reset")) {
|
||||
Fake jdbc=new Fake(); jdbc.failCommit=stage.equals("commit"); jdbc.failReset=stage.equals("reset");
|
||||
if(stage.equals("rollback")) { jdbc.failAt=1; jdbc.failRollback=true; jdbc.executionFailure=new java.sql.SQLTransientConnectionException("stale", "HY000",20001); }
|
||||
BlobBoundExecutor.PreparedStatementConfigurer configurer=new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
public void configure(PreparedStatement statement) { }
|
||||
public boolean isRollbackConfirmedBusinessError(java.sql.SQLException error) { return error.getErrorCode()==20001; }
|
||||
};
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->BlobBoundExecutor.execute(jdbc.connection(),
|
||||
Arrays.asList(PREVIEW),Arrays.asList(bound()),null,s->s,()->null,7,false,configurer));
|
||||
var data=AgentRpcError.toJson(failure,"execute_batch","test").getAsJsonObject("data");
|
||||
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsupportedSavepointFailsBeforeAnyWrite() {
|
||||
Fake jdbc = new Fake(); jdbc.autoCommit = false; jdbc.unsupportedSavepoint = true;
|
||||
assertThrows(RuntimeException.class, () -> run(jdbc, Arrays.asList(bound()), false));
|
||||
assertEquals(0, jdbc.executions); assertEquals(0, jdbc.commits); assertEquals(0, jdbc.rollbacks);
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidLaterBindingBeforeOpeningAnyStatement() {
|
||||
Fake jdbc = new Fake();
|
||||
assertThrows(IllegalArgumentException.class, () -> run(jdbc,
|
||||
Arrays.asList(bound(), new BlobBoundStatement(PREVIEW, SQL, Arrays.asList("0x00"))), true));
|
||||
assertEquals(0, jdbc.executions); assertNull(jdbc.preparedSql); assertTrue(jdbc.autoCommit);
|
||||
assertThrows(IllegalArgumentException.class, () -> BlobBoundStatement.validate(Arrays.asList("edited"), Arrays.asList(bound())));
|
||||
}
|
||||
|
||||
@Test void cancellationTracksStatementAndPreventsNextStatement() {
|
||||
Fake jdbc = new Fake(); jdbc.cancel = true;
|
||||
assertThrows(java.util.concurrent.CancellationException.class, () -> run(jdbc, Arrays.asList(bound(), bound()), true));
|
||||
assertEquals(2, jdbc.cancels); assertEquals(1, jdbc.executions);
|
||||
assertEquals(1, jdbc.rollbacks); assertEquals(2, jdbc.closedStatements);
|
||||
}
|
||||
|
||||
@Test void invalidLaterParameterCountFailsBeforeAnyWriteAndIgnoresQuotedMarkers() {
|
||||
Fake jdbc=new Fake();
|
||||
assertThrows(IllegalArgumentException.class,()->run(jdbc,Arrays.asList(bound(),new BlobBoundStatement(PREVIEW,SQL,Arrays.asList("cafe"))),true));
|
||||
assertEquals(0,jdbc.executions); assertNull(jdbc.preparedSql);
|
||||
BlobBoundStatement.validate(Arrays.asList(PREVIEW),Arrays.asList(new BlobBoundStatement(PREVIEW,
|
||||
"BEGIN UPDATE t SET b=?; x:='?''?'; x:=q'[?]'; -- ?\n/* ? */ END;",Arrays.asList("cafe"))));
|
||||
}
|
||||
|
||||
@Test void legacyAgentRejectsBindingBeforeCallingLegacyBatch() {
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(), new Class<?>[]{DatabaseAgent.class},
|
||||
(p, m, a) -> { if (m.getName().equals("supportsBlobBindStatements")) return false;
|
||||
if (m.getName().equals("getConnection")) return new Fake().connection();
|
||||
if (m.getName().startsWith("execute")) throw new AssertionError("Must not execute unsupported payload"); return defaultValue(m.getReturnType()); });
|
||||
String response = new JsonRpcServer(agent).handleRequest("{\"id\":1,\"method\":\"execute_batch\",\"params\":{\"statements\":[],\"boundStatements\":[]}}");
|
||||
assertTrue(JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error").get("message").getAsString().contains("blob_bind_statements_v1"), response);
|
||||
}
|
||||
|
||||
@Test void onlyOptedInServerAdvertisesBindingCapability() {
|
||||
com.google.gson.Gson gson = new com.google.gson.Gson();
|
||||
assertFalse(gson.toJson(AgentProtocol.multiSessionJdbcHandshakeResult()).contains("blob_bind_statements_v1"));
|
||||
assertTrue(gson.toJson(AgentProtocol.multiSessionJdbcHandshakeResult(true)).contains("blob_bind_statements_v1"));
|
||||
}
|
||||
|
||||
@Test void laterSetterFailureOccursBeforeAnyWriteAndClosesAllResources() {
|
||||
Fake jdbc=new Fake(); jdbc.failBindAt=3;
|
||||
assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound(),bound()),true));
|
||||
assertEquals(0,jdbc.executions); assertEquals(1,jdbc.rollbacks); assertEquals(2,jdbc.closedStatements);
|
||||
for(InputStream stream:jdbc.streams)assertThrows(java.io.IOException.class,stream::read);
|
||||
}
|
||||
|
||||
@Test void cancellationDuringResourceCloseStillPreventsCommit() {
|
||||
Fake jdbc=new Fake();jdbc.cancelOnClose=true;
|
||||
assertThrows(java.util.concurrent.CancellationException.class,()->run(jdbc,Arrays.asList(bound()),true));
|
||||
assertEquals(1,jdbc.executions);assertEquals(0,jdbc.commits);assertEquals(1,jdbc.rollbacks);
|
||||
}
|
||||
|
||||
@Test void connectionResetAfterCommitReportsUnknownInsteadOfClaimingNoWrite() {
|
||||
Fake jdbc=new Fake();jdbc.failReset=true;
|
||||
RuntimeException failure=assertThrows(RuntimeException.class,()->run(jdbc,Arrays.asList(bound()),true));
|
||||
assertEquals(1,jdbc.commits);assertTrue(jdbc.connectionClosed);
|
||||
com.google.gson.JsonObject data=AgentRpcError.toJson(failure,"execute_transaction","test").getAsJsonObject("data");
|
||||
assertEquals("quarantine",data.get("sessionDisposition").getAsString());
|
||||
assertEquals("unknown",data.get("operationOutcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void rpcRejectsNonStringHexRatherThanGsonCoercingIt() {
|
||||
DatabaseAgent agent=(DatabaseAgent)Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),new Class<?>[]{DatabaseAgent.class},
|
||||
(p,m,a)->{if(m.getName().equals("supportsBlobBindStatements"))return true;
|
||||
if(m.getName().equals("getConnection"))return new Fake().connection();
|
||||
if(m.getName().startsWith("execute"))throw new AssertionError("Malformed payload must not execute");return defaultValue(m.getReturnType());});
|
||||
String response=new JsonRpcServer(agent).handleRequest("{\"id\":2,\"method\":\"execute_transaction\",\"params\":{\"statements\":[\"preview\"],\"boundStatements\":[{\"previewSql\":\"preview\",\"sql\":\"BEGIN x:=?; END;\",\"blobParameters\":[12]}]}}");
|
||||
assertTrue(response.contains("hexadecimal string"),response);
|
||||
}
|
||||
|
||||
private static QueryResult run(Fake jdbc, List<BlobBoundStatement> statements, boolean transaction) {
|
||||
List<String> previews = new ArrayList<>(); for (BlobBoundStatement ignored : statements) previews.add(PREVIEW);
|
||||
return BlobBoundExecutor.execute(jdbc.connection(), previews, statements, null, schema -> null, () -> "", 7, transaction);
|
||||
}
|
||||
|
||||
private static final class Fake {
|
||||
boolean autoCommit = true, fail, cancel, unsupportedSavepoint, failRollback, connectionClosed, failReset, cancelOnClose, piecewiseRead, failRelease, failCommit;
|
||||
int pieces;
|
||||
int value=42,savedValue=42,transactionStartValue=42,failAt,failBindAt;
|
||||
int commits, rollbacks, savepointRollbacks, savepoints, releases, executions, closedStatements, cancels, timeout;
|
||||
String preparedSql;
|
||||
java.sql.SQLException executionFailure;
|
||||
java.sql.SQLException releaseFailure;
|
||||
List<String> events = new ArrayList<>(), boundHex = new ArrayList<>();
|
||||
List<Long> lengths = new ArrayList<>(); List<InputStream> streams = new ArrayList<>();
|
||||
Connection connection() {
|
||||
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (p,m,a) -> {
|
||||
switch (m.getName()) {
|
||||
case "getAutoCommit": return autoCommit;
|
||||
case "isValid": return true;
|
||||
case "close": connectionClosed=true; return null;
|
||||
case "setAutoCommit": if(failReset && (boolean)a[0])throw new java.sql.SQLException("reset failed"); if(autoCommit && !(boolean)a[0])transactionStartValue=value; autoCommit = (boolean)a[0]; return null;
|
||||
case "getMetaData": return Proxy.newProxyInstance(DatabaseMetaData.class.getClassLoader(), new Class<?>[]{DatabaseMetaData.class}, (x,y,z) -> y.getName().equals("supportsTransactions") ? true : defaultValue(y.getReturnType()));
|
||||
case "setSavepoint": if (unsupportedSavepoint) throw new java.sql.SQLFeatureNotSupportedException(); savepoints++; savedValue=value; return Proxy.newProxyInstance(Savepoint.class.getClassLoader(),new Class<?>[]{Savepoint.class},(x,y,z)->defaultValue(y.getReturnType()));
|
||||
case "releaseSavepoint": releases++; if(failRelease)throw releaseFailure!=null?releaseFailure:new java.sql.SQLTransientConnectionException("release lost connection","08006"); return null;
|
||||
case "commit": commits++; events.add("commit"); if(failCommit)throw new java.sql.SQLTransientConnectionException("commit outcome unknown","HY000",20001); return null;
|
||||
case "rollback": if(failRollback)throw new java.sql.SQLException("rollback lost connection","08006"); if (a == null || a.length == 0) {rollbacks++;value=transactionStartValue;} else {savepointRollbacks++;value=savedValue;} return null;
|
||||
case "prepareCall": case "prepareStatement": preparedSql=(String)a[0]; return statement(m.getName().equals("prepareCall"));
|
||||
default: return defaultValue(m.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
PreparedStatement statement(boolean callable) {
|
||||
Class<?> type = callable ? CallableStatement.class : PreparedStatement.class;
|
||||
return (PreparedStatement) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, (p,m,a) -> {
|
||||
switch (m.getName()) {
|
||||
case "setQueryTimeout": timeout=(int)a[0]; return null;
|
||||
case "setBlob": assertInstanceOf(InputStream.class,a[1]); assertInstanceOf(Long.class,a[2]);
|
||||
InputStream stream=(InputStream)a[1]; streams.add(stream); lengths.add((Long)a[2]);
|
||||
if(streams.size()==failBindAt)throw new java.sql.SQLException("binding failed");
|
||||
byte[] bytes;
|
||||
if(piecewiseRead) {
|
||||
java.io.ByteArrayOutputStream sent=new java.io.ByteArrayOutputStream(); byte[] buffer=new byte[32768];
|
||||
int read;
|
||||
while((read=stream.read(buffer))!=-1) { sent.write(buffer,0,read); pieces++; if(stream.available()==0)break; }
|
||||
bytes=sent.toByteArray();
|
||||
} else bytes=stream.readAllBytes();
|
||||
StringBuilder hex=new StringBuilder(); for(byte b:bytes)hex.append(String.format("%02x",b&255)); boundHex.add(hex.toString()); return null;
|
||||
case "execute": executions++;value++; if(fail)throw new SQLTimeoutException("timed out","HYT00"); if(executions==failAt)throw executionFailure!=null?executionFailure:new java.sql.SQLException("stale target","23000"); if(cancel)JdbcExecutor.current().cancelActiveStatements(); return false;
|
||||
case "getUpdateCount": return 1;
|
||||
case "cancel": cancels++; return null;
|
||||
case "close": closedStatements++; events.add("close"); if(cancelOnClose)JdbcExecutor.current().cancelActiveStatements(); return null;
|
||||
case "hashCode": return System.identityHashCode(p);
|
||||
case "equals": return p==a[0];
|
||||
default: return defaultValue(m.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if(type==boolean.class)return false;if(type==int.class)return 0;if(type==long.class)return 0L;return null;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,17 @@ class CommonJavaCompatibilityTest {
|
||||
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constraintMetadataUnsupportedIsNotAnEmptySuccess() {
|
||||
JsonRpcServer server = new JsonRpcServer(new MinimalAgent());
|
||||
JsonObject response = JsonParser.parseString(server.handleRequest(
|
||||
"{\"id\":1,\"method\":\"list_constraints\",\"params\":{\"schema\":\"APP\",\"table\":\"T\"}}"
|
||||
)).getAsJsonObject();
|
||||
|
||||
assertFalse(response.has("result"));
|
||||
assertTrue(response.getAsJsonObject("error").get("message").getAsString().contains("not supported"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcConnectionTestAddsOptionalDatabaseInfoWithoutChangingLegacySuccess() {
|
||||
JsonRpcServer legacyServer = new JsonRpcServer(new MinimalAgent());
|
||||
@@ -381,6 +392,66 @@ class CommonJavaCompatibilityTest {
|
||||
assertFalse(queryB.isAlive());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeSessionCancellationOnlyCancelsTargetSession() throws Exception {
|
||||
nativeCancellationProof("cancel_session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeSessionCloseCancelsTargetBeforeWaitingForItsRequestLock() throws Exception {
|
||||
nativeCancellationProof("close_session");
|
||||
}
|
||||
|
||||
private void nativeCancellationProof(String action) throws Exception {
|
||||
List<NativeCancelAgent> created = Collections.synchronizedList(new ArrayList<>());
|
||||
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> {
|
||||
NativeCancelAgent agent = new NativeCancelAgent();
|
||||
created.add(agent);
|
||||
return agent;
|
||||
});
|
||||
nativeRequest(server, "open_session", "a", 1);
|
||||
nativeRequest(server, "open_session", "b", 2);
|
||||
try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) {
|
||||
var target = workers.submit(() -> nativeRequest(server, "get_explain_info", "a", 3));
|
||||
var sibling = workers.submit(() -> nativeRequest(server, "get_explain_info", "b", 4));
|
||||
try {
|
||||
assertTrue(created.get(0).started.await(3, TimeUnit.SECONDS));
|
||||
assertTrue(created.get(1).started.await(3, TimeUnit.SECONDS));
|
||||
nativeRequest(server, action, "a", 5);
|
||||
String targetResponse = target.get(2, TimeUnit.SECONDS);
|
||||
assertTrue(JsonParser.parseString(targetResponse).getAsJsonObject().has("error"), targetResponse);
|
||||
assertTrue(created.get(0).cancelled.get());
|
||||
assertTrue(created.get(0).cleaned.get());
|
||||
assertFalse(created.get(0).executor.hasActiveStatements());
|
||||
assertFalse(created.get(1).cancelled.get(), "Sibling native Statement remains active");
|
||||
assertFalse(sibling.isDone(), "Sibling request remains blocked until its own release");
|
||||
if ("close_session".equals(action)) {
|
||||
assertTrue(created.get(0).closed.await(2, TimeUnit.SECONDS), "Close acquired request lock after cancellation");
|
||||
}
|
||||
created.get(1).release.countDown();
|
||||
String siblingResponse = sibling.get(2, TimeUnit.SECONDS);
|
||||
assertTrue(JsonParser.parseString(siblingResponse).getAsJsonObject().has("result"), siblingResponse);
|
||||
} finally {
|
||||
created.forEach(agent -> agent.release.countDown());
|
||||
nativeRequest(server, "close_session", "a", 6);
|
||||
nativeRequest(server, "close_session", "b", 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String nativeRequest(MultiSessionJsonRpcServer server, String method, String session, int id) {
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("jsonrpc", "2.0");
|
||||
request.addProperty("id", id);
|
||||
request.addProperty("method", method);
|
||||
JsonObject params = new JsonObject();
|
||||
params.addProperty("agentSessionId", session);
|
||||
params.addProperty("sql", "SELECT 1");
|
||||
params.addProperty("timeoutSecs", 0);
|
||||
params.addProperty("mode", "explain");
|
||||
request.add("params", params);
|
||||
return server.handleRequest(request.toString());
|
||||
}
|
||||
@Test
|
||||
void cancelActiveStatementsClosesOnlyThatExecutorPagedSessions() {
|
||||
JdbcExecutor target = new JdbcExecutor();
|
||||
@@ -436,6 +507,19 @@ class CommonJavaCompatibilityTest {
|
||||
assertTrue(row.get(2).getAsJsonPrimitive().isNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcServerDispatchesDeferredLobOption() {
|
||||
MinimalAgent agent = new MinimalAgent();
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
|
||||
String response = server.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"" + AgentProtocol.METHOD_EXECUTE_QUERY + "\",\"params\":{\"sql\":\"select payload from documents\",\"deferLobs\":true}}"
|
||||
);
|
||||
|
||||
assertTrue(JsonParser.parseString(response).getAsJsonObject().has("result"));
|
||||
assertTrue(agent.lastExecuteOptions.getDeferLobs());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcServerReconnectsWhenStoredJdbcConnectionIsStale() {
|
||||
ReconnectingAgent agent = new ReconnectingAgent();
|
||||
@@ -524,14 +608,14 @@ class CommonJavaCompatibilityTest {
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
|
||||
String startResponse = server.handleRequest(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"" + AgentProtocol.METHOD_START_TABLE_READ + "\",\"params\":{\"sql\":\"select * from orders\",\"schema\":\"public\",\"pageSize\":2,\"fetchSize\":8,\"maxRows\":20,\"timeoutSecs\":3}}"
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"" + AgentProtocol.METHOD_START_TABLE_READ + "\",\"params\":{\"sql\":\"select * from orders\",\"schema\":\"public\",\"pageSize\":2,\"fetchSize\":8,\"maxRows\":20,\"timeoutSecs\":3,\"deferLobs\":true}}"
|
||||
);
|
||||
JsonObject startJson = JsonParser.parseString(startResponse).getAsJsonObject();
|
||||
|
||||
assertTrue(startJson.has("result"));
|
||||
assertEquals("select * from orders", agent.lastSql);
|
||||
assertEquals("public", agent.lastSchema);
|
||||
assertEquals(new QueryPageOptions(2, 8, 20, 3), agent.lastOptions);
|
||||
assertEquals(new QueryPageOptions(2, 8, 20, 3, true), agent.lastOptions);
|
||||
assertEquals("table-session", startJson.getAsJsonObject("result").get("session_id").getAsString());
|
||||
|
||||
String fetchResponse = server.handleRequest(
|
||||
@@ -586,7 +670,9 @@ class CommonJavaCompatibilityTest {
|
||||
assertEquals(true, page.getHas_more());
|
||||
|
||||
assertEquals(JdbcExecutor.DEFAULT_MAX_ROWS, new ExecuteQueryOptions().getMaxRows());
|
||||
assertFalse(new ExecuteQueryOptions().getDeferLobs());
|
||||
assertEquals(100, new QueryPageOptions().getPageSize());
|
||||
assertFalse(new QueryPageOptions().getDeferLobs());
|
||||
assertNotNull(JdbcExecutor.INSTANCE);
|
||||
}
|
||||
|
||||
@@ -885,6 +971,8 @@ class CommonJavaCompatibilityTest {
|
||||
}
|
||||
|
||||
private static class MinimalAgent implements DatabaseAgent {
|
||||
private ExecuteQueryOptions lastExecuteOptions;
|
||||
|
||||
@Override
|
||||
public void connect(ConnectParams params) {
|
||||
}
|
||||
@@ -934,6 +1022,7 @@ class CommonJavaCompatibilityTest {
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
lastExecuteOptions = options;
|
||||
return new QueryResult(Collections.emptyList(), Collections.emptyList(), 0L, 0L);
|
||||
}
|
||||
|
||||
@@ -962,6 +1051,41 @@ class CommonJavaCompatibilityTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeCancelAgent extends MinimalAgent {
|
||||
private final CountDownLatch started = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private final CountDownLatch closed = new CountDownLatch(1);
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
private final AtomicBoolean cleaned = new AtomicBoolean();
|
||||
private volatile JdbcExecutor executor;
|
||||
private final java.sql.Statement statement = proxy(java.sql.Statement.class, (method, args) -> {
|
||||
if ("cancel".equals(method.getName())) {
|
||||
cancelled.set(true);
|
||||
release.countDown();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
@Override
|
||||
public String getExplainInfo(String sql, String database, String schema, int timeout, String mode) {
|
||||
executor = JdbcExecutor.current();
|
||||
try (var operation = executor.beginNativeOperation(); var tracked = executor.trackStatement(statement)) {
|
||||
started.countDown();
|
||||
if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("Native Statement was not cancelled");
|
||||
operation.checkCancelled();
|
||||
return "{}";
|
||||
} catch (InterruptedException | java.sql.SQLException error) {
|
||||
throw new RuntimeException(error);
|
||||
} finally {
|
||||
cleaned.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
closed.countDown();
|
||||
}
|
||||
}
|
||||
private static final class CancelTrackingAgent extends MinimalAgent {
|
||||
private final CountDownLatch statementStarted = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JdbcBoundedCursorTest {
|
||||
@Test
|
||||
void boundsTheWholeCursorAndPreservesDuplicateColumnsAndLookahead() {
|
||||
for (int max : new int[]{0, 1, 3, 10000, Integer.MAX_VALUE}) {
|
||||
for (int count : new int[]{0, 1, 2, 3, 4, 5}) {
|
||||
Fixture f = new Fixture(count);
|
||||
QueryPageResult page = f.start(new QueryPageOptions(1, null, max));
|
||||
List<List<Object>> rows = new ArrayList<>(page.getRows());
|
||||
while (page.getHas_more()) {
|
||||
page = f.executor.fetchPage(page.getSession_id(), 1);
|
||||
rows.addAll(page.getRows());
|
||||
}
|
||||
int expected = Math.min(count, Math.max(max, 1));
|
||||
assertEquals(expected, rows.size());
|
||||
for (int i = 0; i < expected; i++) assertEquals(List.of(i + 1, i + 1), rows.get(i));
|
||||
assertEquals(List.of("NAME", "NAME"), page.getColumns());
|
||||
assertEquals(count > expected, page.getTruncated());
|
||||
assertEquals(JdbcExecutor.statementMaxRows(max), f.statementLimit);
|
||||
assertEquals(1, f.fetchSize);
|
||||
f.assertClosed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesPositiveDefaultFetchSizeAndPreservesExplicitFetchSize() {
|
||||
for (Integer fetch : new Integer[]{null, 0, -1, 7}) {
|
||||
Fixture f = new Fixture(5);
|
||||
f.start(new QueryPageOptions(0, fetch, 3));
|
||||
assertEquals(fetch != null && fetch > 0 ? fetch : 1, f.fetchSize);
|
||||
f.executor.closeAllQuerySessions();
|
||||
f.assertClosed();
|
||||
}
|
||||
Fixture defaults = new Fixture(1);
|
||||
defaults.start(new QueryPageOptions());
|
||||
assertEquals(10001, defaults.statementLimit);
|
||||
assertEquals(100, defaults.fetchSize);
|
||||
defaults.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesOrdinaryCursorPolicyUnchanged() {
|
||||
Fixture f = new Fixture(5);
|
||||
f.executor.executePage(f.connection, "SELECT name, name FROM people", null, schema -> "",
|
||||
new QueryPageOptions(1, null, 3));
|
||||
assertEquals(0, f.statementLimit);
|
||||
assertEquals(0, f.fetchSize);
|
||||
f.executor.cancelActiveStatements();
|
||||
f.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleansUpLimitFetchExecuteAndReadFailures() {
|
||||
for (String failure : List.of("setMaxRows", "setFetchSize", "execute", "next")) {
|
||||
Fixture f = new Fixture(5);
|
||||
f.failure = failure;
|
||||
RuntimeException error = assertThrows(RuntimeException.class, () -> f.start(new QueryPageOptions()));
|
||||
assertTrue(error.toString().contains("fixture " + failure));
|
||||
f.assertClosed();
|
||||
}
|
||||
Fixture f = new Fixture(5);
|
||||
QueryPageResult page = f.start(new QueryPageOptions(1, null, 3));
|
||||
f.failure = "next";
|
||||
assertThrows(RuntimeException.class, () -> f.executor.fetchPage(page.getSession_id(), 1));
|
||||
f.assertClosed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelsActiveExecutionAndIdleBoundedCursor() {
|
||||
Fixture executing = new Fixture(5);
|
||||
executing.cancelOnExecute = true;
|
||||
assertThrows(RuntimeException.class, () -> executing.start(new QueryPageOptions()));
|
||||
assertEquals(1, executing.cancels);
|
||||
executing.assertClosed();
|
||||
|
||||
Fixture idle = new Fixture(5);
|
||||
idle.start(new QueryPageOptions(1, null, 3));
|
||||
idle.executor.cancelActiveStatements();
|
||||
assertEquals(1, idle.cancels);
|
||||
idle.assertClosed();
|
||||
|
||||
Fixture fetching = new Fixture(5);
|
||||
QueryPageResult page = fetching.start(new QueryPageOptions(1, null, 3));
|
||||
fetching.cancelOnNext = true;
|
||||
assertThrows(RuntimeException.class, () -> fetching.executor.fetchPage(page.getSession_id(), 1));
|
||||
assertEquals(1, fetching.cancels);
|
||||
fetching.assertClosed();
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final JdbcExecutor executor = new JdbcExecutor();
|
||||
final int count;
|
||||
int row;
|
||||
int statementLimit;
|
||||
int fetchSize;
|
||||
int closes;
|
||||
int cancels;
|
||||
String failure;
|
||||
boolean cancelOnExecute;
|
||||
boolean cancelOnNext;
|
||||
final ResultSetMetaData metadata = proxy(ResultSetMetaData.class, (object, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "getColumnCount": return 2;
|
||||
case "getColumnLabel": return "NAME";
|
||||
case "getColumnType": return Types.INTEGER;
|
||||
case "getColumnTypeName": return "NUMBER";
|
||||
default: return zero(method.getReturnType());
|
||||
}
|
||||
});
|
||||
final ResultSet result = proxy(ResultSet.class, (object, method, args) -> {
|
||||
fail(method.getName());
|
||||
switch (method.getName()) {
|
||||
case "getMetaData": return metadata;
|
||||
case "next": return nextRow();
|
||||
case "getInt": case "getObject": return row;
|
||||
default: return zero(method.getReturnType());
|
||||
}
|
||||
});
|
||||
final Statement statement = proxy(Statement.class, (object, method, args) -> {
|
||||
fail(method.getName());
|
||||
switch (method.getName()) {
|
||||
case "hashCode": return System.identityHashCode(object);
|
||||
case "equals": return object == args[0];
|
||||
case "setMaxRows": statementLimit = (Integer) args[0]; return null;
|
||||
case "setFetchSize": fetchSize = (Integer) args[0]; return null;
|
||||
case "execute":
|
||||
if (cancelOnExecute) {
|
||||
executor.cancelActiveStatements();
|
||||
throw new SQLException("fixture canceled");
|
||||
}
|
||||
return true;
|
||||
case "getResultSet": return result;
|
||||
case "close": closes++; return null;
|
||||
case "cancel": cancels++; return null;
|
||||
default: return zero(method.getReturnType());
|
||||
}
|
||||
});
|
||||
final Connection connection = proxy(Connection.class, (object, method, args) ->
|
||||
"createStatement".equals(method.getName()) ? statement : zero(method.getReturnType()));
|
||||
|
||||
Fixture(int count) { this.count = count; }
|
||||
|
||||
boolean nextRow() throws SQLException {
|
||||
if (cancelOnNext) {
|
||||
executor.cancelActiveStatements();
|
||||
throw new SQLException("fixture canceled during fetch");
|
||||
}
|
||||
return ++row <= count && (statementLimit == 0 || row <= statementLimit);
|
||||
}
|
||||
|
||||
void fail(String method) throws SQLException {
|
||||
if (method.equals(failure)) throw new SQLException("fixture " + method);
|
||||
}
|
||||
|
||||
QueryPageResult start(QueryPageOptions options) {
|
||||
return executor.executeBoundedPage(connection, "SELECT name, name FROM people", null,
|
||||
schema -> "", () -> "", options, executor::defaultResultValue);
|
||||
}
|
||||
|
||||
void assertClosed() {
|
||||
assertTrue(closes > 0);
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
assertFalse(executor.hasOpenSessions());
|
||||
}
|
||||
}
|
||||
|
||||
private static Object zero(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,86 @@ import static org.junit.jupiter.api.Assertions.fail;
|
||||
class JdbcConnectionPoolingTest {
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
@Test
|
||||
void blobRollbackFailureCannotReturnDirtyConnectionToAnotherSession() throws Exception {
|
||||
for (boolean manual : List.of(false, true)) {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
CountDownLatch batchFinished = new CountDownLatch(1);
|
||||
CountDownLatch finishRequest = new CountDownLatch(1);
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
String url = h2Url("blob_rollback");
|
||||
try (Connection setup = DriverManager.getConnection(url, "sa", "");
|
||||
Statement statement = setup.createStatement();
|
||||
JdbcConnectionPoolRegistry registry = new JdbcConnectionPoolRegistry(poolSettings(2))) {
|
||||
statement.execute("CREATE TABLE rollback_probe (id INT)");
|
||||
List<JsonRpcServer> sessions = new java.util.ArrayList<>();
|
||||
for (int index = 0; index < 2; index++) {
|
||||
H2TestAgent agent = new H2TestAgent(url, physicalOpens) {
|
||||
@Override public boolean supportsBlobBindStatements() { return true; }
|
||||
|
||||
@Override protected Connection openConnection(ConnectParams params) throws Exception {
|
||||
Connection physical = openH2(url, physicalOpens);
|
||||
if (physicalOpens.get() != 1) return physical;
|
||||
return (Connection) Proxy.newProxyInstance(getClass().getClassLoader(),
|
||||
new Class<?>[]{Connection.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("rollback")) {
|
||||
throw new SQLException("Rollback was not acknowledged", "HY000");
|
||||
}
|
||||
try { return method.invoke(physical, args); }
|
||||
catch (InvocationTargetException error) { throw error.getCause(); }
|
||||
});
|
||||
}
|
||||
|
||||
@Override public QueryResult executeBlobBoundStatements(List<String> previews,
|
||||
List<BlobBoundStatement> statements, String schema, int timeoutSecs, boolean transaction) {
|
||||
try {
|
||||
return BlobBoundExecutor.execute(requireConnected(), previews, statements,
|
||||
schema, value -> null, () -> null, timeoutSecs, transaction,
|
||||
new BlobBoundExecutor.PreparedStatementConfigurer() {
|
||||
@Override public void configure(java.sql.PreparedStatement statement) { }
|
||||
@Override public void discardConnection(Connection connection) throws SQLException {
|
||||
agentDiscard(connection);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
batchFinished.countDown();
|
||||
awaitUninterruptibly(finishRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private void agentDiscard(Connection connection) throws SQLException {
|
||||
discardConnection(connection);
|
||||
}
|
||||
};
|
||||
agent.attachConnectionPoolRegistry(registry);
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
server.dispatchForRuntime(AgentProtocol.METHOD_CONNECT,
|
||||
JsonParser.parseString("{\"database\":\"pooling\",\"username\":\"sa\",\"password\":\"\"}").getAsJsonObject());
|
||||
sessions.add(server);
|
||||
}
|
||||
if (manual) sessions.get(0).dispatchForRuntime(AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, new JsonObject());
|
||||
List<String> sql = List.of("INSERT INTO rollback_probe VALUES (1)", "INSERT INTO missing_table VALUES (2)");
|
||||
JsonObject request = new JsonObject();
|
||||
request.add("statements", GSON.toJsonTree(sql));
|
||||
request.add("boundStatements", GSON.toJsonTree(sql.stream()
|
||||
.map(value -> new BlobBoundStatement(value, value, List.of())).toList()));
|
||||
Future<Exception> failure = worker.submit(() -> assertThrows(Exception.class,
|
||||
() -> sessions.get(0).dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_BATCH, request)));
|
||||
try {
|
||||
assertTrue(batchFinished.await(3, TimeUnit.SECONDS));
|
||||
QueryResult result = (QueryResult) sessions.get(1).dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_QUERY,
|
||||
JsonParser.parseString("{\"sql\":\"SELECT COUNT(*) FROM rollback_probe\"}").getAsJsonObject());
|
||||
assertEquals(0L, ((Number) result.getRows().get(0).get(0)).longValue(), "manual=" + manual);
|
||||
assertEquals(2, physicalOpens.get());
|
||||
} finally { finishRequest.countDown(); }
|
||||
assertTrue(failure.get(3, TimeUnit.SECONDS).toString().contains("rollback was not confirmed"));
|
||||
} finally {
|
||||
finishRequest.countDown();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void registryReusesOnePhysicalConnectionAcrossSequentialLeases() throws Exception {
|
||||
AtomicInteger physicalOpens = new AtomicInteger();
|
||||
|
||||
@@ -26,6 +26,70 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcExecutorTest {
|
||||
@Test
|
||||
void executeAllPreservesPrintMessagesAlongsideRows() {
|
||||
CountingResultSetFixture rows = countingResultSet(new Object[][]{{1, "value"}});
|
||||
List<QueryResult> results = JdbcExecutor.INSTANCE.executeAll(
|
||||
executionConnection(true, -1, new SQLWarning("printed", "01000", 42), new AtomicInteger(), rows.resultSet(), null),
|
||||
"SELECT; PRINT", 10, null, 5, JdbcExecutor.INSTANCE::defaultResultValue);
|
||||
assertEquals("printed", results.get(0).getMessages().get(0).get("message"));
|
||||
assertEquals("42", results.get(0).getMessages().get(0).get("code"));
|
||||
assertEquals(1, results.get(0).getRows().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeAllDrainsTruncatedRowsAndLaterResultsWithoutReexecuting() {
|
||||
AtomicInteger executeCalls = new AtomicInteger();
|
||||
AtomicInteger index = new AtomicInteger();
|
||||
AtomicInteger drained = new AtomicInteger();
|
||||
ResultSet first = countingResultSet(new Object[][]{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}}).resultSet();
|
||||
ResultSet tracked = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class}, (ignored, method, args) -> {
|
||||
Object result = method.invoke(first, args);
|
||||
if (method.getName().equals("next") && Boolean.TRUE.equals(result)) drained.incrementAndGet();
|
||||
return result;
|
||||
});
|
||||
ResultSet second = countingResultSet(new Object[][]{{9, "last"}}).resultSet();
|
||||
Statement statement = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, (ignored, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute": executeCalls.incrementAndGet(); return true;
|
||||
case "setMaxRows": throw new AssertionError("UI retention must not cap JDBC responses");
|
||||
case "getResultSet": return index.get() == 0 ? tracked : second;
|
||||
case "getMoreResults": int next = index.incrementAndGet(); return next == 2;
|
||||
case "getUpdateCount": return index.get() == 1 ? 7 : -1;
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (ignored, method, args) ->
|
||||
method.getName().equals("createStatement") ? statement : defaultValue(method.getReturnType()));
|
||||
List<QueryResult> results = JdbcExecutor.INSTANCE.executeAll(conn, "SELECT; UPDATE; SELECT", 1, null, 5,
|
||||
JdbcExecutor.INSTANCE::defaultResultValue);
|
||||
assertEquals(1, executeCalls.get());
|
||||
assertEquals(4, drained.get());
|
||||
assertEquals(3, results.size());
|
||||
assertTrue(results.get(0).getTruncated());
|
||||
assertEquals(1, results.get(0).getRows().size());
|
||||
assertEquals(7, results.get(1).getAffected_rows());
|
||||
assertEquals(9, results.get(2).getRows().get(0).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void executeAllPropagatesLateErrorsInsteadOfReturningPartialSuccess() {
|
||||
AtomicInteger executeCalls = new AtomicInteger();
|
||||
Statement stmt = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, (ignored, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "execute": executeCalls.incrementAndGet(); return false;
|
||||
case "getUpdateCount": return 1;
|
||||
case "getMoreResults": throw new SQLException("late failure");
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
Connection conn = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, (ignored, method, args) ->
|
||||
method.getName().equals("createStatement") ? stmt : defaultValue(method.getReturnType()));
|
||||
assertThrows(RuntimeException.class, () -> JdbcExecutor.INSTANCE.executeAll(conn, "UPDATE", 1, null, 5,
|
||||
JdbcExecutor.INSTANCE::defaultResultValue));
|
||||
assertEquals(1, executeCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statementRowLimitIncludesOverflowProbeWithoutIntegerOverflow() {
|
||||
assertEquals(2, JdbcExecutor.statementMaxRows(0));
|
||||
@@ -236,7 +300,7 @@ class JdbcExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePageKeepsWarningsHiddenWithoutADriverMessageReader() {
|
||||
void executePageRetainsWarningsWithoutReplacingTheResult() {
|
||||
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
|
||||
executionConnection(
|
||||
false,
|
||||
@@ -254,6 +318,7 @@ class JdbcExecutorTest {
|
||||
|
||||
assertEquals(Collections.emptyList(), result.getColumns());
|
||||
assertEquals(Collections.emptyList(), result.getRows());
|
||||
assertEquals("existing paged warning", result.getMessages().get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -268,6 +333,23 @@ class JdbcExecutorTest {
|
||||
|
||||
assertEquals(Arrays.asList("id", "name"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList(1, "Ada")), result.getRows());
|
||||
assertEquals("informational", result.getMessages().get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void executePageRetainsWarningsWithOrdinaryRows() {
|
||||
CountingResultSetFixture fixture = countingResultSet(new Object[][]{{1, "Ada"}});
|
||||
QueryPageResult result = JdbcExecutor.INSTANCE.executePage(
|
||||
executionConnection(true, -1, new SQLWarning("notice with rows"), new AtomicInteger(), fixture.resultSet(), null),
|
||||
"SELECT id, name FROM people",
|
||||
"",
|
||||
schema -> "",
|
||||
new QueryPageOptions()
|
||||
);
|
||||
|
||||
assertEquals(Arrays.asList("id", "name"), result.getColumns());
|
||||
assertEquals(Arrays.asList(Arrays.asList(1, "Ada")), result.getRows());
|
||||
assertEquals("notice with rows", result.getMessages().get(0).get("message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JdbcNativeStatementTrackingTest {
|
||||
@Test
|
||||
void trackedStatementKeepsOriginalIdentityAndCancellationIsSessionLocal() throws Exception {
|
||||
JdbcExecutor target = new JdbcExecutor();
|
||||
JdbcExecutor sibling = new JdbcExecutor();
|
||||
AtomicInteger targetCancels = new AtomicInteger();
|
||||
AtomicInteger siblingCancels = new AtomicInteger();
|
||||
PreparedStatement original = statement(targetCancels, null);
|
||||
try (var a = target.trackStatement(original); var b = sibling.trackStatement(statement(siblingCancels, null))) {
|
||||
assertSame(original, a.statement());
|
||||
target.cancelActiveStatements();
|
||||
assertEquals(1, targetCancels.get());
|
||||
assertEquals(0, siblingCancels.get());
|
||||
assertTrue(target.hasActiveStatements());
|
||||
assertTrue(sibling.hasActiveStatements());
|
||||
}
|
||||
assertFalse(target.hasActiveStatements());
|
||||
assertFalse(sibling.hasActiveStatements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationIsRemovedEvenWhenJdbcCloseFails() {
|
||||
JdbcExecutor executor = new JdbcExecutor();
|
||||
SQLException failure = new SQLException("close failed");
|
||||
var tracked = executor.trackStatement(statement(new AtomicInteger(), failure));
|
||||
assertSame(failure, assertThrows(SQLException.class, tracked::close));
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
assertDoesNotThrow(tracked::close);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationLatchesAcrossStatementGapsButDoesNotPoisonNextNativeOperation() {
|
||||
JdbcExecutor executor = new JdbcExecutor();
|
||||
try (var operation = executor.beginNativeOperation()) {
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
executor.cancelActiveStatements();
|
||||
assertThrows(CancellationException.class, operation::checkCancelled);
|
||||
}
|
||||
try (var next = executor.beginNativeOperation()) {
|
||||
assertDoesNotThrow(next::checkCancelled);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreparedStatement statement(AtomicInteger cancels, SQLException closeError) {
|
||||
return (PreparedStatement) Proxy.newProxyInstance(PreparedStatement.class.getClassLoader(),
|
||||
new Class<?>[]{PreparedStatement.class}, (proxy, method, args) -> {
|
||||
if ("cancel".equals(method.getName())) cancels.incrementAndGet();
|
||||
if ("close".equals(method.getName()) && closeError != null) throw closeError;
|
||||
if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy);
|
||||
if ("equals".equals(method.getName())) return proxy == args[0];
|
||||
if (method.getReturnType() == boolean.class) return false;
|
||||
if (method.getReturnType() == int.class) return 0;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
class JsonRpcLargeValueTest {
|
||||
@Test
|
||||
void dispatchesOpaqueLocatorAndCharacterOffsetAndReleasesItWithoutSql() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
|
||||
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
|
||||
if (method.getName().equals("supportsQueryTiming")) return false;
|
||||
if (method.getName().equals("permitsAutomaticReconnect")) return false;
|
||||
if (method.getName().equals("readLargeValueChunk")) {
|
||||
calls.add("read");
|
||||
assertArrayEquals(new Object[]{"opaque-original", 700L, 4096}, args);
|
||||
return Map.of("status", "ok", "data", "中文😀", "next_offset", 703L, "eof", false, "value_kind", "text");
|
||||
}
|
||||
if (method.getName().equals("releaseLargeValue")) { calls.add("release"); assertEquals("opaque-original", args[0]); return true; }
|
||||
return null;
|
||||
});
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
Object result = server.dispatchForRuntime(AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK,
|
||||
JsonParser.parseString("{\"valueRef\":\"opaque-original\",\"offset\":700,\"limit\":4096}").getAsJsonObject());
|
||||
assertEquals("中文😀", ((Map<?, ?>) result).get("data"));
|
||||
assertEquals(true, server.dispatchForRuntime(AgentProtocol.METHOD_RELEASE_LARGE_VALUE,
|
||||
JsonParser.parseString("{\"valueRef\":\"opaque-original\"}").getAsJsonObject()));
|
||||
assertEquals(List.of("read", "release"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidatesLocatorsBeforeTransactionBoundary() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
|
||||
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
|
||||
if (method.getName().equals("supportsQueryTiming")) return false;
|
||||
if (method.getName().equals("permitsAutomaticReconnect")) return false;
|
||||
if (method.getName().equals("invalidateLargeValues")) { calls.add("invalidate"); return null; }
|
||||
if (method.getName().equals("commitManualTransaction")) { calls.add("commit"); return Map.of("ok", true); }
|
||||
return null;
|
||||
});
|
||||
new JsonRpcServer(agent).dispatchForRuntime(AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION,
|
||||
JsonParser.parseString("{}").getAsJsonObject());
|
||||
assertEquals(List.of("invalidate", "commit"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidatesBeforeFailedWriteAndRollbackAndOnCancellation() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
DatabaseAgent agent = (DatabaseAgent) Proxy.newProxyInstance(DatabaseAgent.class.getClassLoader(),
|
||||
new Class<?>[]{DatabaseAgent.class}, (object, method, args) -> {
|
||||
if (method.getName().equals("supportsQueryTiming")) return false;
|
||||
if (method.getName().equals("permitsAutomaticReconnect")) return false;
|
||||
if (method.getName().equals("invalidateLargeValues")) { calls.add("invalidate"); return null; }
|
||||
if (method.getName().equals("executeBatch")) { calls.add("write"); throw new java.sql.SQLException("concurrent row removed"); }
|
||||
if (method.getName().equals("rollbackManualTransaction")) { calls.add("rollback"); return Map.of("ok", true); }
|
||||
return null;
|
||||
});
|
||||
JsonRpcServer server = new JsonRpcServer(agent);
|
||||
assertThrows(Exception.class, () -> server.dispatchForRuntime(AgentProtocol.METHOD_EXECUTE_BATCH,
|
||||
JsonParser.parseString("{\"statements\":[\"UPDATE T SET C='full original value'\"]}").getAsJsonObject()));
|
||||
assertEquals(List.of("invalidate", "write"), calls);
|
||||
calls.clear();
|
||||
server.dispatchForRuntime(AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION, JsonParser.parseString("{}").getAsJsonObject());
|
||||
assertEquals(List.of("invalidate", "rollback"), calls);
|
||||
calls.clear();
|
||||
server.cancelActiveStatements();
|
||||
assertEquals(List.of("invalidate"), calls);
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -48,10 +50,12 @@ class StandardJdbcMetadataTest {
|
||||
@Test
|
||||
void scopesSchemasToTheConnectionCatalog() {
|
||||
AtomicReference<Object[]> capturedArgs = new AtomicReference<>();
|
||||
AtomicInteger schemaCalls = new AtomicInteger();
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getSchemas".equals(method.getName())) {
|
||||
schemaCalls.incrementAndGet();
|
||||
capturedArgs.set(args);
|
||||
return rows(row("TABLE_SCHEM", "APP"));
|
||||
}
|
||||
@@ -74,6 +78,57 @@ class StandardJdbcMetadataTest {
|
||||
assertEquals(List.of("APP"), StandardJdbcMetadata.INSTANCE.listSchemas(conn, profile, "initial_catalog"));
|
||||
assertEquals("regular_catalog", capturedArgs.get()[0]);
|
||||
assertEquals(null, capturedArgs.get()[1]);
|
||||
assertEquals(1, schemaCalls.get());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"false, false, true", "true, false, true", "false, true, true", "false, false, false"})
|
||||
void retriesEmptyCatalogSchemasBeforeAddingCurrentSchema(boolean fallbackFails, boolean currentCatalog, boolean fallbackEnabled) {
|
||||
List<String> catalogs = new ArrayList<>();
|
||||
DatabaseMetaData meta = proxy(DatabaseMetaData.class, new MethodHandler() {
|
||||
@Override
|
||||
public Object handle(Method method, Object[] args) {
|
||||
if ("getSchemas".equals(method.getName())) {
|
||||
String catalog = (String) args[0];
|
||||
catalogs.add(catalog);
|
||||
if (catalog != null) {
|
||||
return rows();
|
||||
}
|
||||
if (fallbackFails) {
|
||||
throw new UnsupportedOperationException("Unscoped schemas unavailable");
|
||||
}
|
||||
return rows(row("TABLE_SCHEM", "DBX_USER"), row("TABLE_SCHEM", "DBX_DEMO"), row("TABLE_SCHEM", "SYS"));
|
||||
}
|
||||
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 currentCatalog ? "HXE" : null;
|
||||
}
|
||||
if ("getSchema".equals(method.getName())) {
|
||||
return "DBX_USER";
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
|
||||
JdbcAgentProfile effectiveProfile = fallbackEnabled ? profile : new JdbcAgentProfile("example.Driver", "jdbc:example:") {
|
||||
@Override
|
||||
public boolean getCatalogFallbackEnabled() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
assertEquals(
|
||||
fallbackFails || !fallbackEnabled ? List.of("DBX_USER") : List.of("DBX_DEMO", "DBX_USER"),
|
||||
StandardJdbcMetadata.INSTANCE.listSchemas(conn, effectiveProfile, currentCatalog ? "initial_catalog" : "HXE")
|
||||
);
|
||||
assertEquals(fallbackEnabled ? Arrays.asList("HXE", null) : List.of("HXE"), catalogs);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation 'io.github.spannm:ucanaccess:5.1.5'
|
||||
implementation 'io.github.spannm:jackcess-encrypt:5.1.0'
|
||||
implementation 'io.github.spannm:ucanaccess:5.1.8'
|
||||
implementation 'io.github.spannm:jackcess-encrypt:5.1.6'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -5,15 +5,19 @@ import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.StandardJdbcMetadata;
|
||||
import com.dbx.agent.TableInfo;
|
||||
|
||||
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.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -37,6 +41,7 @@ public final class CacheAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
configuredDatabase = params.getDatabase();
|
||||
}
|
||||
|
||||
@@ -74,6 +79,85 @@ public final class CacheAgent extends ConfiguredJdbcAgent {
|
||||
return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), CACHE_PROFILE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return listTables(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
List<String> tableTypes = new ArrayList<>();
|
||||
for (String tableType : CACHE_PROFILE.getTableTypes()) {
|
||||
if (normalized.tableTypeAllowed(tableType)) {
|
||||
tableTypes.add(tableType);
|
||||
}
|
||||
}
|
||||
if (tableTypes.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return unchecked(() -> {
|
||||
Connection connection = requireConnection();
|
||||
try {
|
||||
return readTables(connection, schema, normalized, tableTypes);
|
||||
} catch (SQLException error) {
|
||||
if (error.getErrorCode() != -30 && !"42S02".equals(error.getSQLState())) {
|
||||
throw error;
|
||||
}
|
||||
return StandardJdbcMetadata.INSTANCE.listTables(connection, CACHE_PROFILE, configuredDatabase, schema, normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<TableInfo> readTables(
|
||||
Connection connection,
|
||||
String schema,
|
||||
MetadataListConstraints constraints,
|
||||
List<String> tableTypes
|
||||
) throws SQLException {
|
||||
int offset = constraints.hasOffset() ? constraints.getOffset() : 0;
|
||||
int limit = constraints.hasLimit() ? constraints.getLimit() : Integer.MAX_VALUE;
|
||||
long fetchLimit = (long) offset + limit;
|
||||
StringBuilder sql = new StringBuilder("SELECT ");
|
||||
if (constraints.hasLimit() && !constraints.hasFilter() && fetchLimit <= Integer.MAX_VALUE) {
|
||||
sql.append("TOP ").append(fetchLimit).append(' ');
|
||||
}
|
||||
sql.append("TABLE_NAME, TABLE_TYPE, DESCRIPTION FROM INFORMATION_SCHEMA.TABLES WHERE ");
|
||||
if (!isBlank(schema)) {
|
||||
sql.append("TABLE_SCHEMA = ? AND ");
|
||||
}
|
||||
sql.append("TABLE_TYPE IN (").append(String.join(", ", Collections.nCopies(tableTypes.size(), "?")))
|
||||
.append(") ORDER BY TABLE_NAME, TABLE_SCHEMA");
|
||||
|
||||
List<TableInfo> tables = new ArrayList<>();
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
int parameter = 1;
|
||||
if (!isBlank(schema)) {
|
||||
statement.setString(parameter++, schema.trim());
|
||||
}
|
||||
for (String tableType : tableTypes) {
|
||||
statement.setString(parameter++, tableType);
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
int skipped = 0;
|
||||
while (tables.size() < limit && result.next()) {
|
||||
String name = result.getString("TABLE_NAME");
|
||||
String comment = result.getString("DESCRIPTION");
|
||||
if (!constraints.nameOrCommentMatches(name, comment)) {
|
||||
continue;
|
||||
}
|
||||
if (skipped < offset) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
String tableType = result.getString("TABLE_TYPE");
|
||||
tables.add(new TableInfo(name, "BASE TABLE".equalsIgnoreCase(tableType) ? "TABLE" : tableType, comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
return cacheColumns(requireConnection(), getProfile(), configuredDatabase, schema, table);
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
package com.dbx.agent.cache;
|
||||
|
||||
import com.dbx.agent.AbstractJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CacheAgentTablesTest {
|
||||
@Test
|
||||
void bindsSpecialSchemaNamesLiterallyWithoutJdbcMetadata() throws Exception {
|
||||
for (String schema : List.of("_system", "%Atelier", "SQLUser", "a'b")) {
|
||||
Fixture fixture = new Fixture(List.of(table("Example", "BASE TABLE", "description")));
|
||||
List<TableInfo> tables = fixture.agent.listTables(schema);
|
||||
|
||||
assertEquals("Example", tables.get(0).getName());
|
||||
assertEquals("TABLE", tables.get(0).getTable_type());
|
||||
assertEquals("description", tables.get(0).getComment());
|
||||
assertTrue(fixture.sql.contains("FROM INFORMATION_SCHEMA.TABLES"));
|
||||
assertTrue(fixture.sql.contains("TABLE_SCHEMA = ?"));
|
||||
assertEquals(schema, fixture.bindings.get(0));
|
||||
assertFalse(fixture.sql.contains(schema));
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertTrue(fixture.resultClosed);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopsReadingMetadataAtTheRequestedPage() throws Exception {
|
||||
List<Map<String, String>> rows = new ArrayList<>();
|
||||
for (int index = 0; index < 5000; index++) {
|
||||
rows.add(table(String.format("Table%05d", index), "TABLE", ""));
|
||||
}
|
||||
Fixture fixture = new Fixture(rows);
|
||||
|
||||
List<TableInfo> tables = fixture.agent.listTables("_system", constraints(null, 2, 3, "TABLE"));
|
||||
|
||||
assertEquals(2, tables.size());
|
||||
assertEquals("Table00003", tables.get(0).getName());
|
||||
assertEquals("Table00004", tables.get(1).getName());
|
||||
assertEquals(5, fixture.rowsRead);
|
||||
assertTrue(fixture.sql.startsWith("SELECT TOP 5 "));
|
||||
assertTrue(fixture.sql.endsWith("ORDER BY TABLE_NAME, TABLE_SCHEMA"));
|
||||
assertEquals(List.of("_system", "TABLE", "BASE TABLE", "SYSTEM TABLE"), fixture.bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersViewsBeforeApplyingThePageLimit() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Report", "VIEW", "")));
|
||||
|
||||
assertEquals("VIEW", fixture.agent.listTables("SQLUser", constraints(null, 1, 0, "VIEW")).get(0).getTable_type());
|
||||
assertEquals(List.of("SQLUser", "VIEW", "SYSTEM VIEW"), fixture.bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesObjectTypeOverloadThroughTheNativeQuery() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Report", "SYSTEM VIEW", "")));
|
||||
|
||||
assertEquals("SYSTEM VIEW", fixture.agent.listTables("SQLUser", List.of("VIEW")).get(0).getTable_type());
|
||||
assertEquals(List.of("SQLUser", "VIEW", "SYSTEM VIEW"), fixture.bindings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesOffsetBeyondTheLastTable() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Example", "TABLE", "")));
|
||||
|
||||
assertTrue(fixture.agent.listTables("SQLUser", constraints(null, 2, 5, "TABLE")).isEmpty());
|
||||
assertEquals(1, fixture.rowsRead);
|
||||
}
|
||||
|
||||
@Test
|
||||
void avoidsOverflowInTheServerFetchLimit() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
|
||||
assertTrue(fixture.agent.listTables("SQLUser", constraints(null, 2, Integer.MAX_VALUE, "TABLE")).isEmpty());
|
||||
assertFalse(fixture.sql.contains("TOP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesFuzzyNameAndCommentSearchBeforeOffset() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(
|
||||
table("Alpha", "TABLE", ""),
|
||||
table("Budget", "TABLE", "monthly report"),
|
||||
table("Other", "TABLE", ""),
|
||||
table("MonthlyReport", "TABLE", ""),
|
||||
table("Unused", "TABLE", "")
|
||||
));
|
||||
|
||||
List<TableInfo> tables = fixture.agent.listTables("SQLUser", constraints("mrt", 1, 1, "TABLE"));
|
||||
|
||||
assertEquals("MonthlyReport", tables.get(0).getName());
|
||||
assertEquals(4, fixture.rowsRead);
|
||||
assertFalse(fixture.sql.contains("TOP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsUnscopedAndUnpagedLists() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Alpha", "TABLE", ""), table("Beta", "SYSTEM VIEW", "")));
|
||||
|
||||
assertEquals(2, fixture.agent.listTables(null).size());
|
||||
assertFalse(fixture.sql.contains("TABLE_SCHEMA = ?"));
|
||||
assertFalse(fixture.sql.contains("TOP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedObjectTypesDoNotContactTheDatabase() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
|
||||
assertTrue(fixture.agent.listTables("SQLUser", constraints(null, 1, 0, "PROCEDURE")).isEmpty());
|
||||
assertEquals("", fixture.sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotFallbackForAnEmptySchema() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
|
||||
assertTrue(fixture.agent.listTables("Empty").isEmpty());
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertTrue(fixture.resultClosed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToJdbcForServersWithoutInformationSchema() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of(table("Legacy", "TABLE", "")));
|
||||
fixture.queryFailure = new SQLException("Table INFORMATION_SCHEMA.TABLES not found", "42S02", -30);
|
||||
fixture.allowJdbc = true;
|
||||
|
||||
assertEquals("Legacy", fixture.agent.listTables("SQLUser", constraints(null, 1, 0, "TABLE")).get(0).getName());
|
||||
assertTrue(fixture.jdbcUsed);
|
||||
assertTrue(fixture.statementClosed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesConfiguredNamespaceForInheritedMetadata() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
fixture.allowJdbc = true;
|
||||
|
||||
assertEquals("USER", fixture.agent.listDatabases().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesResourcesWhenReadingFails() throws Exception {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
fixture.readFailure = new SQLException("connection lost", "08006");
|
||||
|
||||
assertThrows(RuntimeException.class, () -> fixture.agent.listTables("SQLUser"));
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertTrue(fixture.resultClosed);
|
||||
assertFalse(fixture.jdbcUsed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRetryPermissionOrConnectionErrorsThroughLegacyMetadata() throws Exception {
|
||||
for (String state : List.of("42000", "08006")) {
|
||||
Fixture fixture = new Fixture(List.of());
|
||||
fixture.queryFailure = new SQLException("metadata query failed", state);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> fixture.agent.listTables("SQLUser"));
|
||||
assertTrue(fixture.statementClosed);
|
||||
assertFalse(fixture.jdbcUsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static MetadataListConstraints constraints(String filter, int limit, int offset, String type) {
|
||||
return new MetadataListConstraints(filter, limit, offset, List.of(type));
|
||||
}
|
||||
|
||||
private static Map<String, String> table(String name, String type, String comment) {
|
||||
return Map.of("TABLE_NAME", name, "TABLE_TYPE", type, "DESCRIPTION", comment, "REMARKS", comment);
|
||||
}
|
||||
|
||||
private static <Value> Value proxy(Class<Value> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final CacheAgent agent = new CacheAgent();
|
||||
private final List<String> bindings = new ArrayList<>();
|
||||
private String sql = "";
|
||||
private int rowsRead;
|
||||
private boolean statementClosed;
|
||||
private boolean resultClosed;
|
||||
private boolean allowJdbc;
|
||||
private boolean jdbcUsed;
|
||||
private SQLException queryFailure;
|
||||
private SQLException readFailure;
|
||||
|
||||
private Fixture(List<Map<String, String>> values) throws Exception {
|
||||
Connection connection = proxy(Connection.class, (instance, method, arguments) -> {
|
||||
switch (method.getName()) {
|
||||
case "prepareStatement":
|
||||
sql = (String) arguments[0];
|
||||
return proxy(PreparedStatement.class, (statement, operation, parameters) -> {
|
||||
switch (operation.getName()) {
|
||||
case "setString": bindings.add((String) parameters[1]); return null;
|
||||
case "executeQuery":
|
||||
if (queryFailure != null) throw queryFailure;
|
||||
return rows(values);
|
||||
case "close": statementClosed = true; return null;
|
||||
default: throw new AssertionError(operation.getName());
|
||||
}
|
||||
});
|
||||
case "getMetaData":
|
||||
if (!allowJdbc) throw new AssertionError("unexpected legacy JDBC metadata query");
|
||||
jdbcUsed = true;
|
||||
return proxy(DatabaseMetaData.class, (metadata, operation, parameters) -> {
|
||||
switch (operation.getName()) {
|
||||
case "getTables": return rows(values);
|
||||
case "getTableTypes": return rows(List.of(Map.of("TABLE_TYPE", "TABLE")));
|
||||
case "getCatalogs": return rows(List.of());
|
||||
case "getSearchStringEscape": return "\\";
|
||||
default: throw new AssertionError(operation.getName());
|
||||
}
|
||||
});
|
||||
case "getCatalog": return null;
|
||||
default: throw new AssertionError(method.getName());
|
||||
}
|
||||
});
|
||||
Field connectionField = AbstractJdbcAgent.class.getDeclaredField("connection");
|
||||
connectionField.setAccessible(true);
|
||||
connectionField.set(agent, connection);
|
||||
agent.afterConnect(new ConnectParams("db.local", 1972, "USER", "", "", "", "", false), connection);
|
||||
}
|
||||
|
||||
private ResultSet rows(List<Map<String, String>> values) {
|
||||
int[] position = {-1};
|
||||
return proxy(ResultSet.class, (instance, method, arguments) -> {
|
||||
switch (method.getName()) {
|
||||
case "next":
|
||||
if (readFailure != null) throw readFailure;
|
||||
boolean hasNext = ++position[0] < values.size();
|
||||
if (hasNext) rowsRead++;
|
||||
return hasNext;
|
||||
case "getString": return values.get(position[0]).get((String) arguments[0]);
|
||||
case "close": resultClosed = true; return null;
|
||||
default: throw new AssertionError(method.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -744,6 +744,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
if (errorCode == -3325 || errorCode == -2207) {
|
||||
return true;
|
||||
}
|
||||
if (isDamengPermissionError(candidate)) {
|
||||
return true;
|
||||
}
|
||||
String message = candidate.getMessage();
|
||||
if (message == null) {
|
||||
continue;
|
||||
@@ -786,6 +789,27 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 达梦中文环境在无权限时只返回「权限不足」(vendorCode -5669),消息里不带对象名,
|
||||
* 无法通过 {@link #isDamengMetadataUnavailableError} 的对象名匹配识别。
|
||||
* 这类错误同样应按「元数据不可用」处理:走字典视图降级,而不是让查看对象直接报错。
|
||||
*/
|
||||
private static boolean isDamengPermissionError(SQLException error) {
|
||||
if (error.getErrorCode() == -5669) {
|
||||
return true;
|
||||
}
|
||||
String message = error.getMessage();
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String normalized = message.toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("权限")
|
||||
|| normalized.contains("privilege")
|
||||
|| normalized.contains("permission")
|
||||
|| normalized.contains("access denied")
|
||||
|| normalized.contains("not authorized");
|
||||
}
|
||||
|
||||
/** 连接类错误(断连/超时等)不属于元数据不可用,必须继续上抛。 */
|
||||
private static boolean isDamengConnectionError(Throwable error) {
|
||||
for (Throwable current = error; current != null; current = current.getCause()) {
|
||||
|
||||
@@ -1159,6 +1159,37 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTriggerCatalogWhenDbmsMetadataPermissionDenied() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String sql = (String) args[0];
|
||||
sqls.add(sql);
|
||||
if (sql.contains("DBMS_METADATA.GET_DDL")) {
|
||||
return failingMetadataStatement(new SQLException("权限不足", "28000", -5669));
|
||||
}
|
||||
if (sql.contains("ALL_TRIGGERS")) {
|
||||
return metadataStatement(List.of(List.of("BEGIN NULL; END;")));
|
||||
}
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}));
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "TRG_AUDIT", "TRIGGER");
|
||||
|
||||
Assertions.assertTrue(source.getSource().contains("BEGIN NULL; END;"), source.getSource());
|
||||
Assertions.assertTrue(source.getSource().contains("系统字典视图"), source.getSource());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("ALL_TRIGGERS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlFallsBackToViewAndMaterializedViewCatalogDefinitions() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.databend:databend-jdbc:0.4.6'
|
||||
implementation 'com.databend:databend-jdbc:0.4.8'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.12.1'
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.ibm.db2:jcc:11.5.9.0'
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MetadataSqlSupport;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.QueryPageResult;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.sql.Blob;
|
||||
import java.sql.Clob;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
@@ -25,13 +29,18 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class Db2Agent extends AbstractJdbcAgent {
|
||||
private static final String LARGE_VALUE_BYTES_COLUMN_PREFIX = "__DBX_LARGE_VALUE_BYTES_";
|
||||
private static final String DEFERRED_LOB_MARKER = "D:1";
|
||||
private static final Set<String> NUMERIC_PRECISION_TYPES = Set.of(
|
||||
"DECIMAL", "NUMERIC", "INTEGER", "SMALLINT", "BIGINT", "REAL", "DOUBLE", "FLOAT"
|
||||
);
|
||||
private static final Set<String> NUMERIC_SCALE_TYPES = Set.of("DECIMAL", "NUMERIC");
|
||||
private static final Set<String> CHARACTER_LENGTH_TYPES = Set.of("VARCHAR", "CHAR", "CLOB", "GRAPHIC", "VARGRAPHIC");
|
||||
private final Set<String> deferredQuerySessions = ConcurrentHashMap.newKeySet();
|
||||
private final Set<String> deferredTableReadSessions = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Override
|
||||
protected String driverClass() {
|
||||
@@ -55,6 +64,16 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterDisconnect() {
|
||||
deferredQuerySessions.clear();
|
||||
deferredTableReadSessions.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExplainInfo(String sql, String database, String schema, int timeoutSecs, String mode) {
|
||||
return unchecked(() -> Db2ExplainPlan.read(requireConnected(), sql, schema, timeoutSecs, mode));
|
||||
}
|
||||
@Override
|
||||
public List<DatabaseInfo> listDatabases() {
|
||||
return unchecked(() -> {
|
||||
@@ -383,7 +402,11 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
return JdbcExecutor.current().execute(
|
||||
boolean deferLobs = shouldDeferLobs(sql, options.getDeferLobs());
|
||||
JdbcExecutor.ResultValueReader valueReader = deferLobs
|
||||
? (JdbcExecutor.ColumnAwareResultValueReader) this::deferredResultValue
|
||||
: this::resultValue;
|
||||
QueryResult result = JdbcExecutor.current().execute(
|
||||
requireConnected(),
|
||||
sql,
|
||||
schema,
|
||||
@@ -391,8 +414,72 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
options.getMaxRows(),
|
||||
options.getFetchSize(),
|
||||
options.getTimeoutSecs(),
|
||||
this::resultValue
|
||||
valueReader
|
||||
);
|
||||
return deferLobs ? addDeferredLobMarkers(result) : result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
|
||||
boolean deferLobs = shouldDeferLobs(sql, options.getDeferLobs());
|
||||
JdbcExecutor.ResultValueReader valueReader = deferLobs
|
||||
? (JdbcExecutor.ColumnAwareResultValueReader) this::deferredResultValue
|
||||
: this::resultValue;
|
||||
QueryPageResult result = JdbcExecutor.current().executePage(
|
||||
requireConnected(),
|
||||
sql,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
this::resetSchemaSQL,
|
||||
options,
|
||||
valueReader,
|
||||
advancePastUpdateCounts()
|
||||
);
|
||||
rememberDeferredSession(deferredQuerySessions, result, deferLobs);
|
||||
return deferLobs ? addDeferredLobMarkers(result) : result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
|
||||
QueryPageResult result = JdbcExecutor.current().fetchPage(sessionId, pageSize);
|
||||
return finishDeferredPage(deferredQuerySessions, sessionId, result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean closeQuerySession(String sessionId) {
|
||||
deferredQuerySessions.remove(sessionId);
|
||||
return super.closeQuerySession(sessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
|
||||
boolean deferLobs = shouldDeferLobs(sql, options.getDeferLobs());
|
||||
JdbcExecutor.ResultValueReader valueReader = deferLobs
|
||||
? (JdbcExecutor.ColumnAwareResultValueReader) this::deferredResultValue
|
||||
: this::resultValue;
|
||||
QueryPageResult result = JdbcExecutor.current().startTableRead(
|
||||
requireConnected(),
|
||||
sql,
|
||||
schema,
|
||||
this::setSchemaSQL,
|
||||
this::resetSchemaSQL,
|
||||
options,
|
||||
valueReader
|
||||
);
|
||||
rememberDeferredSession(deferredTableReadSessions, result, deferLobs);
|
||||
return deferLobs ? addDeferredLobMarkers(result) : result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
|
||||
QueryPageResult result = JdbcExecutor.current().fetchTableReadPage(sessionId, pageSize);
|
||||
return finishDeferredPage(deferredTableReadSessions, sessionId, result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean closeTableReadSession(String sessionId) {
|
||||
deferredTableReadSessions.remove(sessionId);
|
||||
return super.closeTableReadSession(sessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -411,6 +498,138 @@ public final class Db2Agent extends AbstractJdbcAgent {
|
||||
});
|
||||
}
|
||||
|
||||
Object deferredResultValue(ResultSet rs, int index, int sqlType, String typeName) {
|
||||
String lobType = deferredLobType(sqlType, typeName);
|
||||
if (lobType == null) {
|
||||
return resultValue(rs, index, sqlType);
|
||||
}
|
||||
return unchecked(() -> {
|
||||
if ("BLOB".equals(lobType)) {
|
||||
Blob value = rs.getBlob(index);
|
||||
return value == null || rs.wasNull() ? null : "<BLOB>";
|
||||
}
|
||||
Clob value = rs.getClob(index);
|
||||
return value == null || rs.wasNull() ? null : "<" + lobType + ">";
|
||||
});
|
||||
}
|
||||
|
||||
static QueryResult addDeferredLobMarkers(QueryResult result) {
|
||||
DeferredLobResult deferred = deferredLobResult(result.getColumns(), result.getColumn_types(), result.getRows());
|
||||
if (deferred == null) {
|
||||
return result;
|
||||
}
|
||||
result.setColumns(deferred.columns());
|
||||
result.setColumn_types(deferred.columnTypes());
|
||||
result.setRows(deferred.rows());
|
||||
return result;
|
||||
}
|
||||
|
||||
static QueryPageResult addDeferredLobMarkers(QueryPageResult result) {
|
||||
DeferredLobResult deferred = deferredLobResult(result.getColumns(), result.getColumn_types(), result.getRows());
|
||||
if (deferred == null) {
|
||||
return result;
|
||||
}
|
||||
result.setColumns(deferred.columns());
|
||||
result.setColumn_types(deferred.columnTypes());
|
||||
result.setRows(deferred.rows());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DeferredLobResult deferredLobResult(
|
||||
List<String> columns,
|
||||
List<String> columnTypes,
|
||||
List<List<Object>> rows
|
||||
) {
|
||||
if (columns.size() != columnTypes.size()
|
||||
|| columns.stream().anyMatch(column -> column.toUpperCase(Locale.ROOT).startsWith(LARGE_VALUE_BYTES_COLUMN_PREFIX))) {
|
||||
return null;
|
||||
}
|
||||
List<String> deferredTypes = new ArrayList<>(columnTypes.size());
|
||||
boolean hasDeferredLob = false;
|
||||
for (String columnType : columnTypes) {
|
||||
String lobType = deferredLobType(Types.OTHER, columnType);
|
||||
deferredTypes.add(lobType);
|
||||
hasDeferredLob |= lobType != null;
|
||||
}
|
||||
if (!hasDeferredLob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> expandedColumns = new ArrayList<>(columns.size() + deferredTypes.size());
|
||||
List<String> expandedColumnTypes = new ArrayList<>(columnTypes.size() + deferredTypes.size());
|
||||
for (int index = 0; index < columns.size(); index++) {
|
||||
expandedColumns.add(columns.get(index));
|
||||
expandedColumnTypes.add(columnTypes.get(index));
|
||||
String lobType = deferredTypes.get(index);
|
||||
if (lobType != null) {
|
||||
expandedColumns.add(LARGE_VALUE_BYTES_COLUMN_PREFIX + markerAliasKind(lobType) + "_" + index);
|
||||
expandedColumnTypes.add("VARCHAR");
|
||||
}
|
||||
}
|
||||
|
||||
List<List<Object>> expandedRows = new ArrayList<>(rows.size());
|
||||
for (List<Object> row : rows) {
|
||||
List<Object> expandedRow = new ArrayList<>(expandedColumns.size());
|
||||
for (int index = 0; index < columns.size(); index++) {
|
||||
Object value = index < row.size() ? row.get(index) : null;
|
||||
expandedRow.add(value);
|
||||
if (deferredTypes.get(index) != null) {
|
||||
expandedRow.add(value == null ? null : DEFERRED_LOB_MARKER);
|
||||
}
|
||||
}
|
||||
expandedRows.add(expandedRow);
|
||||
}
|
||||
return new DeferredLobResult(expandedColumns, expandedColumnTypes, expandedRows);
|
||||
}
|
||||
|
||||
private static String deferredLobType(int sqlType, String typeName) {
|
||||
String normalized = typeName == null ? "" : typeName.trim().toUpperCase(Locale.ROOT);
|
||||
int parameterStart = normalized.indexOf('(');
|
||||
if (parameterStart >= 0) {
|
||||
normalized = normalized.substring(0, parameterStart).trim();
|
||||
}
|
||||
if (sqlType == Types.BLOB || "BLOB".equals(normalized)) {
|
||||
return "BLOB";
|
||||
}
|
||||
if ("DBCLOB".equals(normalized)) {
|
||||
return "DBCLOB";
|
||||
}
|
||||
if (sqlType == Types.CLOB || sqlType == Types.NCLOB || "CLOB".equals(normalized) || "NCLOB".equals(normalized)) {
|
||||
return "CLOB";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String markerAliasKind(String lobType) {
|
||||
return "BLOB".equals(lobType) ? "L" : "C";
|
||||
}
|
||||
|
||||
private static boolean shouldDeferLobs(String sql, boolean requested) {
|
||||
return requested && !sql.toUpperCase(Locale.ROOT).contains(LARGE_VALUE_BYTES_COLUMN_PREFIX);
|
||||
}
|
||||
|
||||
private static void rememberDeferredSession(Set<String> sessions, QueryPageResult result, boolean deferLobs) {
|
||||
if (deferLobs && result.getHas_more() && result.getSession_id() != null) {
|
||||
sessions.add(result.getSession_id());
|
||||
}
|
||||
}
|
||||
|
||||
private static QueryPageResult finishDeferredPage(Set<String> sessions, String sessionId, QueryPageResult result) {
|
||||
if (!sessions.contains(sessionId)) {
|
||||
return result;
|
||||
}
|
||||
if (!result.getHas_more()) {
|
||||
sessions.remove(sessionId);
|
||||
}
|
||||
return addDeferredLobMarkers(result);
|
||||
}
|
||||
|
||||
private record DeferredLobResult(
|
||||
List<String> columns,
|
||||
List<String> columnTypes,
|
||||
List<List<Object>> rows
|
||||
) {}
|
||||
|
||||
static String buildUrl(ConnectParams params) {
|
||||
if (!params.getConnection_string().trim().isEmpty()) {
|
||||
return params.getConnection_string();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.dbx.agent.db2;
|
||||
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import com.google.gson.Gson;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Reads a single, tagged DB2 LUW estimated plan on an isolated connection. */
|
||||
final class Db2ExplainPlan {
|
||||
private static final Gson JSON = new Gson();
|
||||
private static final int MAX_ROWS = 10_000;
|
||||
private static final String[] REQUEST_KEYS = {
|
||||
"EXPLAIN_REQUESTER", "EXPLAIN_TIME", "SOURCE_NAME", "SOURCE_SCHEMA",
|
||||
"SOURCE_VERSION", "EXPLAIN_LEVEL", "STMTNO", "SECTNO"
|
||||
};
|
||||
|
||||
private Db2ExplainPlan() {
|
||||
}
|
||||
|
||||
static String read(Connection connection, String sql, String schema, int timeoutSecs, String mode)
|
||||
throws SQLException {
|
||||
if (mode != null && !mode.isBlank() && !"explain".equalsIgnoreCase(mode)) {
|
||||
throw new IllegalArgumentException("DB2 supports estimated execution plans only");
|
||||
}
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
String source = singleQuery(sql);
|
||||
// Do not risk committing or rolling back an existing business transaction.
|
||||
if (!connection.getAutoCommit()) {
|
||||
throw new IllegalStateException("DB2 execution plans require an isolated connection with autocommit enabled");
|
||||
}
|
||||
try (var operation = executor.beginNativeOperation()) {
|
||||
return readPlan(connection, source, schema, timeoutSecs, executor, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readPlan(Connection connection, String source, String schema, int timeoutSecs,
|
||||
JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
String originalSchema = null;
|
||||
boolean schemaChanged = false;
|
||||
Throwable failure = null;
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
String requester;
|
||||
try (var tracked = statement(connection, timeoutSecs, executor, operation);
|
||||
ResultSet rows = tracked.statement().executeQuery("SELECT SESSION_USER, CURRENT SCHEMA FROM SYSIBM.SYSDUMMY1")) {
|
||||
if (!rows.next()) {
|
||||
throw new SQLException("DB2 did not return the session authorization ID");
|
||||
}
|
||||
requester = rows.getString(1).trim();
|
||||
originalSchema = rows.getString(2).trim();
|
||||
}
|
||||
String explainSchema = findExplainSchema(connection, requester, timeoutSecs, executor, operation);
|
||||
if (schema != null && !schema.isBlank() && !schema.equals(originalSchema)) {
|
||||
// SET SCHEMA is a session register: rollback alone does not restore it.
|
||||
schemaChanged = true;
|
||||
execute(connection, "SET SCHEMA " + quote(schema), timeoutSecs, executor, operation);
|
||||
}
|
||||
String tag = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
execute(connection, "EXPLAIN PLAN SET QUERYTAG = '" + tag + "' FOR " + source, timeoutSecs, executor, operation);
|
||||
Map<String, Object> plan = new LinkedHashMap<>();
|
||||
plan.put("version", 1);
|
||||
plan.put("databaseType", "db2");
|
||||
plan.put("requestTag", tag);
|
||||
plan.put("statement", readStatement(connection, explainSchema, requester, tag, timeoutSecs, executor, operation));
|
||||
plan.put("operators", readRows(connection, explainSchema, "EXPLAIN_OPERATOR",
|
||||
"r.OPERATOR_ID, r.OPERATOR_TYPE, r.TOTAL_COST, r.IO_COST, r.CPU_COST, r.FIRST_ROW_COST",
|
||||
"r.OPERATOR_ID", requester, tag, timeoutSecs,
|
||||
new String[]{"id", "type", "totalCost", "ioCost", "cpuCost", "firstRowCost"}, executor, operation));
|
||||
plan.put("streams", readRows(connection, explainSchema, "EXPLAIN_STREAM",
|
||||
"r.STREAM_ID, r.SOURCE_TYPE, r.SOURCE_ID, r.TARGET_TYPE, r.TARGET_ID, r.STREAM_COUNT, r.OBJECT_SCHEMA, r.OBJECT_NAME",
|
||||
"r.STREAM_ID", requester, tag, timeoutSecs,
|
||||
new String[]{"id", "sourceType", "sourceId", "targetType", "targetId", "rowCount", "objectSchema", "objectName"}, executor, operation));
|
||||
plan.put("predicates", readRows(connection, explainSchema, "EXPLAIN_PREDICATE",
|
||||
"r.OPERATOR_ID, r.PREDICATE_TEXT, r.HOW_APPLIED",
|
||||
"r.OPERATOR_ID, r.PREDICATE_ID", requester, tag, timeoutSecs,
|
||||
new String[]{"operatorId", "text", "howApplied"}, executor, operation));
|
||||
if (((List<?>) plan.get("operators")).isEmpty()) {
|
||||
throw new SQLException("DB2 returned no operators for this execution plan");
|
||||
}
|
||||
return JSON.toJson(plan);
|
||||
} catch (SQLException error) {
|
||||
SQLException diagnostic = diagnostic(error);
|
||||
failure = diagnostic;
|
||||
throw diagnostic;
|
||||
} catch (RuntimeException | Error error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
SQLException cleanupFailure = null;
|
||||
try {
|
||||
connection.rollback();
|
||||
if (schemaChanged && originalSchema != null) {
|
||||
execute(connection, "SET SCHEMA " + quote(originalSchema), timeoutSecs, executor, null);
|
||||
connection.rollback();
|
||||
}
|
||||
// Never switch autocommit back on when rollback failed: that could commit Explain rows.
|
||||
connection.setAutoCommit(true);
|
||||
} catch (SQLException error) {
|
||||
cleanupFailure = error;
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException closeError) {
|
||||
error.addSuppressed(closeError);
|
||||
}
|
||||
}
|
||||
if (cleanupFailure != null) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String findExplainSchema(Connection connection, String requester, int timeoutSecs, JdbcExecutor executor, JdbcExecutor.NativeOperation operation)
|
||||
throws SQLException {
|
||||
String sql = "SELECT TABSCHEMA FROM SYSCAT.TABLES WHERE TABNAME = 'EXPLAIN_INSTANCE' "
|
||||
+ "AND TABSCHEMA IN (?, 'SYSTOOLS') ORDER BY CASE WHEN TABSCHEMA = ? THEN 0 ELSE 1 END";
|
||||
try (var tracked = prepared(connection, sql, timeoutSecs, executor, operation)) {
|
||||
PreparedStatement statement = tracked.statement();
|
||||
statement.setString(1, requester);
|
||||
statement.setString(2, requester);
|
||||
operation.checkCancelled();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
// SYSCAT.TABLES includes aliases, so qualify reads through the user's aliases.
|
||||
if (rows.next()) {
|
||||
return rows.getString(1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Let EXPLAIN produce native SQL0219N when neither location exists, preserving its
|
||||
// SQLCODE/SQLSTATE. The diagnostic below adds initialization guidance without fabricating a code.
|
||||
return requester;
|
||||
}
|
||||
|
||||
private static Map<String, String> readStatement(Connection connection, String schema, String requester,
|
||||
String tag, int timeoutSecs, JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
String sql = "SELECT " + String.join(", ", REQUEST_KEYS) + " FROM "
|
||||
+ qualified(schema, "EXPLAIN_STATEMENT") + " WHERE EXPLAIN_REQUESTER = ? AND QUERYTAG = ? AND EXPLAIN_LEVEL = 'P'";
|
||||
try (var tracked = prepared(connection, sql, timeoutSecs, executor, operation)) {
|
||||
PreparedStatement statement = tracked.statement();
|
||||
statement.setString(1, requester);
|
||||
statement.setString(2, tag);
|
||||
operation.checkCancelled();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
if (!rows.next()) {
|
||||
throw new SQLException("DB2 returned no tagged plan-selection statement");
|
||||
}
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for (int index = 0; index < REQUEST_KEYS.length; index++) {
|
||||
result.put(REQUEST_KEYS[index], rows.getString(index + 1));
|
||||
}
|
||||
if (rows.next()) {
|
||||
throw new SQLException("DB2 returned multiple statements for one execution plan request");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Map<String, String>> readRows(Connection connection, String schema, String table,
|
||||
String columns, String order, String requester, String tag, int timeoutSecs, String[] fields, JdbcExecutor executor, JdbcExecutor.NativeOperation operation)
|
||||
throws SQLException {
|
||||
List<String> join = new ArrayList<>();
|
||||
for (String key : REQUEST_KEYS) {
|
||||
join.add("r." + key + " = s." + key);
|
||||
}
|
||||
String sql = "SELECT " + columns + " FROM " + qualified(schema, table) + " r JOIN "
|
||||
+ qualified(schema, "EXPLAIN_STATEMENT") + " s ON " + String.join(" AND ", join)
|
||||
+ " WHERE s.EXPLAIN_REQUESTER = ? AND s.QUERYTAG = ? AND s.EXPLAIN_LEVEL = 'P' ORDER BY " + order;
|
||||
try (var tracked = prepared(connection, sql, timeoutSecs, executor, operation)) {
|
||||
PreparedStatement statement = tracked.statement();
|
||||
statement.setMaxRows(MAX_ROWS + 1);
|
||||
statement.setString(1, requester);
|
||||
statement.setString(2, tag);
|
||||
operation.checkCancelled();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
List<Map<String, String>> result = new ArrayList<>();
|
||||
while (rows.next()) {
|
||||
if (result.size() >= MAX_ROWS) {
|
||||
throw new SQLException("DB2 execution plan exceeds the limit of " + MAX_ROWS + " " + table + " rows");
|
||||
}
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
for (int index = 0; index < fields.length; index++) {
|
||||
String value = rows.getString(index + 1);
|
||||
if (value != null) {
|
||||
// Preserve predicate text; CHAR fields and numeric strings have no useful padding.
|
||||
row.put(fields[index], "text".equals(fields[index]) ? value : value.stripTrailing());
|
||||
}
|
||||
}
|
||||
result.add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SQLException diagnostic(SQLException error) {
|
||||
String hint = "";
|
||||
for (SQLException current = error; current != null; current = current.getNextException()) {
|
||||
if (current.getErrorCode() == -219) {
|
||||
hint = " DB2 Explain tables are missing or incompatible. Ask an administrator to create them for the connecting user with CALL SYSPROC.SYSINSTALLOBJECTS('EXPLAIN', 'C', '', CURRENT USER), or migrate existing Explain tables and grant INSERT/SELECT privileges.";
|
||||
break;
|
||||
}
|
||||
if (current.getErrorCode() == -551 || current.getErrorCode() == -552) {
|
||||
hint = " Ask an administrator to grant Explain/object privileges and INSERT/SELECT access to the Explain tables.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hint.isEmpty()) {
|
||||
return error;
|
||||
}
|
||||
SQLException result = new SQLException(error.getMessage() + hint, error.getSQLState(), error.getErrorCode(), error);
|
||||
result.setNextException(error);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JdbcExecutor.TrackedStatement<Statement> statement(Connection connection, int timeoutSecs,
|
||||
JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
if (operation != null) operation.checkCancelled();
|
||||
return configure(executor.trackStatement(connection.createStatement()), timeoutSecs, operation);
|
||||
}
|
||||
|
||||
private static JdbcExecutor.TrackedStatement<PreparedStatement> prepared(Connection connection, String sql,
|
||||
int timeoutSecs, JdbcExecutor executor, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
operation.checkCancelled();
|
||||
return configure(executor.trackStatement(connection.prepareStatement(sql)), timeoutSecs, operation);
|
||||
}
|
||||
|
||||
private static <S extends Statement> JdbcExecutor.TrackedStatement<S> configure(
|
||||
JdbcExecutor.TrackedStatement<S> tracked, int timeoutSecs, JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
try {
|
||||
if (operation != null) operation.checkCancelled();
|
||||
timeout(tracked.statement(), timeoutSecs);
|
||||
return tracked;
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
try {
|
||||
tracked.close();
|
||||
} catch (SQLException closeError) {
|
||||
error.addSuppressed(closeError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private static void timeout(Statement statement, int timeoutSecs) throws SQLException {
|
||||
if (timeoutSecs >= 0) {
|
||||
statement.setQueryTimeout(timeoutSecs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void execute(Connection connection, String sql, int timeoutSecs, JdbcExecutor executor,
|
||||
JdbcExecutor.NativeOperation operation) throws SQLException {
|
||||
try (var tracked = statement(connection, timeoutSecs, executor, operation)) {
|
||||
if (operation != null) operation.checkCancelled();
|
||||
tracked.statement().execute(sql);
|
||||
}
|
||||
}
|
||||
private static String qualified(String schema, String table) {
|
||||
return quote(schema) + "." + quote(table);
|
||||
}
|
||||
|
||||
private static String quote(String value) {
|
||||
return JdbcIdentifiers.INSTANCE.doubleQuote(value);
|
||||
}
|
||||
|
||||
/** Rust checks read-only SQL too; this protects direct Agent/RPC callers and trailing terminators. */
|
||||
static String singleQuery(String sql) {
|
||||
if (sql == null || sql.isBlank()) {
|
||||
throw new IllegalArgumentException("DB2 execution plan SQL must not be empty");
|
||||
}
|
||||
StringBuilder significant = new StringBuilder();
|
||||
int terminator = -1;
|
||||
for (int index = 0; index < sql.length(); index++) {
|
||||
char ch = sql.charAt(index);
|
||||
if (Character.isWhitespace(ch)) {
|
||||
significant.append(' ');
|
||||
} else if (ch == '-' && index + 1 < sql.length() && sql.charAt(index + 1) == '-') {
|
||||
index += 2;
|
||||
while (index < sql.length() && sql.charAt(index) != '\n' && sql.charAt(index) != '\r') {
|
||||
index++;
|
||||
}
|
||||
significant.append(' ');
|
||||
} else if (ch == '/' && index + 1 < sql.length() && sql.charAt(index + 1) == '*') {
|
||||
int depth = 1;
|
||||
index += 2;
|
||||
while (index < sql.length() && depth > 0) {
|
||||
if (index + 1 < sql.length() && sql.charAt(index) == '/' && sql.charAt(index + 1) == '*') {
|
||||
depth++;
|
||||
index += 2;
|
||||
} else if (index + 1 < sql.length() && sql.charAt(index) == '*' && sql.charAt(index + 1) == '/') {
|
||||
depth--;
|
||||
index += 2;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (depth != 0) {
|
||||
throw new IllegalArgumentException("Unterminated DB2 SQL comment");
|
||||
}
|
||||
index--;
|
||||
significant.append(' ');
|
||||
} else {
|
||||
if (terminator >= 0) {
|
||||
throw new IllegalArgumentException("DB2 execution plans require a single SQL statement");
|
||||
}
|
||||
if (ch == ';') {
|
||||
terminator = index;
|
||||
} else if (ch == '\'' || ch == '"') {
|
||||
char quote = ch;
|
||||
significant.append(" quoted ");
|
||||
boolean closed = false;
|
||||
while (++index < sql.length()) {
|
||||
if (sql.charAt(index) == quote) {
|
||||
if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) {
|
||||
index++;
|
||||
} else {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!closed) {
|
||||
throw new IllegalArgumentException("Unterminated DB2 SQL quoted value");
|
||||
}
|
||||
} else {
|
||||
significant.append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
String code = significant.toString().stripLeading();
|
||||
if (!code.matches("(?is)^(SELECT|WITH|VALUES)\\b.*")) {
|
||||
throw new IllegalArgumentException("DB2 estimated execution plans accept SELECT, WITH, or VALUES queries");
|
||||
}
|
||||
// End a possible trailing line comment before SQL assembled around the source can continue.
|
||||
return (terminator >= 0 ? sql.substring(0, terminator) : sql).trim() + "\n";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import com.dbx.agent.test.JdbcMetadataSqlFake;
|
||||
@@ -15,6 +16,7 @@ import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Blob;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
@@ -24,6 +26,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -92,6 +95,59 @@ class Db2AgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
assertEquals("0x89504e", agent.resultValue(resultSet, 1, Types.LONGVARBINARY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defersBlobMaterializationWithoutCallingGetBytes() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
AtomicInteger getBytesCalls = new AtomicInteger();
|
||||
Blob blob = proxy(Blob.class, (method, args) -> defaultValue(method.getReturnType()));
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getBlob".equals(method.getName())) {
|
||||
return blob;
|
||||
}
|
||||
if ("getBytes".equals(method.getName())) {
|
||||
getBytesCalls.incrementAndGet();
|
||||
return new byte[] {1};
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
assertEquals("<BLOB>", agent.deferredResultValue(resultSet, 1, Types.BLOB, "BLOB"));
|
||||
assertEquals(0, getBytesCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsHiddenMarkersForDeferredLobColumns() {
|
||||
QueryResult result = new QueryResult(
|
||||
Arrays.asList("MAFAPPDATAID", "APP", "DESCRIPTION"),
|
||||
Arrays.asList("BIGINT", "BLOB", "CLOB(1M)"),
|
||||
Arrays.asList(
|
||||
Arrays.asList(1, "<BLOB>", "<CLOB>"),
|
||||
Arrays.asList(2, null, null)
|
||||
),
|
||||
0L,
|
||||
1L,
|
||||
false
|
||||
);
|
||||
|
||||
Db2Agent.addDeferredLobMarkers(result);
|
||||
|
||||
assertEquals(
|
||||
Arrays.asList("MAFAPPDATAID", "APP", "__DBX_LARGE_VALUE_BYTES_L_1", "DESCRIPTION", "__DBX_LARGE_VALUE_BYTES_C_2"),
|
||||
result.getColumns()
|
||||
);
|
||||
assertEquals(Arrays.asList("BIGINT", "BLOB", "VARCHAR", "CLOB(1M)", "VARCHAR"), result.getColumn_types());
|
||||
assertEquals(
|
||||
Arrays.asList(
|
||||
Arrays.asList(1, "<BLOB>", "D:1", "<CLOB>", "D:1"),
|
||||
Arrays.asList(2, null, null, null, null)
|
||||
),
|
||||
result.getRows()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsAllCatalogSchemasWithoutOwnerTypeFiltering() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.dbx.agent.db2;
|
||||
|
||||
import com.dbx.agent.JdbcIdentifiers;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Opt-in real DB2 LUW checks. Credentials stay in DBX_DB2_JDBC_URL/USER/PASSWORD.
|
||||
* The sequence check needs CREATEIN/DROPIN on the connecting user's schema.
|
||||
*/
|
||||
@EnabledIfEnvironmentVariable(named = "DBX_DB2_JDBC_URL", matches = ".+")
|
||||
class Db2ExplainPlanIntegrationTest {
|
||||
@Test
|
||||
void realPlansRoundTripAndRollbackWithoutChangingSessionSchema() throws Exception {
|
||||
try (Connection connection = connect()) {
|
||||
String originalSchema = scalar(connection, "VALUES CURRENT SCHEMA").trim();
|
||||
String explainSchema = explainSchema(connection);
|
||||
long before = requestCount(connection, explainSchema);
|
||||
Db2Agent agent = agent(connection);
|
||||
String businessSchema = System.getenv("DBX_DB2_SCHEMA");
|
||||
List<String> queries = List.of(
|
||||
"SELECT IBMREQD FROM SYSIBM.SYSDUMMY1;",
|
||||
"WITH x AS (SELECT IBMREQD FROM SYSIBM.SYSDUMMY1) SELECT * FROM x",
|
||||
"SELECT a.IBMREQD FROM SYSIBM.SYSDUMMY1 a JOIN SYSIBM.SYSDUMMY1 b ON a.IBMREQD = b.IBMREQD WHERE a.IBMREQD = 'Y'"
|
||||
);
|
||||
int index = 0;
|
||||
for (String sql : queries) {
|
||||
String raw = agent.getExplainInfo(sql, null, businessSchema, 30, "explain");
|
||||
JsonObject plan = JsonParser.parseString(raw).getAsJsonObject();
|
||||
assertFalse(plan.getAsJsonArray("operators").isEmpty());
|
||||
assertFalse(plan.getAsJsonArray("streams").isEmpty());
|
||||
assertEquals(originalSchema, scalar(connection, "VALUES CURRENT SCHEMA").trim());
|
||||
assertTrue(connection.getAutoCommit());
|
||||
assertEquals(before, requestCount(connection, explainSchema), "Explain rows must be rolled back");
|
||||
String directory = System.getenv("DBX_DB2_PROOF_DIR");
|
||||
if (directory != null && !directory.isBlank()) {
|
||||
Path path = Path.of(directory);
|
||||
Files.createDirectories(path);
|
||||
Files.writeString(path.resolve("jdbc-plan-" + index + ".json"), raw);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void explainDoesNotConsumeSequenceValueFromTheBusinessStatement() throws Exception {
|
||||
try (Connection connection = connect()) {
|
||||
String schema = scalar(connection, "VALUES SESSION_USER").trim();
|
||||
String name = "DBXEP_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
|
||||
String sequence = JdbcIdentifiers.INSTANCE.doubleQuote(schema) + "." + JdbcIdentifiers.INSTANCE.doubleQuote(name);
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE SEQUENCE " + sequence + " AS BIGINT START WITH 1 INCREMENT BY 1 NO CACHE");
|
||||
try {
|
||||
assertEquals("1", scalar(connection, "VALUES NEXT VALUE FOR " + sequence).trim());
|
||||
agent(connection).getExplainInfo("SELECT NEXT VALUE FOR " + sequence + " FROM SYSIBM.SYSDUMMY1", null, null, 30, null);
|
||||
assertEquals("2", scalar(connection, "VALUES NEXT VALUE FOR " + sequence).trim(),
|
||||
"Generating the estimated plan must not execute the sequence expression");
|
||||
} finally {
|
||||
statement.execute("DROP SEQUENCE " + sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentConnectionsReturnDistinctRequestsAndLeaveNoPersistedExplainRows() throws Exception {
|
||||
String explainSchema;
|
||||
long before;
|
||||
try (Connection connection = connect()) {
|
||||
explainSchema = explainSchema(connection);
|
||||
before = requestCount(connection, explainSchema);
|
||||
}
|
||||
List<Callable<String>> tasks = new ArrayList<>();
|
||||
for (int index = 0; index < 16; index++) {
|
||||
int value = index;
|
||||
tasks.add(() -> {
|
||||
try (Connection connection = connect()) {
|
||||
JsonObject plan = JsonParser.parseString(agent(connection).getExplainInfo(
|
||||
"SELECT " + value + " AS MARKER FROM SYSIBM.SYSDUMMY1", null,
|
||||
System.getenv("DBX_DB2_SCHEMA"), 30, null
|
||||
)).getAsJsonObject();
|
||||
assertFalse(plan.getAsJsonArray("operators").isEmpty());
|
||||
return plan.get("requestTag").getAsString();
|
||||
}
|
||||
});
|
||||
}
|
||||
HashSet<String> tags = new HashSet<>();
|
||||
try (var executor = Executors.newFixedThreadPool(4)) {
|
||||
for (var future : executor.invokeAll(tasks)) tags.add(future.get());
|
||||
}
|
||||
assertEquals(tasks.size(), tags.size());
|
||||
try (Connection connection = connect()) {
|
||||
assertEquals(before, requestCount(connection, explainSchema));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTransactionRemainsOpenWhenExplainIsRejected() throws Exception {
|
||||
try (Connection connection = connect()) {
|
||||
connection.setAutoCommit(false);
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> agent(connection).getExplainInfo("SELECT * FROM SYSIBM.SYSDUMMY1", null, null, 30, null));
|
||||
assertFalse(connection.getAutoCommit());
|
||||
assertEquals("Y", scalar(connection, "SELECT IBMREQD FROM SYSIBM.SYSDUMMY1").trim());
|
||||
connection.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
private static Db2Agent agent(Connection connection) {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
TestSupport.setPrivateConnection(agent, connection);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static Connection connect() throws Exception {
|
||||
return DriverManager.getConnection(System.getenv("DBX_DB2_JDBC_URL"),
|
||||
System.getenv("DBX_DB2_USER"), System.getenv("DBX_DB2_PASSWORD"));
|
||||
}
|
||||
|
||||
private static String scalar(Connection connection, String sql) throws Exception {
|
||||
try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(sql)) {
|
||||
assertTrue(rows.next());
|
||||
return rows.getString(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static String explainSchema(Connection connection) throws Exception {
|
||||
String user = scalar(connection, "VALUES SESSION_USER").trim();
|
||||
try (var statement = connection.prepareStatement("SELECT TABSCHEMA FROM SYSCAT.TABLES "
|
||||
+ "WHERE TABNAME = 'EXPLAIN_INSTANCE' AND TABSCHEMA IN (?, 'SYSTOOLS') "
|
||||
+ "ORDER BY CASE WHEN TABSCHEMA = ? THEN 0 ELSE 1 END")) {
|
||||
statement.setString(1, user);
|
||||
statement.setString(2, user);
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
assertTrue(rows.next(), "The integration database must have Explain tables installed");
|
||||
return rows.getString(1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long requestCount(Connection connection, String schema) throws Exception {
|
||||
try (var statement = connection.prepareStatement("SELECT COUNT(*) FROM "
|
||||
+ JdbcIdentifiers.INSTANCE.doubleQuote(schema) + ".EXPLAIN_INSTANCE WHERE EXPLAIN_REQUESTER = SESSION_USER");
|
||||
ResultSet rows = statement.executeQuery()) {
|
||||
assertTrue(rows.next());
|
||||
return rows.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.dbx.agent.db2;
|
||||
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import com.dbx.agent.JdbcExecutor;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Db2ExplainPlanTest {
|
||||
@Test
|
||||
void agentReturnsTaggedPlanWithoutExecutingBusinessQuery() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
JsonObject plan = JsonParser.parseString(jdbc.agent().getExplainInfo(
|
||||
"SELECT * FROM ORDERS WHERE NOTE = ';'; -- trailing comment", "DBX", "APP", 9, "explain"
|
||||
)).getAsJsonObject();
|
||||
assertEquals(1, plan.get("version").getAsInt());
|
||||
assertEquals("db2", plan.get("databaseType").getAsString());
|
||||
assertTrue(plan.get("requestTag").getAsString().matches("[0-9a-f]{20}"));
|
||||
assertEquals("12345678901234567890.123", plan.getAsJsonArray("operators").get(0)
|
||||
.getAsJsonObject().get("totalCost").getAsString());
|
||||
JsonObject stream = plan.getAsJsonArray("streams").get(0).getAsJsonObject();
|
||||
assertEquals("D", stream.get("sourceType").getAsString());
|
||||
assertEquals("-1", stream.get("sourceId").getAsString());
|
||||
assertEquals("ORDERS", stream.get("objectName").getAsString());
|
||||
assertEquals("(Q1.NOTE = ';')", plan.getAsJsonArray("predicates").get(0)
|
||||
.getAsJsonObject().get("text").getAsString());
|
||||
assertEquals(1, jdbc.executed.stream().filter(sql -> sql.startsWith("EXPLAIN PLAN")).count());
|
||||
assertTrue(jdbc.executed.stream().noneMatch(sql -> sql.startsWith("SELECT * FROM ORDERS")));
|
||||
assertTrue(jdbc.executed.stream().anyMatch(sql -> sql.endsWith("FOR SELECT * FROM ORDERS WHERE NOTE = ';'\n")));
|
||||
assertEquals(List.of(false, true), jdbc.autocommitChanges);
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
assertTrue(jdbc.timeouts.stream().allMatch(timeout -> timeout == 9));
|
||||
for (String query : jdbc.prepared) {
|
||||
if (query.contains(" r JOIN ")) {
|
||||
for (String key : List.of("EXPLAIN_REQUESTER", "EXPLAIN_TIME", "SOURCE_NAME", "SOURCE_SCHEMA",
|
||||
"SOURCE_VERSION", "EXPLAIN_LEVEL", "STMTNO", "SECTNO")) {
|
||||
assertTrue(query.contains("r." + key + " = s." + key), key);
|
||||
}
|
||||
assertTrue(query.contains("s.EXPLAIN_REQUESTER = ? AND s.QUERYTAG = ?"));
|
||||
assertTrue(query.contains("s.EXPLAIN_LEVEL = 'P'"));
|
||||
}
|
||||
}
|
||||
assertTrue(jdbc.boundTags.stream().allMatch(tag -> tag.equals(plan.get("requestTag").getAsString())));
|
||||
assertTrue(jdbc.prepared.stream().filter(sql -> sql.contains("EXPLAIN_") && !sql.contains("SYSCAT"))
|
||||
.allMatch(sql -> sql.contains("\"AUTH_USER\".")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void systoolsFallbackIsIndependentOfBusinessSchemaAndIncludesAliases() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainSchema = "SYSTOOLS";
|
||||
jdbc.agent().getExplainInfo("WITH x AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM x", null, "Mixed \"Schema", -1, null);
|
||||
assertTrue(jdbc.executed.contains("SET SCHEMA \"Mixed \"\"Schema\""));
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertTrue(jdbc.timeouts.isEmpty());
|
||||
assertTrue(jdbc.prepared.stream().filter(sql -> sql.contains(" r JOIN ")).allMatch(sql -> sql.contains("\"SYSTOOLS\".")));
|
||||
assertFalse(jdbc.prepared.get(0).contains("TYPE"));
|
||||
assertEquals("AUTH_USER", jdbc.catalogRequester);
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTransactionIsRejectedWithoutRollbackOrCommit() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.autocommit = false;
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1 FROM SYSIBM.SYSDUMMY1", null, "APP", 0, "explain"));
|
||||
assertTrue(error.getMessage().contains("isolated connection"));
|
||||
assertEquals(0, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
assertTrue(jdbc.executed.isEmpty());
|
||||
assertTrue(jdbc.autocommitChanges.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsSupportedQueryPrefixes() {
|
||||
for (String sql : Arrays.asList("SELECT * FROM \"APP\".\"ORDERS\"",
|
||||
"WITH q AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q", "VALUES 1",
|
||||
"/* plan */ select * FROM APP.ORDERS",
|
||||
"-- plan\nwith q AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q",
|
||||
"/* plan */ values (1)",
|
||||
"/* outer /* inner */ trailing */ SELECT 1 FROM SYSIBM.SYSDUMMY1",
|
||||
"/* outer /* inner */ DELETE */ VALUES ('a;''b')",
|
||||
"WITH q AS (/* outer /* inner */ UPDATE */ SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q")) {
|
||||
assertEquals(sql + "\n", Db2ExplainPlan.singleQuery(sql));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidInputsAndModesBeforeChangingConnection() {
|
||||
for (String sql : Arrays.asList(null, "", "DELETE FROM APP.ORDERS", "SELECT 1; DROP TABLE APP.ORDERS",
|
||||
"TABLE APP.ORDERS", "table \"APP\".\"ORDERS\";", "/* plan */ TABLE APP.ORDERS",
|
||||
"SELECT 1;;", "SELECT 'unterminated", "SELECT 1 /* unterminated", "SELECT 1 /* outer /* inner */")) {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
assertThrows(IllegalArgumentException.class, () -> jdbc.agent().getExplainInfo(sql, null, null, 5, "explain"));
|
||||
assertTrue(jdbc.autocommitChanges.isEmpty());
|
||||
assertTrue(jdbc.executed.isEmpty());
|
||||
}
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 5, "autotrace"));
|
||||
assertTrue(jdbc.autocommitChanges.isEmpty());
|
||||
assertEquals("/* leading /* nested */ comment */ VALUES ('a;''b')\n",
|
||||
Db2ExplainPlan.singleQuery("/* leading /* nested */ comment */ VALUES ('a;''b'); -- end"));
|
||||
assertEquals("SELECT \"semi;column\" FROM T -- comment\n",
|
||||
Db2ExplainPlan.singleQuery("SELECT \"semi;column\" FROM T -- comment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesSqlCodeStateAndChainWhileAddingPermissionHint() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
SQLException nativeError = new SQLException("SQL0551N not authorized", "42501", -551);
|
||||
nativeError.setNextException(new SQLException("Additional diagnostic", "42501", -551));
|
||||
jdbc.explainError = nativeError;
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT * FROM ORDERS", null, "APP", 5, "explain"));
|
||||
SQLException diagnostic = sqlCause(error);
|
||||
assertEquals(-551, diagnostic.getErrorCode());
|
||||
assertEquals("42501", diagnostic.getSQLState());
|
||||
assertSame(nativeError, diagnostic.getCause());
|
||||
assertSame(nativeError, diagnostic.getNextException());
|
||||
assertTrue(error.getMessage().contains("INSERT/SELECT"));
|
||||
assertEquals(List.of(false, true), jdbc.autocommitChanges);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTablesGiveInitializationInstructionAndReleaseTransaction() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainSchema = null;
|
||||
jdbc.explainError = new SQLException("SQL0219N Explain table does not exist", "42704", -219);
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, "APP", 0, "explain"));
|
||||
assertTrue(error.getMessage().contains("SYSINSTALLOBJECTS"));
|
||||
assertTrue(error.getMessage().contains("CURRENT USER"));
|
||||
assertEquals(-219, sqlCause(error).getErrorCode());
|
||||
assertEquals("42704", sqlCause(error).getSQLState());
|
||||
assertTrue(jdbc.executed.stream().anyMatch(sql -> sql.startsWith("EXPLAIN")));
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollbackFailureClosesConnectionWithoutEnablingAutocommit() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.rollbackError = new SQLException("rollback failed", "08006", -30081);
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, "APP", 1, "explain"));
|
||||
assertEquals("08006", sqlCause(error).getSQLState());
|
||||
assertTrue(jdbc.closed);
|
||||
assertEquals(List.of(false), jdbc.autocommitChanges);
|
||||
assertEquals(0, jdbc.commits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupFailureIsSuppressedUnderOriginalDatabaseError() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainError = new SQLException("SQL0104N unexpected token", "42601", -104);
|
||||
jdbc.rollbackError = new SQLException("rollback failed", "08006", -30081);
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 1, "explain"));
|
||||
assertSame(jdbc.explainError, sqlCause(error));
|
||||
assertEquals(1, jdbc.explainError.getSuppressed().length);
|
||||
assertSame(jdbc.rollbackError, jdbc.explainError.getSuppressed()[0]);
|
||||
assertTrue(jdbc.closed);
|
||||
assertEquals(List.of(false), jdbc.autocommitChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctRequestsUseDistinctTagsAndNeverCommit() {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
Db2Agent agent = jdbc.agent();
|
||||
String first = JsonParser.parseString(agent.getExplainInfo("SELECT 1", null, null, 0, null))
|
||||
.getAsJsonObject().get("requestTag").getAsString();
|
||||
String second = JsonParser.parseString(agent.getExplainInfo("SELECT 2", null, null, 0, null))
|
||||
.getAsJsonObject().get("requestTag").getAsString();
|
||||
assertNotEquals(first, second);
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAmbiguousOrEmptyPlanInsteadOfSilentlyReturningAnotherRequest() {
|
||||
for (int count : List.of(0, 2)) {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.statementCount = count;
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 0, null));
|
||||
assertEquals(1, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
}
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.operatorCount = 0;
|
||||
RuntimeException error = assertThrows(RuntimeException.class,
|
||||
() -> jdbc.agent().getExplainInfo("SELECT 1", null, null, 0, null));
|
||||
assertTrue(error.getMessage().contains("no operators"));
|
||||
assertEquals(1, jdbc.rollbacks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationReachesBlockedNativeExplainAndCleansUpItsTransaction() throws Exception {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.explainStarted = new CountDownLatch(1);
|
||||
jdbc.cancelSignal = new CountDownLatch(1);
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
Db2Agent agent = jdbc.agent();
|
||||
try (var worker = Executors.newSingleThreadExecutor()) {
|
||||
var pending = worker.submit(() -> agent.getExplainInfo("SELECT * FROM ORDERS", null, "APP", 0, "explain"));
|
||||
try {
|
||||
assertTrue(jdbc.explainStarted.await(3, TimeUnit.SECONDS), "Native Explain entered its blocking call");
|
||||
assertTrue(executor.hasActiveStatements(), "Native Statement registered with the session executor");
|
||||
executor.cancelActiveStatements();
|
||||
ExecutionException error = assertThrows(ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
|
||||
assertEquals("57014", sqlCause(error).getSQLState());
|
||||
assertEquals(1, jdbc.cancelCalls);
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertEquals(0, jdbc.commits);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertEquals(0, jdbc.openResultSets);
|
||||
} finally {
|
||||
jdbc.cancelSignal.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void cancellationInNativeStatementGapPreventsExplainAndStillRestoresSchema() throws Exception {
|
||||
JdbcBoundary jdbc = new JdbcBoundary();
|
||||
jdbc.gapStarted = new CountDownLatch(1);
|
||||
jdbc.gapResume = new CountDownLatch(1);
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
Db2Agent agent = jdbc.agent();
|
||||
try (var worker = Executors.newSingleThreadExecutor()) {
|
||||
var pending = worker.submit(() -> agent.getExplainInfo("SELECT * FROM ORDERS", null, "APP", 0, "explain"));
|
||||
try {
|
||||
assertTrue(jdbc.gapStarted.await(3, TimeUnit.SECONDS));
|
||||
assertFalse(executor.hasActiveStatements(), "Previous schema Statement already closed");
|
||||
executor.cancelActiveStatements();
|
||||
jdbc.gapResume.countDown();
|
||||
ExecutionException error = assertThrows(ExecutionException.class, () -> pending.get(2, TimeUnit.SECONDS));
|
||||
assertInstanceOf(CancellationException.class, error.getCause());
|
||||
assertTrue(jdbc.executed.stream().noneMatch(sql -> sql.startsWith("EXPLAIN")));
|
||||
assertEquals(2, jdbc.rollbacks);
|
||||
assertTrue(jdbc.autocommit);
|
||||
assertEquals("SESSION_DEFAULT", jdbc.schema);
|
||||
assertEquals(0, jdbc.openStatements);
|
||||
assertFalse(executor.hasActiveStatements());
|
||||
} finally {
|
||||
jdbc.gapResume.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
private static SQLException sqlCause(Throwable error) {
|
||||
while (!(error instanceof SQLException) && error.getCause() != null) error = error.getCause();
|
||||
return assertInstanceOf(SQLException.class, error);
|
||||
}
|
||||
|
||||
/** JDBC is the mocked boundary; production Agent and serialization run unchanged. */
|
||||
private static final class JdbcBoundary {
|
||||
boolean autocommit = true;
|
||||
boolean closed;
|
||||
String schema = "SESSION_DEFAULT";
|
||||
String explainSchema = "AUTH_USER";
|
||||
String catalogRequester;
|
||||
int rollbacks;
|
||||
int commits;
|
||||
int openStatements;
|
||||
int openResultSets;
|
||||
int statementCount = 1;
|
||||
int operatorCount = 1;
|
||||
SQLException explainError;
|
||||
SQLException rollbackError;
|
||||
CountDownLatch explainStarted;
|
||||
CountDownLatch cancelSignal;
|
||||
int cancelCalls;
|
||||
CountDownLatch gapStarted;
|
||||
CountDownLatch gapResume;
|
||||
int nativeStatementCreations;
|
||||
final List<Boolean> autocommitChanges = new ArrayList<>();
|
||||
final List<String> executed = new ArrayList<>();
|
||||
final List<String> prepared = new ArrayList<>();
|
||||
final List<Integer> timeouts = new ArrayList<>();
|
||||
final List<String> boundTags = new ArrayList<>();
|
||||
|
||||
Db2Agent agent() {
|
||||
Db2Agent agent = new Db2Agent();
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (object, method, args) -> {
|
||||
return switch (method.getName()) {
|
||||
case "isClosed" -> closed;
|
||||
case "getAutoCommit" -> autocommit;
|
||||
case "setAutoCommit" -> {
|
||||
autocommit = (Boolean) args[0];
|
||||
autocommitChanges.add(autocommit);
|
||||
yield null;
|
||||
}
|
||||
case "rollback" -> {
|
||||
rollbacks++;
|
||||
if (rollbackError != null) throw rollbackError;
|
||||
yield null;
|
||||
}
|
||||
case "commit" -> { commits++; yield null; }
|
||||
case "close" -> { closed = true; yield null; }
|
||||
case "createStatement" -> {
|
||||
nativeStatementCreations++;
|
||||
if (nativeStatementCreations == 3 && gapStarted != null) {
|
||||
gapStarted.countDown();
|
||||
if (!gapResume.await(5, TimeUnit.SECONDS)) throw new SQLException("Native gap was not released");
|
||||
}
|
||||
yield statement(null);
|
||||
}
|
||||
case "prepareStatement" -> {
|
||||
String query = (String) args[0];
|
||||
prepared.add(query);
|
||||
yield statement(query);
|
||||
}
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
}));
|
||||
return agent;
|
||||
}
|
||||
|
||||
Statement statement(String query) {
|
||||
openStatements++;
|
||||
Map<Integer, String> params = new LinkedHashMap<>();
|
||||
return proxy(PreparedStatement.class, (object, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "setQueryTimeout": timeouts.add((Integer) args[0]); return null;
|
||||
case "setMaxRows": return null;
|
||||
case "cancel": cancelCalls++; if (cancelSignal != null) cancelSignal.countDown(); return null;
|
||||
case "setString": params.put((Integer) args[0], (String) args[1]); return null;
|
||||
case "close": openStatements--; return null;
|
||||
case "execute":
|
||||
String sql = (String) args[0];
|
||||
executed.add(sql);
|
||||
if (sql.startsWith("EXPLAIN") && explainStarted != null) {
|
||||
explainStarted.countDown();
|
||||
if (!cancelSignal.await(5, TimeUnit.SECONDS)) throw new SQLException("Native Statement was not cancelled", "HYT00");
|
||||
throw new SQLException("Native Statement cancelled", "57014", -952);
|
||||
}
|
||||
if (sql.startsWith("EXPLAIN") && explainError != null) throw explainError;
|
||||
if (sql.startsWith("SET SCHEMA")) {
|
||||
schema = sql.substring("SET SCHEMA ".length() + 1, sql.length() - 1).replace("\"\"", "\"");
|
||||
}
|
||||
return false;
|
||||
case "executeQuery":
|
||||
if (query == null) {
|
||||
executed.add((String) args[0]);
|
||||
return rows(new String[][]{{"AUTH_USER", schema}});
|
||||
}
|
||||
if (query.contains("SYSCAT.TABLES")) {
|
||||
catalogRequester = params.get(1);
|
||||
return rows(explainSchema == null ? new String[0][] : new String[][]{{explainSchema}});
|
||||
}
|
||||
boundTags.add(params.get(2));
|
||||
if (!query.contains(" r JOIN ")) {
|
||||
String[][] values = new String[statementCount][];
|
||||
Arrays.fill(values, new String[]{"AUTH_USER", "2026-10-07 01:02:03.123456", "SQLC2P31", "NULLID", "", "P", "1", "201"});
|
||||
return rows(values);
|
||||
}
|
||||
if (query.contains("\"EXPLAIN_OPERATOR\"")) {
|
||||
return rows(operatorCount == 0 ? new String[0][] :
|
||||
new String[][]{{"1", "TBSCAN", "12345678901234567890.123", "1.25", "999", "2.125"}});
|
||||
}
|
||||
if (query.contains("\"EXPLAIN_STREAM\"")) {
|
||||
return rows(new String[][]{{"0", "D", "-1", "O", "1", "200.5", "APP", "ORDERS"}});
|
||||
}
|
||||
if (query.contains("\"EXPLAIN_PREDICATE\"")) {
|
||||
return rows(new String[][]{{"1", "(Q1.NOTE = ';')", "SARG "}});
|
||||
}
|
||||
throw new AssertionError(query);
|
||||
default: return defaultValue(method.getReturnType());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ResultSet rows(String[][] values) {
|
||||
openResultSets++;
|
||||
int[] cursor = {-1};
|
||||
return proxy(ResultSet.class, (object, method, args) -> {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> ++cursor[0] < values.length;
|
||||
case "getString" -> values[cursor[0]][(Integer) args[0] - 1];
|
||||
case "close" -> { openResultSets--; yield null; }
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == boolean.class) return false;
|
||||
if (type == int.class) return 0;
|
||||
if (type == long.class) return 0L;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Generated
+76
-12
@@ -160,6 +160,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -236,7 +242,7 @@ checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"half",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
@@ -341,6 +347,17 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
@@ -794,9 +811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -1408,6 +1425,7 @@ dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
@@ -1577,6 +1595,7 @@ dependencies = [
|
||||
"serde",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2058,6 +2077,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.2.0"
|
||||
@@ -3545,7 +3584,7 @@ dependencies = [
|
||||
"darling",
|
||||
"heck",
|
||||
"manyhow",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3600,7 +3639,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"mysql-common-derive",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
@@ -3686,6 +3725,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -3919,6 +3968,12 @@ dependencies = [
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -4612,22 +4667,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.7"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"crc16",
|
||||
"futures-sink",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.2",
|
||||
"rustls 0.23.42",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"ryu",
|
||||
@@ -4637,6 +4694,7 @@ dependencies = [
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4868,7 +4926,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -5510,7 +5568,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
@@ -6991,6 +7049,12 @@ dependencies = [
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -756,6 +756,15 @@ mod tests {
|
||||
.expect("list tables");
|
||||
assert!(tables.iter().any(|table| table.name == "facts"));
|
||||
|
||||
let columns = session
|
||||
.list_columns(DuckDbWorkerColumnParams {
|
||||
database: "main".to_string(),
|
||||
schema: "warehouse".to_string(),
|
||||
table: "facts".to_string(),
|
||||
})
|
||||
.expect("list init-script attached columns");
|
||||
assert_eq!(columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(), ["id"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -832,6 +841,15 @@ mod tests {
|
||||
.expect("list tables");
|
||||
assert!(tables.iter().any(|table| table.name == "orders"));
|
||||
|
||||
let columns = session
|
||||
.list_columns(DuckDbWorkerColumnParams {
|
||||
database: "main".to_string(),
|
||||
schema: "SALES DB".to_string(),
|
||||
table: "orders".to_string(),
|
||||
})
|
||||
.expect("list SQL-attached columns");
|
||||
assert_eq!(columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(), ["id"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -319,7 +319,23 @@ pub fn duckdb_query_columns_in_database_with_attached(
|
||||
table: &str,
|
||||
attached_names: &[String],
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
let use_primary_catalog = database.trim().is_empty() || database == "main";
|
||||
let database = duckdb_catalog_name(con, database, attached_names)?;
|
||||
let columns = duckdb_query_columns_in_catalog(con, &database, schema, table)?;
|
||||
if columns.is_empty() && use_primary_catalog {
|
||||
if let Some(attached) = attached_names.iter().find(|name| name.eq_ignore_ascii_case(schema)) {
|
||||
return duckdb_query_columns_in_catalog(con, attached, "main", table);
|
||||
}
|
||||
}
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
fn duckdb_query_columns_in_catalog(
|
||||
con: &duckdb::Connection,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
let mut pk_stmt = con
|
||||
.prepare(
|
||||
"SELECT kcu.column_name
|
||||
@@ -335,11 +351,10 @@ pub fn duckdb_query_columns_in_database_with_attached(
|
||||
ORDER BY kcu.ordinal_position",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pk_rows = pk_stmt
|
||||
.query_map((database.as_str(), schema, table), |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pk_rows =
|
||||
pk_stmt.query_map((database, schema, table), |row| row.get::<_, String>(0)).map_err(|e| e.to_string())?;
|
||||
let primary_keys: std::collections::HashSet<String> = pk_rows.filter_map(|r| r.ok()).collect();
|
||||
let column_comments = duckdb_column_comments(con, &database, schema, table);
|
||||
let column_comments = duckdb_column_comments(con, database, schema, table);
|
||||
|
||||
let mut stmt = con
|
||||
.prepare(
|
||||
@@ -350,7 +365,7 @@ pub fn duckdb_query_columns_in_database_with_attached(
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map((database.as_str(), schema, table), |row| {
|
||||
.query_map((database, schema, table), |row| {
|
||||
let name = row.get::<_, String>(0)?;
|
||||
let comment = column_comments.get(&name).cloned().flatten();
|
||||
Ok(db::ColumnInfo {
|
||||
@@ -370,8 +385,8 @@ pub fn duckdb_query_columns_in_database_with_attached(
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let columns: Vec<db::ColumnInfo> = rows.filter_map(|r| r.ok()).collect();
|
||||
if columns.is_empty() && duckdb_is_quack_catalog(con, &database) {
|
||||
if let Some(remote) = duckdb_quack_remote_columns(con, &database, schema, table) {
|
||||
if columns.is_empty() && duckdb_is_quack_catalog(con, database) {
|
||||
if let Some(remote) = duckdb_quack_remote_columns(con, database, schema, table) {
|
||||
return Ok(remote);
|
||||
}
|
||||
}
|
||||
@@ -638,6 +653,111 @@ mod tests {
|
||||
assert_eq!(name.comment.as_deref(), Some("Display name"));
|
||||
}
|
||||
|
||||
fn duckdb_attached_columns_fixture() -> (duckdb::Connection, Vec<String>) {
|
||||
let con = duckdb::Connection::open_in_memory().unwrap();
|
||||
duckdb_attach_database(&con, "cost_db", ":memory:").unwrap();
|
||||
duckdb_attach_database(&con, "other_db", ":memory:").unwrap();
|
||||
con.execute_batch(
|
||||
"CREATE TABLE memory.main.md_bom(primary_id INTEGER); \
|
||||
CREATE TABLE cost_db.main.md_bom(product_id INTEGER PRIMARY KEY, description VARCHAR DEFAULT 'part'); \
|
||||
COMMENT ON COLUMN cost_db.main.md_bom.description IS 'Product description'; \
|
||||
CREATE TABLE other_db.main.md_bom(other_id INTEGER); \
|
||||
CREATE TABLE other_db.main.other_only(id INTEGER);",
|
||||
)
|
||||
.unwrap();
|
||||
(con, vec!["cost_db".to_string(), "other_db".to_string()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_query_columns_resolves_attached_catalog_as_schema() {
|
||||
let (con, attached_names) = duckdb_attached_columns_fixture();
|
||||
con.execute_batch("SELECT product_id FROM cost_db.md_bom").unwrap();
|
||||
|
||||
for database in ["main", "", " "] {
|
||||
for schema in ["cost_db", "COST_DB"] {
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, database, schema, "md_bom", &attached_names)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(),
|
||||
["product_id", "description"]
|
||||
);
|
||||
assert!(columns[0].is_primary_key);
|
||||
assert!(!columns[0].is_nullable);
|
||||
assert_eq!(columns[0].data_type, "INTEGER");
|
||||
assert!(!columns[1].is_primary_key);
|
||||
assert!(columns[1].is_nullable);
|
||||
assert_eq!(columns[1].column_default.as_deref(), Some("'part'"));
|
||||
assert_eq!(columns[1].comment.as_deref(), Some("Product description"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_query_columns_prefers_existing_schema_table_over_attached_catalog() {
|
||||
let (con, attached_names) = duckdb_attached_columns_fixture();
|
||||
con.execute_batch("CREATE SCHEMA memory.cost_db; CREATE TABLE memory.cost_db.md_bom(schema_id INTEGER);")
|
||||
.unwrap();
|
||||
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, "main", "cost_db", "md_bom", &attached_names).unwrap();
|
||||
assert_eq!(columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(), ["schema_id"]);
|
||||
|
||||
con.execute_batch("DROP TABLE memory.cost_db.md_bom").unwrap();
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, "main", "cost_db", "md_bom", &attached_names).unwrap();
|
||||
assert_eq!(
|
||||
columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(),
|
||||
["product_id", "description"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_query_columns_keeps_explicit_catalog_and_default_schema() {
|
||||
let (con, attached_names) = duckdb_attached_columns_fixture();
|
||||
for (database, expected) in [("main", "primary_id"), ("memory", "primary_id"), ("other_db", "other_id")] {
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, database, "main", "md_bom", &attached_names)
|
||||
.unwrap();
|
||||
assert_eq!(columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(), [expected]);
|
||||
}
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, "cost_db", "main", "md_bom", &attached_names).unwrap();
|
||||
assert_eq!(
|
||||
columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(),
|
||||
["product_id", "description"]
|
||||
);
|
||||
|
||||
for database in ["memory", "other_db", "cost_db", "unknown"] {
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, database, "cost_db", "md_bom", &attached_names)
|
||||
.unwrap();
|
||||
assert!(columns.is_empty(), "must not redirect explicit {database}.cost_db.md_bom");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_query_columns_isolates_attached_catalog_fallback() {
|
||||
let (con, attached_names) = duckdb_attached_columns_fixture();
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, "main", "other_db", "md_bom", &attached_names)
|
||||
.unwrap();
|
||||
assert_eq!(columns.iter().map(|column| column.name.as_str()).collect::<Vec<_>>(), ["other_id"]);
|
||||
|
||||
for (schema, table) in [("cost_db", "missing"), ("cost_db", "other_only"), ("unknown", "md_bom")] {
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, "main", schema, table, &attached_names).unwrap();
|
||||
assert!(columns.is_empty(), "unexpected columns for {schema}.{table}");
|
||||
}
|
||||
let columns = duckdb_query_columns_in_database_with_attached(&con, "main", "cost_db", "md_bom", &[]).unwrap();
|
||||
assert!(columns.is_empty());
|
||||
|
||||
con.execute_batch("DETACH cost_db").unwrap();
|
||||
let columns =
|
||||
duckdb_query_columns_in_database_with_attached(&con, "main", "cost_db", "md_bom", &attached_names).unwrap();
|
||||
assert!(columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_table_ddl_filters_by_database_schema_and_table_name() {
|
||||
let unique = uuid::Uuid::new_v4();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.exasol:exasol-jdbc:26.2.7'
|
||||
implementation 'com.exasol:exasol-jdbc:26.2.9'
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
|
||||
@@ -1,16 +1,195 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.JdbcAgentProfile;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import java.nio.charset.Charset;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
private static final String COMMON_OBJECTS_SQL = """
|
||||
SELECT TRIM(RDB$GENERATOR_NAME) AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS OBJECT_COMMENT
|
||||
FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
UNION ALL
|
||||
SELECT TRIM(RDB$TRIGGER_NAME), 'TRIGGER', TRIM(RDB$RELATION_NAME)
|
||||
FROM RDB$TRIGGERS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
UNION ALL
|
||||
SELECT TRIM(RDB$INDEX_NAME), 'INDEX', TRIM(RDB$RELATION_NAME)
|
||||
FROM RDB$INDICES WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
""".stripIndent().trim();
|
||||
|
||||
static String catalogObjectsSql(int majorVersion) {
|
||||
if (majorVersion < 3) {
|
||||
return COMMON_OBJECTS_SQL + " UNION ALL SELECT TRIM(RDB$FUNCTION_NAME), 'FUNCTION_UDF', TRIM(RDB$MODULE_NAME) FROM RDB$FUNCTIONS WHERE COALESCE(RDB$SYSTEM_FLAG,0)=0";
|
||||
}
|
||||
return COMMON_OBJECTS_SQL + " UNION ALL " + """
|
||||
SELECT TRIM(RDB$PACKAGE_NAME), 'PACKAGE', NULL
|
||||
FROM RDB$PACKAGES WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
UNION ALL
|
||||
SELECT CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME)
|
||||
ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END,
|
||||
CASE WHEN NULLIF(TRIM(RDB$MODULE_NAME),'') IS NULL AND NULLIF(TRIM(RDB$ENGINE_NAME),'') IS NULL
|
||||
THEN 'FUNCTION_INTERNAL' ELSE 'FUNCTION_UDF' END,
|
||||
COALESCE(NULLIF(TRIM(RDB$MODULE_NAME), ''), NULLIF(TRIM(RDB$ENGINE_NAME), ''))
|
||||
FROM RDB$FUNCTIONS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0
|
||||
""".stripIndent().trim();
|
||||
}
|
||||
private Charset dataCharset;
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
if (table == null || table.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String normalizedTable = table.trim();
|
||||
if (normalizedTable.startsWith("\"") && normalizedTable.endsWith("\"") && normalizedTable.length() > 1) {
|
||||
normalizedTable = normalizedTable.substring(1, normalizedTable.length() - 1);
|
||||
}
|
||||
final String searchTable = normalizedTable;
|
||||
return unchecked(() -> {
|
||||
List<TriggerInfo> triggers = new ArrayList<>();
|
||||
try (PreparedStatement statement = requireConnection().prepareStatement(
|
||||
"SELECT TRIM(RDB$TRIGGER_NAME), RDB$TRIGGER_TYPE FROM RDB$TRIGGERS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND COALESCE(RDB$SYSTEM_FLAG,0)=0 ORDER BY RDB$TRIGGER_NAME")) {
|
||||
statement.setString(1, searchTable);
|
||||
statement.setString(2, searchTable);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
int type = result.getInt(2);
|
||||
triggers.add(new TriggerInfo(result.getString(1), triggerEvent(type), type % 2 == 1 ? "BEFORE" : "AFTER"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return triggers;
|
||||
});
|
||||
}
|
||||
|
||||
static String triggerEvent(int type) {
|
||||
return switch (type) {
|
||||
case 1, 2 -> "INSERT";
|
||||
case 3, 4 -> "UPDATE";
|
||||
case 5, 6 -> "DELETE";
|
||||
case 17, 18 -> "INSERT OR UPDATE";
|
||||
case 25, 26 -> "INSERT OR DELETE";
|
||||
case 27, 28 -> "UPDATE OR DELETE";
|
||||
case 113, 114 -> "INSERT OR UPDATE OR DELETE";
|
||||
case 8192 -> "CONNECT";
|
||||
case 8193 -> "DISCONNECT";
|
||||
case 8194 -> "TRANSACTION START";
|
||||
case 8195 -> "TRANSACTION COMMIT";
|
||||
case 8196 -> "TRANSACTION ROLLBACK";
|
||||
default -> Integer.toString(type);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildJdbcUrl(ConnectParams params) {
|
||||
String url = super.buildJdbcUrl(params);
|
||||
LegacyTextDecoder.charsetFromUrl(url); // Fail invalid configuration before opening a connection.
|
||||
return LegacyTextDecoder.jdbcUrl(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterConnect(ConnectParams params, Connection connection) {
|
||||
super.afterConnect(params, connection);
|
||||
dataCharset = LegacyTextDecoder.charsetFromUrl(super.buildJdbcUrl(params));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object resultValue(ResultSet resultSet, int index, int sqlType) {
|
||||
Object value = super.resultValue(resultSet, index, sqlType);
|
||||
return switch (sqlType) {
|
||||
case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR,
|
||||
Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR, Types.CLOB, Types.NCLOB ->
|
||||
value instanceof String text ? decodeLegacyText(text) : value;
|
||||
default -> value;
|
||||
};
|
||||
}
|
||||
|
||||
private String decodeLegacyText(String value) {
|
||||
return LegacyTextDecoder.decode(value, dataCharset);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
List<ColumnInfo> columns = super.getColumns(schema, table);
|
||||
for (ColumnInfo column : columns) column.setComment(decodeLegacyText(column.getComment()));
|
||||
return columns;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema) {
|
||||
return listTables(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TableInfo> listTables(String schema, MetadataListConstraints constraints) {
|
||||
List<TableInfo> tables = super.listTables(schema, constraints);
|
||||
for (TableInfo table : tables) table.setComment(decodeLegacyText(table.getComment()));
|
||||
return tables;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema) {
|
||||
return listObjects(schema, MetadataListConstraints.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
|
||||
List<ObjectInfo> objects = new ArrayList<>(super.listObjects(schema, MetadataListConstraints.NONE));
|
||||
// JDBC metadata flattens all Firebird functions into FUNCTION and does
|
||||
// not expose generators, packages, database triggers, or indexes. Use
|
||||
// the Firebird catalogs so the object browser can keep those categories
|
||||
// distinct without changing the table-scoped metadata APIs.
|
||||
for (Iterator<ObjectInfo> iterator = objects.iterator(); iterator.hasNext();) {
|
||||
ObjectInfo object = iterator.next();
|
||||
if ("FUNCTION".equalsIgnoreCase(object.getObject_type())) iterator.remove();
|
||||
}
|
||||
unchecked(() -> {
|
||||
try (PreparedStatement statement = requireConnection().prepareStatement(catalogObjectsSql(requireConnection().getMetaData().getDatabaseMajorVersion()));
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
String name = trimToNull(resultSet.getString("OBJECT_NAME"));
|
||||
String type = trimToNull(resultSet.getString("OBJECT_TYPE"));
|
||||
if (name == null || type == null) continue;
|
||||
String comment = decodeLegacyText(trimToNull(resultSet.getString("OBJECT_COMMENT")));
|
||||
ObjectInfo object = new ObjectInfo(name, type, schema, comment);
|
||||
if ("INDEX".equals(type) || "TRIGGER".equals(type)) {
|
||||
object.setParent_name(comment);
|
||||
object.setComment(null);
|
||||
}
|
||||
objects.add(object);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
for (ObjectInfo object : objects) object.setComment(decodeLegacyText(object.getComment()));
|
||||
MetadataListConstraints normalized = MetadataListConstraints.orNone(constraints);
|
||||
// Preserve the existing FUNCTION metadata request used by older clients
|
||||
// while exposing the two precise categories to the new object browser.
|
||||
if (normalized.getObjectTypes() != null && normalized.getObjectTypes().contains("FUNCTION")) {
|
||||
for (ObjectInfo object : objects) {
|
||||
if (object.getObject_type().startsWith("FUNCTION_")) object.setObject_type("FUNCTION");
|
||||
}
|
||||
}
|
||||
objects.sort(Comparator.comparing(ObjectInfo::getName).thenComparing(ObjectInfo::getObject_type));
|
||||
return normalized.filterObjects(objects);
|
||||
}
|
||||
static final String PROCEDURE_SOURCE_SQL = """
|
||||
SELECT
|
||||
CASE WHEN METADATA_ROW = 1 THEN PROCEDURE_SOURCE ELSE NULL END AS PROCEDURE_SOURCE,
|
||||
@@ -69,6 +248,10 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
String requestedType = normalizeObjectSourceType(objectType);
|
||||
if (!"PROCEDURE".equals(requestedType)) {
|
||||
return unchecked(() -> FirebirdObjectSource.read(requireConnection(), schema, name, requestedType, this::decodeLegacyText));
|
||||
}
|
||||
String normalizedType = normalizeObjectSourceType(objectType);
|
||||
return unchecked(() -> {
|
||||
String body = null;
|
||||
@@ -81,7 +264,7 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
while (resultSet.next()) {
|
||||
found = true;
|
||||
if (body == null) {
|
||||
body = resultSet.getString("PROCEDURE_SOURCE");
|
||||
body = decodeLegacyText(resultSet.getString("PROCEDURE_SOURCE"));
|
||||
}
|
||||
String parameterName = trimToNull(resultSet.getString("PARAMETER_NAME"));
|
||||
if (parameterName == null) {
|
||||
@@ -123,7 +306,7 @@ public final class FirebirdAgent extends ConfiguredJdbcAgent {
|
||||
throw new IllegalArgumentException("Unsupported object type: null");
|
||||
}
|
||||
String normalized = objectType.trim().toUpperCase(Locale.ROOT);
|
||||
if (!"PROCEDURE".equals(normalized)) {
|
||||
if (!List.of("PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "VIEW").contains(normalized)) {
|
||||
throw new IllegalArgumentException("Unsupported object type: " + objectType);
|
||||
}
|
||||
return normalized;
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/** Read-only catalog source: stored routine bodies are never advertised as executable DDL. */
|
||||
final class FirebirdObjectSource {
|
||||
static ObjectSource read(Connection connection, String schema, String name, String type,
|
||||
UnaryOperator<String> decode) throws SQLException {
|
||||
String targetName = name != null ? name.trim() : "";
|
||||
if (targetName.startsWith("\"") && targetName.endsWith("\"") && targetName.length() > 1) {
|
||||
targetName = targetName.substring(1, targetName.length() - 1);
|
||||
}
|
||||
String sql = switch (type) {
|
||||
case "FUNCTION" -> "SELECT RDB$FUNCTION_SOURCE, RDB$MODULE_NAME, RDB$ENTRYPOINT, RDB$ENGINE_NAME FROM RDB$FUNCTIONS WHERE CASE WHEN RDB$PACKAGE_NAME IS NULL THEN TRIM(RDB$FUNCTION_NAME) ELSE TRIM(RDB$PACKAGE_NAME) || '.' || TRIM(RDB$FUNCTION_NAME) END = ?";
|
||||
case "TRIGGER" -> "SELECT RDB$TRIGGER_SOURCE, TRIM(RDB$RELATION_NAME), RDB$TRIGGER_TYPE, RDB$TRIGGER_SEQUENCE, RDB$TRIGGER_INACTIVE FROM RDB$TRIGGERS WHERE (TRIM(RDB$TRIGGER_NAME) = ? OR TRIM(RDB$TRIGGER_NAME) = UPPER(?))";
|
||||
case "PACKAGE", "PACKAGE_BODY" -> "SELECT RDB$PACKAGE_HEADER_SOURCE, RDB$PACKAGE_BODY_SOURCE FROM RDB$PACKAGES WHERE (TRIM(RDB$PACKAGE_NAME) = ? OR TRIM(RDB$PACKAGE_NAME) = UPPER(?))";
|
||||
case "SEQUENCE" -> "SELECT RDB$GENERATOR_NAME, RDB$INITIAL_VALUE, RDB$GENERATOR_INCREMENT FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?";
|
||||
case "VIEW" -> "SELECT RDB$VIEW_SOURCE FROM RDB$RELATIONS WHERE (TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)) AND RDB$VIEW_BLR IS NOT NULL";
|
||||
default -> throw new IllegalArgumentException("Unsupported Firebird object type: " + type);
|
||||
};
|
||||
boolean legacy = connection.getMetaData().getDatabaseMajorVersion() < 3;
|
||||
if (legacy && "FUNCTION".equals(type)) {
|
||||
sql = "SELECT CAST(NULL AS VARCHAR(1)), RDB$MODULE_NAME, RDB$ENTRYPOINT, CAST(NULL AS VARCHAR(1)) FROM RDB$FUNCTIONS WHERE (TRIM(RDB$FUNCTION_NAME) = ? OR TRIM(RDB$FUNCTION_NAME) = UPPER(?))";
|
||||
} else if (legacy && "SEQUENCE".equals(type)) {
|
||||
sql = "SELECT RDB$GENERATOR_NAME, 0, 1 FROM RDB$GENERATORS WHERE RDB$GENERATOR_NAME = ?";
|
||||
}
|
||||
String source = "";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, targetName);
|
||||
if (sql.contains("UPPER(?)")) {
|
||||
statement.setString(2, targetName);
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
if (result.next()) {
|
||||
String quoted = "\"" + targetName.replace("\"", "\"\"") + "\"";
|
||||
source = switch (type) {
|
||||
case "FUNCTION" -> functionSource(result, decode);
|
||||
case "TRIGGER" -> triggerSource(result, targetName, decode);
|
||||
case "PACKAGE", "PACKAGE_BODY" -> packageSource(result, quoted, decode);
|
||||
case "SEQUENCE" -> legacy ? "-- Generator: " + quoted
|
||||
: "CREATE SEQUENCE " + quoted + " START WITH " + result.getLong(2)
|
||||
+ " INCREMENT BY " + result.getLong(3) + ";";
|
||||
case "VIEW" -> "-- Stored view query\n" + text(decode.apply(result.getString(1)));
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ObjectSource(targetName, type, schema, source, false);
|
||||
}
|
||||
|
||||
private static String triggerSource(ResultSet result, String name, UnaryOperator<String> decode) throws SQLException {
|
||||
String relation = result.getString(2);
|
||||
int type = result.getInt(3);
|
||||
int position = result.getInt(4);
|
||||
int inactive = result.getInt(5);
|
||||
String body = text(decode.apply(result.getString(1)));
|
||||
String rel = relation != null ? relation.trim() : "";
|
||||
String trimmedBody = body.stripLeading();
|
||||
if ((trimmedBody.regionMatches(true, 0, "CREATE", 0, 6) && (trimmedBody.length() == 6 || Character.isWhitespace(trimmedBody.charAt(6))))
|
||||
|| (trimmedBody.regionMatches(true, 0, "ALTER", 0, 5) && (trimmedBody.length() == 5 || Character.isWhitespace(trimmedBody.charAt(5))))) {
|
||||
return body;
|
||||
}
|
||||
String quoted = "\"" + name.replace("\"", "\"\"") + "\"";
|
||||
StringBuilder ddl = new StringBuilder("CREATE OR ALTER TRIGGER ").append(quoted);
|
||||
if (!rel.isEmpty()) {
|
||||
ddl.append(" FOR \"").append(rel.replace("\"", "\"\"")).append("\"");
|
||||
}
|
||||
ddl.append(inactive == 1 ? " INACTIVE\n" : " ACTIVE\n");
|
||||
if (type >= 8192) {
|
||||
ddl.append("ON ").append(FirebirdAgent.triggerEvent(type));
|
||||
} else {
|
||||
String timing = (type % 2 == 1) ? "BEFORE" : "AFTER";
|
||||
String event = FirebirdAgent.triggerEvent(type);
|
||||
ddl.append(timing).append(" ").append(event);
|
||||
}
|
||||
if (position > 0) {
|
||||
ddl.append(" POSITION ").append(position);
|
||||
}
|
||||
ddl.append("\n");
|
||||
if (trimmedBody.regionMatches(true, 0, "AS", 0, 2) && (trimmedBody.length() == 2 || Character.isWhitespace(trimmedBody.charAt(2)))) {
|
||||
ddl.append(trimmedBody);
|
||||
} else if (!trimmedBody.isEmpty()) {
|
||||
ddl.append("AS\n").append(trimmedBody);
|
||||
} else {
|
||||
ddl.append("AS\nBEGIN\nEND");
|
||||
}
|
||||
return ddl.toString().stripTrailing();
|
||||
}
|
||||
|
||||
private static String functionSource(ResultSet result, UnaryOperator<String> decode) throws SQLException {
|
||||
String body = decode.apply(result.getString(1));
|
||||
if (body != null && !body.isBlank()) return "-- Stored function body\n" + body;
|
||||
if (text(result.getString(2)).isBlank() && text(result.getString(4)).isBlank()) {
|
||||
return "-- No stored function body is available.";
|
||||
}
|
||||
return "-- External function\n-- Module: " + text(result.getString(2))
|
||||
+ "\n-- Entry point: " + text(result.getString(3))
|
||||
+ "\n-- Engine: " + text(result.getString(4));
|
||||
}
|
||||
|
||||
private static String packageSource(ResultSet result, String name, UnaryOperator<String> decode) throws SQLException {
|
||||
String header = decode.apply(result.getString(1));
|
||||
String body = decode.apply(result.getString(2));
|
||||
return "CREATE OR ALTER PACKAGE " + name + " AS\n" + text(header)
|
||||
+ (body == null ? "" : "\n\nCREATE OR ALTER PACKAGE BODY " + name + " AS\n" + body);
|
||||
}
|
||||
|
||||
private static String text(String value) {
|
||||
return value == null ? "" : value.stripTrailing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Explicit, read-only recovery of legacy text stored as Latin-1 byte containers. */
|
||||
final class LegacyTextDecoder {
|
||||
static Charset charsetFromUrl(String url) {
|
||||
int query = url.indexOf('?');
|
||||
String charset = null;
|
||||
if (query >= 0) {
|
||||
for (String parameter : url.substring(query + 1).split("[&;]")) {
|
||||
String[] pair = parameter.split("=", 2);
|
||||
if (URLDecoder.decode(pair[0], StandardCharsets.UTF_8).equalsIgnoreCase("dataCharset")) {
|
||||
charset = pair.length == 2 ? URLDecoder.decode(pair[1], StandardCharsets.UTF_8).trim() : "";
|
||||
}
|
||||
}
|
||||
}
|
||||
return charset == null || charset.isEmpty() ? null : Charset.forName(charset);
|
||||
}
|
||||
|
||||
static String jdbcUrl(String url) {
|
||||
int query = url.indexOf('?');
|
||||
if (query < 0) return url;
|
||||
java.util.List<String> parameters = new java.util.ArrayList<>();
|
||||
boolean removed = false;
|
||||
for (String parameter : url.substring(query + 1).split("[&;]")) {
|
||||
String key = URLDecoder.decode(parameter.split("=", 2)[0], StandardCharsets.UTF_8);
|
||||
if (key.equalsIgnoreCase("dataCharset")) removed = true;
|
||||
else if (!parameter.isEmpty()) parameters.add(parameter);
|
||||
}
|
||||
if (!removed) return url;
|
||||
return url.substring(0, query) + (parameters.isEmpty() ? "" : "?" + String.join("&", parameters));
|
||||
}
|
||||
|
||||
static String decode(String value, Charset charset) {
|
||||
if (value == null || charset == null) return value;
|
||||
boolean highByte = false;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char character = value.charAt(i);
|
||||
// Never truncate Unicode characters or replacement characters into bytes.
|
||||
if (character > 255) return value;
|
||||
highByte |= character > 127;
|
||||
}
|
||||
if (!highByte) return value;
|
||||
try {
|
||||
return charset.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(value.getBytes(StandardCharsets.ISO_8859_1))).toString();
|
||||
} catch (CharacterCodingException ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-2
@@ -1,6 +1,7 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -333,10 +334,46 @@ class FirebirdAgentTest {
|
||||
|
||||
IllegalArgumentException error = Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> agent.getObjectSource(null, "P_6141", "VIEW")
|
||||
() -> agent.getObjectSource(null, "P_6141", "UNKNOWN")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("Unsupported object type: VIEW", error.getMessage());
|
||||
Assertions.assertEquals("Unsupported object type: UNKNOWN", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTriggersNormalizesTableNameAndMapsTriggerTypes() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> parameters = new ArrayList<>();
|
||||
FirebirdAgent agent = agentWithRows(
|
||||
sql,
|
||||
parameters,
|
||||
row(
|
||||
"1", "TR_ORDERS_BI",
|
||||
"2", 1
|
||||
),
|
||||
row(
|
||||
"1", "TR_ORDERS_AD",
|
||||
"2", 6
|
||||
)
|
||||
);
|
||||
|
||||
List<TriggerInfo> triggers = agent.listTriggers(null, "\"orders\"");
|
||||
Assertions.assertEquals(2, triggers.size());
|
||||
Assertions.assertEquals("TR_ORDERS_BI", triggers.get(0).getName());
|
||||
Assertions.assertEquals("INSERT", triggers.get(0).getEvent());
|
||||
Assertions.assertEquals("BEFORE", triggers.get(0).getTiming());
|
||||
Assertions.assertEquals("TR_ORDERS_AD", triggers.get(1).getName());
|
||||
Assertions.assertEquals("DELETE", triggers.get(1).getEvent());
|
||||
Assertions.assertEquals("AFTER", triggers.get(1).getTiming());
|
||||
Assertions.assertEquals(List.of("1=orders", "2=orders"), parameters);
|
||||
Assertions.assertTrue(sql.get(0).contains("TRIM(RDB$RELATION_NAME) = ? OR TRIM(RDB$RELATION_NAME) = UPPER(?)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTriggersReturnsEmptyForNullOrBlankTable() {
|
||||
FirebirdAgent agent = new FirebirdAgent();
|
||||
Assertions.assertTrue(agent.listTriggers(null, null).isEmpty());
|
||||
Assertions.assertTrue(agent.listTriggers(null, " ").isEmpty());
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FirebirdObjectSourceTest {
|
||||
@Test
|
||||
void generatorSourceNeverReadsOrIncrementsCurrentValue() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(connection(calls, "G\"ID", 10L, 5L), "", "G\"ID", "SEQUENCE", value -> value);
|
||||
assertEquals("CREATE SEQUENCE \"G\"\"ID\" START WITH 10 INCREMENT BY 5;", source.getSource());
|
||||
assertFalse(source.isEditable());
|
||||
assertEquals("G\"ID", calls.get(1));
|
||||
assertFalse(calls.get(0).contains("GEN_ID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void externalFunctionsShowModuleEntrypointAndEngineWithoutCallingUdf() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(connection(calls, null, "FreeAdhocUDF", "f_abs", null), "", "F_ABS", "FUNCTION", value -> value);
|
||||
assertTrue(source.getSource().contains("Module: FreeAdhocUDF"));
|
||||
assertTrue(source.getSource().contains("Entry point: f_abs"));
|
||||
assertTrue(calls.get(0).startsWith("SELECT "));
|
||||
assertFalse(source.isEditable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedFunctionsUseQualifiedCatalogIdentityAndDecodeOnlyBody() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(connection(calls, "²Ý¸å", null, null, null), "", "MYUDR2.FN", "FUNCTION",
|
||||
value -> LegacyTextDecoder.decode(value, java.nio.charset.Charset.forName("GBK")));
|
||||
assertEquals("MYUDR2.FN", calls.get(1));
|
||||
assertTrue(calls.get(0).contains("RDB$PACKAGE_NAME"));
|
||||
assertTrue(source.getSource().contains("草稿"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableTriggerSourceBuildsStandardCreateOrAlterTriggerDdl() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(
|
||||
connection(calls, "AS\nBEGIN\n NEW.ID = 1;\nEND", "ORDERS", 1, 0, 0),
|
||||
"", "TR_ORDERS_BI", "TRIGGER", value -> value
|
||||
);
|
||||
assertEquals(
|
||||
"CREATE OR ALTER TRIGGER \"TR_ORDERS_BI\" FOR \"ORDERS\" ACTIVE\nBEFORE INSERT\nAS\nBEGIN\n NEW.ID = 1;\nEND",
|
||||
source.getSource()
|
||||
);
|
||||
assertEquals("TR_ORDERS_BI", source.getName());
|
||||
assertEquals("TRIGGER", source.getObject_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseTriggerSourceFormatsEventAndTiming() throws Exception {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ObjectSource source = FirebirdObjectSource.read(
|
||||
connection(calls, "BEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND", null, 8192, 1, 1),
|
||||
"", "TR_CONN", "TRIGGER", value -> value
|
||||
);
|
||||
assertEquals(
|
||||
"CREATE OR ALTER TRIGGER \"TR_CONN\" INACTIVE\nON CONNECT POSITION 1\nAS\nBEGIN\n EXECUTE PROCEDURE LOG_CONN;\nEND",
|
||||
source.getSource()
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection connection(List<String> calls, Object... values) {
|
||||
ResultSet result = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class<?>[] {ResultSet.class},
|
||||
new java.lang.reflect.InvocationHandler() {
|
||||
boolean first = true;
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> { boolean value = first; first = false; yield value; }
|
||||
case "getString" -> { Object value = values[(Integer) args[0] - 1]; yield value == null ? null : value.toString(); }
|
||||
case "getInt" -> ((Number) values[(Integer) args[0] - 1]).intValue();
|
||||
case "getLong" -> ((Number) values[(Integer) args[0] - 1]).longValue();
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
}
|
||||
});
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(PreparedStatement.class.getClassLoader(), new Class<?>[] {PreparedStatement.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "setString" -> { calls.add((String) args[1]); yield null; }
|
||||
case "executeQuery" -> result;
|
||||
case "close" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[] {Connection.class},
|
||||
(proxy, method, args) -> {
|
||||
if (method.getName().equals("getMetaData")) {
|
||||
return Proxy.newProxyInstance(java.sql.DatabaseMetaData.class.getClassLoader(), new Class<?>[] {java.sql.DatabaseMetaData.class},
|
||||
(metadata, operation, arguments) -> {
|
||||
if (operation.getName().equals("getDatabaseMajorVersion")) return 5;
|
||||
throw new UnsupportedOperationException(operation.getName());
|
||||
});
|
||||
}
|
||||
if (method.getName().equals("prepareStatement")) { calls.add((String) args[0]); return statement; }
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.dbx.agent.firebird;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LegacyTextDecoderTest {
|
||||
private static final Charset GBK = Charset.forName("GBK");
|
||||
|
||||
@Test
|
||||
void recoversUserTestRowAndColumnComments() {
|
||||
assertEquals("草稿", LegacyTextDecoder.decode("²Ý¸å", GBK));
|
||||
assertEquals("明细状态", LegacyTextDecoder.decode("Ã÷ϸ״̬", GBK));
|
||||
assertEquals("子流水", LegacyTextDecoder.decode("×ÓÁ÷Ë®", GBK));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesDefaultUnicodeAsciiNullAndMalformedData() {
|
||||
assertEquals("²Ý¸å", LegacyTextDecoder.decode("²Ý¸å", null));
|
||||
assertEquals("中文😀", LegacyTextDecoder.decode("中文😀", GBK));
|
||||
assertEquals("bad�", LegacyTextDecoder.decode("bad�", GBK));
|
||||
assertEquals("id", LegacyTextDecoder.decode("id", GBK));
|
||||
assertNull(LegacyTextDecoder.decode(null, GBK));
|
||||
assertEquals("é", LegacyTextDecoder.decode("é", GBK)); // Incomplete GBK sequence.
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsExplicitBig5AndGb18030() {
|
||||
for (String name : new String[] {"Big5", "GB18030"}) {
|
||||
Charset charset = Charset.forName(name);
|
||||
String latin1 = new String("中文".getBytes(charset), StandardCharsets.ISO_8859_1);
|
||||
assertEquals("中文", LegacyTextDecoder.decode(latin1, charset));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void separatesDbxReadOptionFromJdbcPropertiesIncludingFullUrls() {
|
||||
String url = "jdbc:firebirdsql://localhost:3050/C:/test.fdb?charSet=ISO8859_1&dataCharset=GBK&sqlDialect=3";
|
||||
assertEquals(GBK, LegacyTextDecoder.charsetFromUrl(url));
|
||||
assertEquals("jdbc:firebirdsql://localhost:3050/C:/test.fdb?charSet=ISO8859_1&sqlDialect=3", LegacyTextDecoder.jdbcUrl(url));
|
||||
assertEquals(GBK, LegacyTextDecoder.charsetFromUrl("jdbc:firebirdsql:x?DATACHARSET=%47%42%4b"));
|
||||
assertEquals("jdbc:firebirdsql:x", LegacyTextDecoder.jdbcUrl("jdbc:firebirdsql:x?dataCharset=GBK"));
|
||||
assertNull(LegacyTextDecoder.charsetFromUrl("jdbc:firebirdsql:x?dataCharset="));
|
||||
assertEquals("jdbc:firebirdsql:x?charSet=GBK;sqlDialect=3", LegacyTextDecoder.jdbcUrl("jdbc:firebirdsql:x?charSet=GBK;sqlDialect=3"));
|
||||
assertThrows(IllegalArgumentException.class, () -> LegacyTextDecoder.charsetFromUrl("jdbc:firebirdsql:x?dataCharset=not-a-charset"));
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,12 @@ public final class GoldendbAgent extends AbstractJdbcAgent {
|
||||
case Types.BIT:
|
||||
value = rs.getBoolean(index);
|
||||
break;
|
||||
case Types.BINARY:
|
||||
case Types.VARBINARY:
|
||||
case Types.LONGVARBINARY:
|
||||
case Types.BLOB:
|
||||
value = JdbcExecutor.bytesToHex(rs.getBytes(index));
|
||||
break;
|
||||
default:
|
||||
value = rs.getString(index);
|
||||
break;
|
||||
|
||||
@@ -2,6 +2,14 @@ package com.dbx.agent.goldendb;
|
||||
|
||||
import com.dbx.agent.DatabaseAgent;
|
||||
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class GoldendbAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
@Override
|
||||
@@ -13,4 +21,37 @@ class GoldendbAgentTest extends JdbcFakeExecutionBehaviorTest {
|
||||
protected String resultSetSql() {
|
||||
return "CALL sample_proc()";
|
||||
}
|
||||
|
||||
@Test
|
||||
void binaryResultsPreserveBytesInsteadOfReturningAmbiguousText() {
|
||||
GoldendbAgent agent = new GoldendbAgent();
|
||||
byte[][] values = {
|
||||
"deadbeef".getBytes(StandardCharsets.US_ASCII),
|
||||
"0x00ff".getBytes(StandardCharsets.US_ASCII),
|
||||
new byte[]{0, (byte) 0xff, (byte) 0x80},
|
||||
new byte[0]
|
||||
};
|
||||
String[] expected = {"0x6465616462656566", "0x307830306666", "0x00ff80", "0x"};
|
||||
for (int sqlType : new int[]{Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY, Types.BLOB}) {
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
assertEquals(expected[i], agent.resultValue(binaryResultSet(values[i]), 1, sqlType));
|
||||
}
|
||||
assertNull(agent.resultValue(binaryResultSet(null), 1, sqlType));
|
||||
}
|
||||
}
|
||||
|
||||
private static ResultSet binaryResultSet(byte[] bytes) {
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
GoldendbAgentTest.class.getClassLoader(),
|
||||
new Class<?>[]{ResultSet.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getBytes".equals(method.getName())) {
|
||||
assertEquals(1, args[0]);
|
||||
return bytes;
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) return bytes == null;
|
||||
throw new AssertionError("Unexpected ResultSet call: " + method.getName());
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'com.highgo:HgdbJdbc:6.2.4'
|
||||
implementation 'com.highgo:HgdbJdbc:6.2.5'
|
||||
// PostGIS JDBC is optional: EwkbWktDecoder reflectively probes for
|
||||
// org.postgis.PGgeometry / net.postgis.jdbc.PGgeometry and falls back to
|
||||
// its own EWKB parser when the class is absent. compileOnly keeps the
|
||||
|
||||
@@ -436,6 +436,7 @@ func parseHiveAssignments(raw string, lowercaseKeys bool) map[string]string {
|
||||
if decoded, err := url.QueryUnescape(key); err == nil {
|
||||
key = decoded
|
||||
}
|
||||
key = strings.TrimLeft(strings.TrimSpace(key), "?#")
|
||||
if lowercaseKeys {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
@@ -691,22 +692,27 @@ func applyHiveParameters(config *connectionConfig, values, hiveConfs map[string]
|
||||
func applyOpenSessionVariables(config *connectionConfig, values, hiveConfs, hiveVars map[string]string) {
|
||||
config.HiveConfiguration["set:hiveconf:"+resultSetUniqueColumnNames] = "false"
|
||||
for key, value := range values {
|
||||
lowerKey := strings.ToLower(key)
|
||||
cleanKey := strings.TrimPrefix(key, "#")
|
||||
lowerKey := strings.ToLower(cleanKey)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerKey, "hiveconf:"):
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key[len("hiveconf:"):])] = value
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(cleanKey[len("hiveconf:"):])] = value
|
||||
case strings.HasPrefix(lowerKey, "hivevar:"):
|
||||
config.HiveConfiguration["set:hivevar:"+key[len("hivevar:"):]] = value
|
||||
config.HiveConfiguration["set:hivevar:"+cleanKey[len("hivevar:"):]] = value
|
||||
case strings.HasPrefix(lowerKey, "kyuubi."):
|
||||
config.HiveConfiguration["set:hivevar:"+cleanKey] = value
|
||||
}
|
||||
}
|
||||
for key, value := range hiveConfs {
|
||||
if strings.EqualFold(key, "hive.server2.transport.mode") || strings.EqualFold(key, "hive.server2.thrift.http.path") {
|
||||
cleanKey := strings.TrimPrefix(key, "#")
|
||||
if strings.EqualFold(cleanKey, "hive.server2.transport.mode") || strings.EqualFold(cleanKey, "hive.server2.thrift.http.path") {
|
||||
continue
|
||||
}
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(key)] = value
|
||||
config.HiveConfiguration["set:hiveconf:"+canonicalHiveConfKey(cleanKey)] = value
|
||||
}
|
||||
for key, value := range hiveVars {
|
||||
config.HiveConfiguration["set:hivevar:"+key] = value
|
||||
cleanKey := strings.TrimPrefix(key, "#")
|
||||
config.HiveConfiguration["set:hivevar:"+cleanKey] = value
|
||||
}
|
||||
if proxyUser := firstNonEmpty(parameter(values, "proxyuser"), parameter(values, "hive.server2.proxy.user")); proxyUser != "" {
|
||||
config.HiveConfiguration["hive.server2.proxy.user"] = proxyUser
|
||||
@@ -753,11 +759,14 @@ func openSessionConfigurationValue(values map[string]string, key string) string
|
||||
// Hive JDBC-style URL fragments are sent as hive variables, while values
|
||||
// after '?' are sent as hive confs. Kyuubi strips either prefix before it
|
||||
// resolves session-scoped settings such as kyuubi.engine.type.
|
||||
for _, wanted := range []string{"set:hivevar:" + key, "set:hiveconf:" + key, key} {
|
||||
for candidate, value := range values {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), wanted) {
|
||||
return value
|
||||
}
|
||||
normalizedKey := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(key), "#"))
|
||||
for candidate, value := range values {
|
||||
candidateKey := strings.ToLower(strings.TrimSpace(candidate))
|
||||
candidateKey = strings.TrimPrefix(candidateKey, "set:hivevar:")
|
||||
candidateKey = strings.TrimPrefix(candidateKey, "set:hiveconf:")
|
||||
candidateKey = strings.TrimPrefix(candidateKey, "#")
|
||||
if candidateKey == normalizedKey {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -432,6 +432,36 @@ func TestKyuubiIdentifierQuoteFollowsPreservedEngineType(t *testing.T) {
|
||||
configuration: "set:hiveconf:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "URL param without hash selects Trino",
|
||||
params: connectParams{
|
||||
Host: "kyuubi.example.com",
|
||||
DatabaseType: "kyuubi",
|
||||
URLParams: "kyuubi.engine.type=TRINO",
|
||||
},
|
||||
configuration: "set:hivevar:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "Semicolon with hash selects Trino",
|
||||
params: connectParams{
|
||||
Host: "kyuubi.example.com",
|
||||
DatabaseType: "kyuubi",
|
||||
URLParams: ";#kyuubi.engine.type=trino",
|
||||
},
|
||||
configuration: "set:hivevar:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "Session params with auth selects Trino",
|
||||
params: connectParams{
|
||||
Host: "kyuubi.example.com",
|
||||
DatabaseType: "kyuubi",
|
||||
URLParams: "auth=NOSASL;kyuubi.engine.type=TRINO",
|
||||
},
|
||||
configuration: "set:hivevar:kyuubi.engine.type",
|
||||
identifierQuote: trinoIdentifierQuote,
|
||||
},
|
||||
{
|
||||
name: "Kyuubi Spark keeps Hive quote",
|
||||
params: connectParams{
|
||||
|
||||
@@ -90,6 +90,7 @@ func newDiscoveryConnector(config connectionConfig) *discoveryConnector {
|
||||
BrowserResponseTimeout: config.BrowserResponseTimeout,
|
||||
BrowserDisableSSLCheck: config.BrowserDisableSSLCheck,
|
||||
WaitForNonQueryCompletion: waitsForNonQueryCompletion(config.DatabaseType),
|
||||
IdentifierQuote: config.identifierQuote(),
|
||||
})
|
||||
},
|
||||
driver: &gohive.Driver{},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user